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.
- package/README.md +87 -75
- package/bin/reposets.js +68 -17
- package/cli/commands/credentials.js +170 -49
- package/cli/commands/doctor.js +364 -91
- package/cli/commands/drift.js +48 -0
- package/cli/commands/history.js +203 -0
- package/cli/commands/init.js +110 -104
- package/cli/commands/list.js +60 -39
- package/cli/commands/nuke.js +127 -0
- package/cli/commands/sync.js +219 -59
- package/cli/commands/validate.js +57 -41
- package/cli/flags.js +36 -0
- package/cli/logger.js +48 -0
- package/index.d.ts +471 -1499
- package/index.js +3 -18
- package/lib/config-refs.js +76 -0
- package/lib/credential-labels.js +0 -0
- package/lib/fingerprint.js +52 -0
- package/lib/org-only.js +61 -0
- package/lib/schema-issues.js +50 -0
- package/package.json +11 -10
- package/schemas/annotations.js +81 -0
- package/schemas/common.js +83 -48
- package/schemas/config.js +214 -212
- package/schemas/credentials.js +190 -54
- package/schemas/environment.js +27 -21
- package/schemas/ruleset.js +235 -139
- package/services/ConfigFiles.js +126 -104
- package/services/CredentialResolver.js +97 -33
- package/services/OnePasswordClient.js +88 -16
- package/services/SyncLogger.js +107 -76
- package/store/AppliedState.js +0 -0
- package/store/RepoCache.js +86 -0
- package/store/SyncJournal.js +92 -0
- package/store/migrations.js +86 -0
- package/sync/SyncEngine.js +156 -0
- package/sync/decide.js +56 -0
- package/sync/phase.js +55 -0
- package/sync/phases/cleanup.js +220 -0
- package/sync/phases/code-scanning.js +187 -0
- package/sync/phases/environments.js +106 -0
- package/sync/phases/index.js +39 -0
- package/sync/phases/resource.js +149 -0
- package/sync/phases/rulesets.js +186 -0
- package/sync/phases/secrets.js +138 -0
- package/sync/phases/security.js +129 -0
- package/sync/phases/settings.js +274 -0
- package/sync/phases/variables.js +132 -0
- package/tsdoc-metadata.json +1 -1
- package/bin/reposets.d.ts +0 -1
- package/errors.js +0 -12
- package/lib/crypto.js +0 -27
- package/services/GitHubClient.js +0 -875
- package/services/SyncEngine.js +0 -580
package/services/ConfigFiles.js
CHANGED
|
@@ -1,119 +1,141 @@
|
|
|
1
1
|
import { ConfigSchema } from "../schemas/config.js";
|
|
2
2
|
import { CredentialsSchema } from "../schemas/credentials.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
*
|
|
114
|
-
*
|
|
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
|
|
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,
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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:
|
|
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({
|
|
65
|
+
catch: (error) => new OnePasswordError({
|
|
66
|
+
reference,
|
|
67
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
68
|
+
})
|
|
18
69
|
});
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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,
|
|
102
|
+
export { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClient, OnePasswordClientLive, OnePasswordError };
|