Open History to see the last commit
1import {
2 SecretProviderId,
3 SecretProviderKind,
4 SecretProviderLabel,
5 type SecretProviderConfig,
6} from "@t3tools/contracts";
7import {
8 OnePasswordSecretProviderConfig,
9 type OnePasswordAccount,
10} from "@t3tools/secret-provider-onepassword/shared/definition";
11import * as Option from "effect/Option";
12import * as Result from "effect/Result";
13import * as Schema from "effect/Schema";
14
15const decodeSecretProviderId = Schema.decodeOption(SecretProviderId);
16const decodeOnePasswordConfig = Schema.decodeUnknownOption(OnePasswordSecretProviderConfig);
17
18export type ClassifiedSecretProvider =
19 | { readonly kind: "one_password"; readonly account: OnePasswordAccount }
20 | { readonly kind: "unsupported" };
21
22/** Unknown kinds and configs that no longer decode are shown read-only; removal stays available. */
23export function classifySecretProvider(provider: SecretProviderConfig): ClassifiedSecretProvider {
24 if (provider.kind !== "one_password") return { kind: "unsupported" };
25 const decoded = decodeOnePasswordConfig(provider.config);
26 return decoded._tag === "Some"
27 ? { kind: "one_password", account: decoded.value.account }
28 : { kind: "unsupported" };
29}
30
31/** Stable and readable in settings.json, so re-adding a provider under the same name restores its variables. */
32function secretProviderIdFromName(name: string): SecretProviderId | null {
33 const slug = name
34 .toLowerCase()
35 .replace(/[^a-z0-9]+/g, "-")
36 .replace(/^-|-$/g, "")
37 .slice(0, 64)
38 .replace(/-$/, "");
39 return Option.getOrNull(decodeSecretProviderId(slug));
40}
41
42export interface SecretProviderDraft {
43 readonly name: string;
44 readonly account: string;
45}
46
47export function draftFromSecretProvider(provider: SecretProviderConfig): SecretProviderDraft {
48 const classified = classifySecretProvider(provider);
49 return {
50 name: provider.label,
51 account: classified.kind === "one_password" ? classified.account : "",
52 };
53}
54
55export type SecretProviderTarget =
56 | { readonly mode: "add" }
57 | { readonly mode: "edit"; readonly id: SecretProviderId };
58
59export type SecretProviderDraftIssues = {
60 readonly [Field in keyof SecretProviderDraft]?: string;
61};
62
63function decodeField<S extends Schema.ConstraintDecoder<unknown>>(
64 schema: S,
65 value: string,
66 emptyMessage: string,
67): Result.Result<S["Type"], string> {
68 const trimmed = value.trim();
69 if (trimmed.length === 0) return Result.fail(emptyMessage);
70 return Result.mapError(Schema.decodeResult(schema)(trimmed), (error) => error.message);
71}
72
73/** The settings entry a draft adds or replaces, or every field that blocks it, one message each. */
74export function secretProviderFromDraft(
75 draft: SecretProviderDraft,
76 existingIds: ReadonlySet<string>,
77 target: SecretProviderTarget,
78): Result.Result<
79 { readonly id: SecretProviderId; readonly config: SecretProviderConfig },
80 SecretProviderDraftIssues
81> {
82 const label = decodeField(SecretProviderLabel, draft.name, "Enter a name.");
83 const account = decodeField(
84 OnePasswordSecretProviderConfig.fields.account,
85 draft.account,
86 "Enter the 1Password account.",
87 );
88
89 let id: SecretProviderId | null = null;
90 let nameIssue: string | undefined;
91 if (Result.isFailure(label)) {
92 nameIssue = label.failure;
93 } else {
94 id = target.mode === "edit" ? target.id : secretProviderIdFromName(label.success);
95 if (id === null) nameIssue = "The name needs at least one letter or digit.";
96 else if (target.mode === "add" && existingIds.has(id))
97 nameIssue = "A secret provider with this name exists.";
98 }
99
100 if (
101 id === null ||
102 nameIssue !== undefined ||
103 Result.isFailure(label) ||
104 Result.isFailure(account)
105 ) {
106 return Result.fail({
107 ...(nameIssue === undefined ? {} : { name: nameIssue }),
108 ...(Result.isFailure(account) ? { account: account.failure } : {}),
109 });
110 }
111 return Result.succeed({
112 id,
113 config: {
114 kind: SecretProviderKind.make("one_password"),
115 label: label.success,
116 config: { account: account.success },
117 },
118 });
119}