reposets 0.4.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +87 -75
  2. package/bin/reposets.js +68 -17
  3. package/cli/commands/credentials.js +170 -49
  4. package/cli/commands/doctor.js +364 -91
  5. package/cli/commands/drift.js +48 -0
  6. package/cli/commands/history.js +203 -0
  7. package/cli/commands/init.js +110 -104
  8. package/cli/commands/list.js +60 -39
  9. package/cli/commands/nuke.js +127 -0
  10. package/cli/commands/sync.js +219 -59
  11. package/cli/commands/validate.js +57 -41
  12. package/cli/flags.js +36 -0
  13. package/cli/logger.js +48 -0
  14. package/index.d.ts +471 -1499
  15. package/index.js +3 -18
  16. package/lib/config-refs.js +76 -0
  17. package/lib/credential-labels.js +0 -0
  18. package/lib/fingerprint.js +52 -0
  19. package/lib/org-only.js +61 -0
  20. package/lib/schema-issues.js +50 -0
  21. package/package.json +11 -10
  22. package/schemas/annotations.js +81 -0
  23. package/schemas/common.js +83 -48
  24. package/schemas/config.js +214 -212
  25. package/schemas/credentials.js +190 -54
  26. package/schemas/environment.js +27 -21
  27. package/schemas/ruleset.js +235 -139
  28. package/services/ConfigFiles.js +126 -104
  29. package/services/CredentialResolver.js +97 -33
  30. package/services/OnePasswordClient.js +88 -16
  31. package/services/SyncLogger.js +107 -76
  32. package/store/AppliedState.js +0 -0
  33. package/store/RepoCache.js +86 -0
  34. package/store/SyncJournal.js +92 -0
  35. package/store/migrations.js +86 -0
  36. package/sync/SyncEngine.js +156 -0
  37. package/sync/decide.js +56 -0
  38. package/sync/phase.js +55 -0
  39. package/sync/phases/cleanup.js +220 -0
  40. package/sync/phases/code-scanning.js +187 -0
  41. package/sync/phases/environments.js +106 -0
  42. package/sync/phases/index.js +39 -0
  43. package/sync/phases/resource.js +149 -0
  44. package/sync/phases/rulesets.js +186 -0
  45. package/sync/phases/secrets.js +138 -0
  46. package/sync/phases/security.js +129 -0
  47. package/sync/phases/settings.js +274 -0
  48. package/sync/phases/variables.js +132 -0
  49. package/tsdoc-metadata.json +1 -1
  50. package/bin/reposets.d.ts +0 -1
  51. package/errors.js +0 -12
  52. package/lib/crypto.js +0 -27
  53. package/services/GitHubClient.js +0 -875
  54. package/services/SyncEngine.js +0 -580
@@ -1,119 +1,141 @@
1
1
  import { ConfigSchema } from "../schemas/config.js";
2
2
  import { CredentialsSchema } from "../schemas/credentials.js";
3
- import { AppDirsConfig, ConfigError, ConfigFile, ExplicitPath, FirstMatch, StaticDir, TomlCodec, UpwardWalk, XdgConfigLive, XdgConfigResolver, XdgSavePath } from "xdg-effect";
4
- import { Effect, Option } from "effect";
5
- import { existsSync, statSync } from "node:fs";
3
+ import { Data, Effect, FileSystem, Layer } from "effect";
4
+ import { AppConfig } from "@effected/app";
5
+ import { ConfigFile, ConfigResolver, TomlCodec } from "@effected/config-file";
6
6
 
7
7
  //#region src/services/ConfigFiles.ts
8
+ /**
9
+ * The config file's fixed name, in every tier of the resolver chain.
10
+ *
11
+ * @public
12
+ */
8
13
  const CONFIG_FILENAME = "reposets.config.toml";
14
+ /**
15
+ * The credentials file's fixed name.
16
+ *
17
+ * @public
18
+ */
9
19
  const CREDENTIALS_FILENAME = "reposets.credentials.toml";
10
- const ReposetsConfigFile = ConfigFile.Tag("reposets/Config");
11
- const ReposetsCredentialsFile = ConfigFile.Tag("reposets/Credentials");
12
20
  /**
13
- * Validates all internal cross-references in a parsed config. Checks that
14
- * every group's settings, secrets, variables, rulesets, and environments
15
- * references point to defined top-level sections, and that environment-scoped
16
- * secret/variable groups reference defined environments. Collects ALL errors
17
- * into a single ConfigError.
21
+ * Service identity for the parsed `reposets.config.toml`.
22
+ *
23
+ * @public
18
24
  */
19
- function validateConfigRefs(config) {
20
- const errors = [];
21
- const definedSettings = new Set(Object.keys(config.settings));
22
- const definedSecrets = new Set(Object.keys(config.secrets));
23
- const definedVariables = new Set(Object.keys(config.variables));
24
- const definedRulesets = new Set(Object.keys(config.rulesets));
25
- const definedEnvironments = new Set(Object.keys(config.environments));
26
- const definedSecurity = new Set(Object.keys(config.security));
27
- const definedCodeScanning = new Set(Object.keys(config.code_scanning));
28
- for (const [groupName, group] of Object.entries(config.groups)) {
29
- if (group.settings) {
30
- for (const ref of group.settings) if (!definedSettings.has(ref)) errors.push(`group '${groupName}': unknown settings group '${ref}'`);
31
- }
32
- if (group.rulesets) {
33
- for (const ref of group.rulesets) if (!definedRulesets.has(ref)) errors.push(`group '${groupName}': unknown ruleset '${ref}'`);
34
- }
35
- if (group.environments) {
36
- for (const ref of group.environments) if (!definedEnvironments.has(ref)) errors.push(`group '${groupName}': unknown environment '${ref}'`);
37
- }
38
- if (group.security) {
39
- for (const ref of group.security) if (!definedSecurity.has(ref)) errors.push(`group '${groupName}': unknown security group '${ref}'`);
40
- }
41
- if (group.code_scanning) {
42
- for (const ref of group.code_scanning) if (!definedCodeScanning.has(ref)) errors.push(`group '${groupName}': unknown code_scanning group '${ref}'`);
43
- }
44
- if (group.secrets) {
45
- if (group.secrets.actions) {
46
- for (const ref of group.secrets.actions) if (!definedSecrets.has(ref)) errors.push(`group '${groupName}': unknown secrets group '${ref}'`);
47
- }
48
- if (group.secrets.dependabot) {
49
- for (const ref of group.secrets.dependabot) if (!definedSecrets.has(ref)) errors.push(`group '${groupName}': unknown secrets group '${ref}'`);
50
- }
51
- if (group.secrets.codespaces) {
52
- for (const ref of group.secrets.codespaces) if (!definedSecrets.has(ref)) errors.push(`group '${groupName}': unknown secrets group '${ref}'`);
53
- }
54
- if (group.secrets.environments) for (const [envName, secretGroups] of Object.entries(group.secrets.environments)) {
55
- if (!definedEnvironments.has(envName)) errors.push(`group '${groupName}': unknown environment '${envName}' in secrets.environments`);
56
- for (const ref of secretGroups) if (!definedSecrets.has(ref)) errors.push(`group '${groupName}': in secrets.environments.'${envName}': unknown secrets group '${ref}'`);
57
- }
58
- }
59
- if (group.variables) {
60
- if (group.variables.actions) {
61
- for (const ref of group.variables.actions) if (!definedVariables.has(ref)) errors.push(`group '${groupName}': unknown variables group '${ref}'`);
62
- }
63
- if (group.variables.environments) for (const [envName, varGroups] of Object.entries(group.variables.environments)) {
64
- if (!definedEnvironments.has(envName)) errors.push(`group '${groupName}': unknown environment '${envName}' in variables.environments`);
65
- for (const ref of varGroups) if (!definedVariables.has(ref)) errors.push(`group '${groupName}': in variables.environments.'${envName}': unknown variables group '${ref}'`);
66
- }
67
- }
68
- }
69
- if (errors.length > 0) return Effect.fail(new ConfigError({
70
- operation: "validate",
71
- reason: errors.join("\n")
72
- }));
73
- return Effect.succeed(config);
74
- }
25
+ var ReposetsConfigFile = class extends ConfigFile.Service()("reposets/Config") {};
26
+ /**
27
+ * Service identity for the parsed `reposets.credentials.toml`.
28
+ *
29
+ * @public
30
+ */
31
+ var ReposetsCredentialsFile = class extends ConfigFile.Service()("reposets/Credentials") {};
75
32
  /**
76
- * Creates a live Layer providing both config and credentials file services.
77
- * When configFlag is Some and points to a directory, prepends StaticDir resolver.
78
- * When configFlag is Some and points to a file, prepends ExplicitPath resolver.
79
- * Always includes UpwardWalk + XdgConfigResolver as fallback resolvers.
80
- * Passes validateConfigRefs as the validate callback on the config spec.
33
+ * Reject keys the schema does not know about.
34
+ *
35
+ * @remarks
36
+ * v4 carries `onExcessProperty` on `ParseOptions` per decode call, not on the
37
+ * schema and `@effected/config-file` threads it through from `0.4.0`.
38
+ *
39
+ * Without it a config loader silently discards part of the user's file: a typo'd
40
+ * section name does nothing and reports nothing, and a field removed in a
41
+ * breaking schema change is ignored rather than rejected. Both matter here.
42
+ * `op_service_account_token` was removed for a security reason — it stored the
43
+ * credential that unlocks every other credential — and a migrating user who
44
+ * keeps it must be told, not quietly ignored while believing a dead token is
45
+ * live.
46
+ *
47
+ * This does **not** break the documented `[settings.*]` pass-through. Those keys
48
+ * are covered by a `StructWithRest` rest schema, so they are not excess;
49
+ * verified here and pinned by a test upstream.
50
+ *
51
+ * **Applied to credentials only, for now.** The config file keeps lenient
52
+ * decoding because `doctor` diagnoses unknown keys with nearest-match
53
+ * suggestions — strictly better output than a decode failure — and it locates
54
+ * the file through `discover`, which decodes. Making the load strict means
55
+ * `discover` fails and `doctor` reports "no config found" for a file that is
56
+ * present and one character wrong, losing the diagnosis exactly when it is
57
+ * wanted. Turning it on for config needs `doctor` to locate the file without
58
+ * decoding first.
81
59
  */
82
- function makeConfigFilesLive(configFlag) {
83
- const configResolvers = [];
84
- if (Option.isSome(configFlag)) {
85
- const flag = configFlag.value;
86
- if (existsSync(flag) && statSync(flag).isDirectory()) configResolvers.push(StaticDir({
87
- dir: flag,
88
- filename: CONFIG_FILENAME
89
- }));
90
- else configResolvers.push(ExplicitPath(flag));
60
+ const STRICT_KEYS = {
61
+ onExcessProperty: "error",
62
+ errors: "all"
63
+ };
64
+ /**
65
+ * The credentials file, resolved by upward walk then XDG.
66
+ *
67
+ * @remarks
68
+ * **No `--config` tier.** That flag names the *config* file; pointing it at a
69
+ * directory would be ambiguous here and pointing it at a file would be wrong.
70
+ * A credentials file is found next to the project or in the XDG config
71
+ * directory, and nowhere else.
72
+ *
73
+ * `AppConfig.layer` appends its own XDG tier after the resolvers given here, so
74
+ * the upward walk wins and the XDG fallback comes free.
75
+ *
76
+ * @public
77
+ */
78
+ const CredentialsFilesLive = AppConfig.layer(ReposetsCredentialsFile, {
79
+ filename: CREDENTIALS_FILENAME,
80
+ schema: CredentialsSchema,
81
+ codec: TomlCodec,
82
+ resolvers: [ConfigResolver.upwardWalk({ filename: CREDENTIALS_FILENAME })],
83
+ parseOptions: STRICT_KEYS
84
+ });
85
+ /**
86
+ * Raised when `--config` names a path that does not exist.
87
+ *
88
+ * @remarks
89
+ * Config discovery is best-effort by contract — every `ConfigResolver` has
90
+ * `never` in its error channel — so a resolver that finds nothing falls through
91
+ * to the next tier. That is right for a probe and wrong for an explicit request:
92
+ * `--config /nope.toml` would otherwise load the XDG config instead. The
93
+ * distinction is enforced here rather than pushed into the resolver contract.
94
+ *
95
+ * @public
96
+ */
97
+ var ConfigFlagNotFound = class extends Data.TaggedError("ConfigFlagNotFound") {
98
+ /**
99
+ * @remarks
100
+ * Without this, the class renders as a bare `ConfigFlagNotFound:` and the
101
+ * `path` never reaches the log line. The kit does the same on its own errors.
102
+ */
103
+ get message() {
104
+ return `--config path does not exist: ${this.path}`;
91
105
  }
92
- configResolvers.push(UpwardWalk({ filename: CONFIG_FILENAME }), XdgConfigResolver({ filename: CONFIG_FILENAME }));
93
- return XdgConfigLive.multi({
94
- app: new AppDirsConfig({ namespace: "reposets" }),
95
- configs: [{
96
- tag: ReposetsConfigFile,
97
- schema: ConfigSchema,
98
- codec: TomlCodec,
99
- strategy: FirstMatch,
100
- resolvers: configResolvers,
101
- validate: validateConfigRefs
102
- }, {
103
- tag: ReposetsCredentialsFile,
104
- schema: CredentialsSchema,
105
- codec: TomlCodec,
106
- strategy: FirstMatch,
107
- resolvers: [UpwardWalk({ filename: CREDENTIALS_FILENAME }), XdgConfigResolver({ filename: CREDENTIALS_FILENAME })],
108
- defaultPath: XdgSavePath(CREDENTIALS_FILENAME)
109
- }]
110
- });
111
- }
106
+ };
107
+ /**
108
+ * Builds the resolver tiers that must win over `AppConfig`'s own XDG chain.
109
+ *
110
+ * @remarks
111
+ * `AppConfig.layer` prepends these, in order, ahead of `XdgConfig.resolver` and
112
+ * the native-directory probe, so only the higher-priority tiers appear here.
113
+ */
114
+ const resolversFor = (configFlag) => Effect.gen(function* () {
115
+ if (configFlag === void 0) return [ConfigResolver.upwardWalk({ filename: CONFIG_FILENAME })];
116
+ const info = yield* (yield* FileSystem.FileSystem).stat(configFlag).pipe(Effect.option);
117
+ if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
118
+ return info.value.type === "Directory" ? [ConfigResolver.staticDir({
119
+ dir: configFlag,
120
+ filename: CONFIG_FILENAME
121
+ })] : [ConfigResolver.explicitPath(configFlag)];
122
+ });
112
123
  /**
113
- * Default ConfigFiles layer with no --config flag override.
114
- * Used by CLI entrypoint when no flag is provided.
124
+ * Builds the config-file layer for one invocation's `--config` flag.
125
+ *
126
+ * @remarks
127
+ * Mints a fresh layer per call, so bind the result once per run rather than
128
+ * inlining it at two provide sites.
129
+ *
130
+ * @public
115
131
  */
116
- const ConfigFilesLive = makeConfigFilesLive(Option.none());
132
+ const makeConfigFilesLive = (configFlag) => Layer.unwrap(Effect.map(resolversFor(configFlag), (resolvers) => AppConfig.layer(ReposetsConfigFile, {
133
+ filename: CONFIG_FILENAME,
134
+ schema: ConfigSchema,
135
+ codec: TomlCodec,
136
+ resolvers,
137
+ parseOptions: STRICT_KEYS
138
+ })));
117
139
 
118
140
  //#endregion
119
- export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive, validateConfigRefs };
141
+ export { CONFIG_FILENAME, CREDENTIALS_FILENAME, ConfigFlagNotFound, CredentialsFilesLive, ReposetsConfigFile, ReposetsCredentialsFile, makeConfigFilesLive };
@@ -1,40 +1,104 @@
1
- import { ResolveError } from "../errors.js";
2
1
  import { OnePasswordClient } from "./OnePasswordClient.js";
3
- import { Context, Effect, Layer } from "effect";
2
+ import { Context, Data, Effect, Layer, Redacted } from "effect";
3
+ import { env } from "node:process";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { isAbsolute, resolve } from "node:path";
6
6
 
7
7
  //#region src/services/CredentialResolver.ts
8
- var CredentialResolver = class extends Context.Tag("CredentialResolver")() {};
9
- const CredentialResolverLive = Layer.effect(CredentialResolver, Effect.gen(function* () {
10
- const opClient = yield* OnePasswordClient;
11
- return { resolveAll(profile, basePath) {
12
- return Effect.gen(function* () {
13
- const result = /* @__PURE__ */ new Map();
14
- const resolveSection = profile.resolve;
15
- if (!resolveSection) return result;
16
- if (resolveSection.value) for (const [label, val] of Object.entries(resolveSection.value)) if (typeof val === "string") result.set(label, val);
17
- else result.set(label, JSON.stringify(val));
18
- if (resolveSection.file) for (const [label, filePath] of Object.entries(resolveSection.file)) {
19
- const fullPath = isAbsolute(filePath) ? filePath : resolve(basePath, filePath);
20
- const content = yield* Effect.try({
21
- try: () => readFileSync(fullPath, "utf-8").trim(),
22
- catch: (error) => new ResolveError({ message: `Failed to read file for label '${label}': ${error instanceof Error ? error.message : String(error)}` })
23
- });
24
- result.set(label, content);
25
- }
26
- if (resolveSection.op) {
27
- const opToken = profile.op_service_account_token;
28
- if (!opToken) return yield* Effect.fail(new ResolveError({ message: "No 1Password service account token provided but resolve.op entries are defined" }));
29
- for (const [label, reference] of Object.entries(resolveSection.op)) {
30
- const value = yield* opClient.resolve(reference, opToken).pipe(Effect.mapError((err) => new ResolveError({ message: `Failed to resolve label '${label}': ${err.message}` })));
31
- result.set(label, value);
32
- }
33
- }
34
- return result;
35
- });
36
- } };
37
- }));
8
+ /**
9
+ * A credential could not be resolved.
10
+ *
11
+ * @remarks
12
+ * Every field is an **address**, never a value: the label a config refers to,
13
+ * the kind of source it named, and why that source did not yield anything. A
14
+ * file's contents, an environment variable's value and a 1Password item's value
15
+ * are all deliberately absent — this error is the most likely thing to be
16
+ * printed when credential handling goes wrong, which makes it the most likely
17
+ * place to leak.
18
+ *
19
+ * @public
20
+ */
21
+ var ResolveError = class extends Data.TaggedError("ResolveError") {
22
+ get message() {
23
+ return `Failed to resolve '${this.label}' from ${this.source}: ${this.reason}`;
24
+ }
25
+ };
26
+ /** Read an environment variable, failing in terms of its name. */
27
+ const fromEnv = (label, variable) => Effect.suspend(() => {
28
+ const value = env[variable];
29
+ return value === void 0 ? Effect.fail(new ResolveError({
30
+ label,
31
+ source: "env",
32
+ reason: `environment variable ${variable} is not set`
33
+ })) : Effect.succeed(Redacted.make(value, { label }));
34
+ });
35
+ /** Read a file, failing in terms of its path. */
36
+ const fromFile = (label, filePath, basePath) => Effect.try({
37
+ try: () => {
38
+ const fullPath = isAbsolute(filePath) ? filePath : resolve(basePath, filePath);
39
+ return Redacted.make(readFileSync(fullPath, "utf-8").trim(), { label });
40
+ },
41
+ catch: (error) => new ResolveError({
42
+ label,
43
+ source: "file",
44
+ reason: error instanceof Error ? error.message : String(error)
45
+ })
46
+ });
47
+ /**
48
+ * Turns the references in a credential profile into usable values.
49
+ *
50
+ * @remarks
51
+ * Everything this service returns is {@link Redacted.Redacted}. A redacted value
52
+ * renders as `<redacted>` through `toString`, template interpolation and
53
+ * `JSON.stringify`, so the common ways a secret escapes — a log line, an error
54
+ * message, a serialised object — are closed structurally rather than by
55
+ * discipline. Reading the plaintext takes `Redacted.value`, which greps.
56
+ *
57
+ * All four `resolve` sub-groups are read: `op`, `env`, `file` and `value`.
58
+ *
59
+ * This docstring used to say the opposite — that `value` had been dropped from
60
+ * the section — and the implementation matched the docstring rather than the
61
+ * schema. That decision *was* made and then reversed: `value` holds named values
62
+ * generally, and its common use is non-secret structured data, so the rule that
63
+ * survived is narrower than "no inline anything". A *credential* is never
64
+ * inlined, and that is enforced on `github_token`.
65
+ *
66
+ * The cost of the disagreement: every inline label resolved to nothing, and its
67
+ * consumers reported that the label "is not defined in the active profile's
68
+ * `[resolve]` section" — about a label that was declared and simply never read.
69
+ * A schema and a docstring disagreed, and the code followed the docstring.
70
+ *
71
+ * @public
72
+ */
73
+ var CredentialResolver = class extends Context.Service()("reposets/CredentialResolver", { make: Effect.gen(function* () {
74
+ const onePassword = yield* OnePasswordClient;
75
+ /** One `op://` reference, with 1Password's failure restated in this service's terms. */
76
+ const fromOnePassword = (label, reference) => onePassword.resolve(reference).pipe(Effect.mapError((error) => new ResolveError({
77
+ label,
78
+ source: "op",
79
+ reason: error.message
80
+ })));
81
+ const fromSource = (label, source) => "op" in source ? fromOnePassword(label, source.op) : fromEnv(label, source.env);
82
+ return {
83
+ resolveGitHubToken: (profile) => fromSource("github_token", profile.github_token),
84
+ resolveAll: (profile, basePath) => Effect.gen(function* () {
85
+ const resolved = /* @__PURE__ */ new Map();
86
+ const section = profile.resolve;
87
+ if (section === void 0) return resolved;
88
+ for (const [label, reference] of Object.entries(section.op ?? {})) resolved.set(label, yield* fromOnePassword(label, reference));
89
+ for (const [label, variable] of Object.entries(section.env ?? {})) resolved.set(label, yield* fromEnv(label, variable));
90
+ for (const [label, filePath] of Object.entries(section.file ?? {})) resolved.set(label, yield* fromFile(label, filePath, basePath));
91
+ for (const [label, value] of Object.entries(section.value ?? {})) resolved.set(label, Redacted.make(typeof value === "string" ? value : JSON.stringify(value), { label }));
92
+ return resolved;
93
+ })
94
+ };
95
+ }) }) {};
96
+ /**
97
+ * Live resolver, over the ambient {@link OnePasswordClient}.
98
+ *
99
+ * @public
100
+ */
101
+ const CredentialResolverLive = Layer.effect(CredentialResolver, CredentialResolver.make);
38
102
 
39
103
  //#endregion
40
- export { CredentialResolver, CredentialResolverLive };
104
+ export { CredentialResolver, CredentialResolverLive, ResolveError };
@@ -1,30 +1,102 @@
1
- import { OnePasswordError } from "../errors.js";
2
- import { Context, Effect, Layer } from "effect";
1
+ import { Context, Data, Effect, Layer, Redacted } from "effect";
2
+ import { env } from "node:process";
3
3
 
4
4
  //#region src/services/OnePasswordClient.ts
5
- var OnePasswordClient = class extends Context.Tag("OnePasswordClient")() {};
6
- /* v8 ignore start -- live 1Password SDK calls, tested via OnePasswordClientTest */
7
- const OnePasswordClientLive = Layer.succeed(OnePasswordClient, { resolve(reference, serviceAccountToken) {
8
- return Effect.tryPromise({
5
+ /**
6
+ * The environment variable the 1Password SDK is authenticated from.
7
+ *
8
+ * @public
9
+ */
10
+ const OP_SERVICE_ACCOUNT_TOKEN = "OP_SERVICE_ACCOUNT_TOKEN";
11
+ /**
12
+ * A 1Password reference could not be resolved.
13
+ *
14
+ * @remarks
15
+ * Carries the `op://` **reference** — an address, safe to print — and never the
16
+ * value it points at. `reason` holds the SDK's own diagnostic, which describes
17
+ * why a lookup failed rather than what it would have returned.
18
+ *
19
+ * @public
20
+ */
21
+ var OnePasswordError = class extends Data.TaggedError("OnePasswordError") {
22
+ get message() {
23
+ return `Failed to resolve ${this.reference}: ${this.reason}`;
24
+ }
25
+ };
26
+ /** Read the service account token, failing in terms of the reference that needed it. */
27
+ const serviceAccountToken = (reference) => Effect.suspend(() => {
28
+ const token = env[OP_SERVICE_ACCOUNT_TOKEN];
29
+ return token === void 0 || token === "" ? Effect.fail(new OnePasswordError({
30
+ reference,
31
+ reason: `${OP_SERVICE_ACCOUNT_TOKEN} is not set in the environment`
32
+ })) : Effect.succeed(token);
33
+ });
34
+ /**
35
+ * Resolves `op://` references through the 1Password SDK.
36
+ *
37
+ * @remarks
38
+ * Resolved values come back as {@link Redacted.Redacted}. That is the point of
39
+ * this service's return type rather than a convention: a redacted value renders
40
+ * as `<redacted>` through `toString`, template interpolation and
41
+ * `JSON.stringify`, so a secret cannot reach a log line or an error message by
42
+ * being accidentally formatted. Reading it requires `Redacted.value`, which is
43
+ * greppable.
44
+ *
45
+ * The service account token is read from the environment at call time, not
46
+ * taken as a parameter and not read from the credentials file. It authenticates
47
+ * the SDK that resolves everything else, so keeping it beside the references it
48
+ * unlocks would put the one credential that opens all the others in the file
49
+ * those others merely point out of. Reading it lazily also means a config with
50
+ * no `op` entries never needs it.
51
+ *
52
+ * @public
53
+ */
54
+ var OnePasswordClient = class OnePasswordClient extends Context.Service()("reposets/OnePasswordClient", { make: Effect.succeed({ resolve: (reference) => Effect.gen(function* () {
55
+ const token = yield* serviceAccountToken(reference);
56
+ const value = yield* Effect.tryPromise({
9
57
  try: async () => {
10
58
  const { createClient } = await import("@1password/sdk");
11
59
  return await (await createClient({
12
- auth: serviceAccountToken,
60
+ auth: token,
13
61
  integrationName: "reposets",
14
62
  integrationVersion: "1.0.0"
15
63
  })).secrets.resolve(reference);
16
64
  },
17
- catch: (error) => new OnePasswordError({ message: `Failed to resolve ${reference}: ${error instanceof Error ? error.message : String(error)}` })
65
+ catch: (error) => new OnePasswordError({
66
+ reference,
67
+ reason: error instanceof Error ? error.message : String(error)
68
+ })
18
69
  });
19
- } });
20
- /* v8 ignore stop */
21
- function OnePasswordClientTest(stubs) {
22
- return Layer.succeed(OnePasswordClient, { resolve(reference, _serviceAccountToken) {
70
+ return Redacted.make(value, { label: reference });
71
+ }) }) }) {
72
+ /**
73
+ * A double over a fixed reference value table.
74
+ *
75
+ * @remarks
76
+ * An unknown reference fails exactly as a missing 1Password item does, so a
77
+ * test can exercise the not-found path without a vault.
78
+ */
79
+ static layerTest = (stubs) => Layer.succeed(OnePasswordClient, { resolve: (reference) => {
23
80
  const value = stubs[reference];
24
- if (value === void 0) return Effect.fail(new OnePasswordError({ message: `Test stub: unknown reference ${reference}` }));
25
- return Effect.succeed(value);
81
+ return value === void 0 ? Effect.fail(new OnePasswordError({
82
+ reference,
83
+ reason: "no item found at that reference"
84
+ })) : Effect.succeed(Redacted.make(value, { label: reference }));
26
85
  } });
27
- }
86
+ };
87
+ /**
88
+ * Live 1Password client.
89
+ *
90
+ * @remarks
91
+ * **Builds one SDK client per reference resolved**, which means one
92
+ * authentication handshake per secret rather than one per run. That is v3's
93
+ * behaviour, ported unchanged rather than quietly improved; a profile with
94
+ * twenty `op` entries pays for it twenty times. Memoising the client is the
95
+ * obvious fix and a deliberate follow-up, not an oversight.
96
+ *
97
+ * @public
98
+ */
99
+ const OnePasswordClientLive = Layer.effect(OnePasswordClient, OnePasswordClient.make);
28
100
 
29
101
  //#endregion
30
- export { OnePasswordClient, OnePasswordClientLive, OnePasswordClientTest };
102
+ export { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClient, OnePasswordClientLive, OnePasswordError };