reposets 3.1.8 → 4.0.1
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/bin/reposets.js +25 -20
- package/cli/commands/credentials.js +2 -20
- package/cli/commands/doctor.js +65 -99
- package/cli/commands/history.js +5 -7
- package/cli/commands/init.js +1 -1
- package/cli/commands/nuke.js +24 -28
- package/cli/commands/sync.js +4 -22
- package/index.d.ts +40 -2
- package/index.js +2 -1
- package/package.json +16 -14
- package/services/ConfigFiles.js +32 -7
- package/services/CredentialResolver.js +21 -17
- package/services/OnePasswordClient.js +2 -1
- package/services/SyncLogger.js +4 -4
- package/store/files.js +27 -1
- package/sync/phase.js +1 -0
- package/sync/phases/code-scanning.js +2 -1
- package/sync/phases/environments.js +2 -1
- package/sync/phases/resource.js +15 -18
- package/sync/phases/secrets.js +7 -3
- package/sync/phases/security.js +2 -1
- package/sync/phases/settings.js +3 -2
- package/sync/phases/variables.js +7 -3
- package/tsdoc-metadata.json +1 -1
package/bin/reposets.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { CredentialsFilesLive } from "../services/ConfigFiles.js";
|
|
3
2
|
import { Invocation } from "../services/Invocation.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { CredentialsFilesLive } from "../services/ConfigFiles.js";
|
|
4
|
+
import { CACHE_OPTIONS, STORE_OPTIONS } from "../store/files.js";
|
|
6
5
|
import { SyncJournalLive } from "../store/SyncJournal.js";
|
|
7
6
|
import { credentialsCommand } from "../cli/commands/credentials.js";
|
|
8
|
-
import { ConfigFlag, ConfigLive } from "../cli/flags.js";
|
|
9
7
|
import { doctorCommand } from "../cli/commands/doctor.js";
|
|
10
8
|
import { syncCommand } from "../cli/commands/sync.js";
|
|
11
9
|
import { driftCommand } from "../cli/commands/drift.js";
|
|
@@ -14,7 +12,8 @@ import { initCommand } from "../cli/commands/init.js";
|
|
|
14
12
|
import { listCommand } from "../cli/commands/list.js";
|
|
15
13
|
import { nukeCommand } from "../cli/commands/nuke.js";
|
|
16
14
|
import { validateCommand } from "../cli/commands/validate.js";
|
|
17
|
-
import {
|
|
15
|
+
import { ConfigFlag, ConfigLive } from "../cli/flags.js";
|
|
16
|
+
import { Effect, Layer, Predicate } from "effect";
|
|
18
17
|
import { App, AppCache, AppStore } from "@effected/app";
|
|
19
18
|
import { NodeRuntime, NodeServices } from "@effect/platform-node";
|
|
20
19
|
import { CliAudience, CliRuntime, ConfigIssueRenderer, Fmt } from "@effected/cli";
|
|
@@ -32,7 +31,7 @@ import { Command } from "effect/cli";
|
|
|
32
31
|
* `0.0.0 (dev build)` from every build until the version was threaded down
|
|
33
32
|
* from here through {@link Invocation}.
|
|
34
33
|
*/
|
|
35
|
-
const VERSION = "
|
|
34
|
+
const VERSION = "4.0.1";
|
|
36
35
|
/**
|
|
37
36
|
* The application's directories: XDG resolution and the `reposets` namespace
|
|
38
37
|
* under it.
|
|
@@ -60,10 +59,7 @@ const DirsLive = App.layerDirs({ namespace: "reposets" });
|
|
|
60
59
|
* Migrations still run during layer construction, so they now run on the first
|
|
61
60
|
* command that uses the database rather than on the first command of any kind.
|
|
62
61
|
*/
|
|
63
|
-
const StoreLive = AppStore.layer(
|
|
64
|
-
migrations,
|
|
65
|
-
filename: STATE_DB_FILENAME
|
|
66
|
-
});
|
|
62
|
+
const StoreLive = AppStore.layer(STORE_OPTIONS);
|
|
67
63
|
/**
|
|
68
64
|
* Both databases, for the commands that run the sync engine.
|
|
69
65
|
*
|
|
@@ -72,7 +68,7 @@ const StoreLive = AppStore.layer({
|
|
|
72
68
|
* in `cache.db`. The engine builds its own journal over `Store`, so nothing
|
|
73
69
|
* else is needed here.
|
|
74
70
|
*/
|
|
75
|
-
const DatabasesLive = Layer.mergeAll(StoreLive, AppCache.layer(
|
|
71
|
+
const DatabasesLive = Layer.mergeAll(StoreLive, AppCache.layer(CACHE_OPTIONS));
|
|
76
72
|
/**
|
|
77
73
|
* The run journal over the state database, for `history` and its subcommands.
|
|
78
74
|
*
|
|
@@ -138,8 +134,6 @@ const cli = Command.make("reposets", {}, () => Effect.void).pipe(Command.withDes
|
|
|
138
134
|
* would be shadowed.
|
|
139
135
|
*/
|
|
140
136
|
const PlatformLive = Layer.mergeAll(DirsLive, InvocationLive).pipe(Layer.provideMerge(NodeServices.layer));
|
|
141
|
-
/** Whether a failure is a tagged error of a given tag. */
|
|
142
|
-
const isTagged = (error, tag) => typeof error === "object" && error !== null && "_tag" in error && error._tag === tag;
|
|
143
137
|
/**
|
|
144
138
|
* Render a failure that escaped a command, one line at a time, to stderr.
|
|
145
139
|
*
|
|
@@ -161,19 +155,31 @@ const isTagged = (error, tag) => typeof error === "object" && error !== null &&
|
|
|
161
155
|
* kit's own status line. A TOML syntax error is a `ConfigCodecError` whose own
|
|
162
156
|
* message is the bare "toml parse failed" — the line and column live on the
|
|
163
157
|
* cause, and printing it is the difference between "your config is broken
|
|
164
|
-
* somewhere" and a position plus what the parser expected there. The cause
|
|
165
|
-
*
|
|
166
|
-
*
|
|
158
|
+
* somewhere" and a position plus what the parser expected there. The cause is
|
|
159
|
+
* read with `Fmt.causeMessage`, the whole `cause` chain's messages without
|
|
160
|
+
* the `TomlParseError:` prefix `String` would add, and falls back to
|
|
161
|
+
* `Fmt.describe` when nothing along the chain has a message. The parser
|
|
162
|
+
* reports a 1-based `line:column`: an unclosed `[groups` header on the first
|
|
163
|
+
* line reads "ExpectedTableHeaderClose at 1:8 expected ] to close the table
|
|
167
164
|
* header".
|
|
168
165
|
*
|
|
166
|
+
* When the failure names a file — `ConfigCodecError.path`, which
|
|
167
|
+
* `ConfigFile`'s read pipeline fills in and which its `message` deliberately
|
|
168
|
+
* leaves out — the cause line is prefixed with it, so a syntax error says
|
|
169
|
+
* which of the discovered files it is in.
|
|
170
|
+
*
|
|
169
171
|
* Everything else, defects included, is the kit's default report. Lines built
|
|
170
172
|
* here from data are sanitised: the kit keeps a person's escapes, so it cannot
|
|
171
173
|
* strip an injected one from a `render`'s output.
|
|
172
174
|
*/
|
|
173
175
|
const render = (error, details) => {
|
|
174
176
|
if (details.isDefect) return details.defaultLines;
|
|
175
|
-
if (isTagged(error, "ConfigValidationError")) return ConfigIssueRenderer.render(error).some((line) => line.startsWith("unknown key")) ? [...details.defaultLines, "Run 'reposets doctor' for suggested spellings."] : details.defaultLines;
|
|
176
|
-
if (
|
|
177
|
+
if (Predicate.isTagged(error, "ConfigValidationError")) return ConfigIssueRenderer.render(error).some((line) => line.startsWith("unknown key")) ? [...details.defaultLines, "Run 'reposets doctor' for suggested spellings."] : details.defaultLines;
|
|
178
|
+
if (Predicate.hasProperty(error, "cause") && error.cause !== void 0) {
|
|
179
|
+
const cause = Fmt.causeMessage(error.cause) ?? Fmt.describe(error.cause);
|
|
180
|
+
const where = Predicate.hasProperty(error, "path") && Predicate.isString(error.path) ? `${error.path}: ` : "";
|
|
181
|
+
return [...details.defaultLines, ` ${Fmt.sanitize(`${where}${cause}`)}`];
|
|
182
|
+
}
|
|
177
183
|
return details.defaultLines;
|
|
178
184
|
};
|
|
179
185
|
NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }), {
|
|
@@ -181,8 +187,7 @@ NodeRuntime.runMain(CliRuntime.main(CliAudience.run(cli, { version: VERSION }),
|
|
|
181
187
|
env: {
|
|
182
188
|
audienceEnvVar: "REPOSETS_AUDIENCE",
|
|
183
189
|
log: { envVar: "REPOSETS_LOG_LEVEL" },
|
|
184
|
-
spansEnvVar: "REPOSETS_SPANS"
|
|
185
|
-
stderrIsTerminal: Effect.sync(() => process.stderr.isTTY === true)
|
|
190
|
+
spansEnvVar: "REPOSETS_SPANS"
|
|
186
191
|
},
|
|
187
192
|
render,
|
|
188
193
|
helpOnUsageError: "stderr"
|
|
@@ -96,24 +96,6 @@ const SECRET_IN_NAME = "That looks like a token, not a name. It was not stored."
|
|
|
96
96
|
/** What `--env` names, and what the prompt accepts for it: a shell variable identifier. */
|
|
97
97
|
const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
98
98
|
/**
|
|
99
|
-
* Write credentials back to the file they were read from.
|
|
100
|
-
*
|
|
101
|
-
* @remarks
|
|
102
|
-
* `ConfigFileShape.save` resolves its own default path — the XDG one — rather
|
|
103
|
-
* than the path `discover` found. With a project-local
|
|
104
|
-
* `reposets.credentials.toml` present, that splits reads from writes: `create`
|
|
105
|
-
* lands in XDG while `list` keeps reading the local file, so a profile is
|
|
106
|
-
* created that the user cannot see and that sync will not use. Writing back to
|
|
107
|
-
* the discovered source keeps one file authoritative, and falls back to `save`
|
|
108
|
-
* only when there is genuinely nothing to update.
|
|
109
|
-
*/
|
|
110
|
-
const saveWhereRead = (file, value) => Effect.gen(function* () {
|
|
111
|
-
const existing = (yield* file.discover.pipe(Effect.orElseSucceed(() => [])))[0];
|
|
112
|
-
if (existing === void 0) return yield* file.save(value);
|
|
113
|
-
yield* file.write(value, existing.path);
|
|
114
|
-
return existing.path;
|
|
115
|
-
});
|
|
116
|
-
/**
|
|
117
99
|
* Refuse an invocation the user got wrong.
|
|
118
100
|
*
|
|
119
101
|
* @remarks
|
|
@@ -284,7 +266,7 @@ const createHandler = (input) => Effect.gen(function* () {
|
|
|
284
266
|
...actsAs,
|
|
285
267
|
github_token
|
|
286
268
|
};
|
|
287
|
-
const path = yield*
|
|
269
|
+
const path = yield* credentialsFile.save({ profiles: {
|
|
288
270
|
...existing.profiles,
|
|
289
271
|
[profile]: record
|
|
290
272
|
} });
|
|
@@ -378,7 +360,7 @@ const deleteHandler = (profile) => Effect.gen(function* () {
|
|
|
378
360
|
}), "Provide --profile <name>: the profile to delete."));
|
|
379
361
|
if (credentials.profiles[name] === void 0) return yield* refuse(`Profile '${name}' not found.`);
|
|
380
362
|
const { [name]: _removed, ...remaining } = credentials.profiles;
|
|
381
|
-
const path = yield*
|
|
363
|
+
const path = yield* credentialsFile.save({ profiles: remaining });
|
|
382
364
|
yield* CliMessage.success(`Deleted profile '${name}' from ${path}.`);
|
|
383
365
|
});
|
|
384
366
|
const deleteCommand = Command.make("delete", { profile: profileFlag }, ({ profile }) => deleteHandler(Option.getOrUndefined(profile))).pipe(Command.withDescription("Remove a credential profile"));
|
package/cli/commands/doctor.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import { SettingsGroupSchema } from "../../schemas/config.js";
|
|
2
2
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
3
|
-
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
4
3
|
import { Invocation } from "../../services/Invocation.js";
|
|
5
|
-
import {
|
|
4
|
+
import { CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
5
|
+
import { STORE_OPTIONS } from "../../store/files.js";
|
|
6
6
|
import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
7
7
|
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import { CliMessage, Doc, SchemaIssueRenderer, Status } from "@effected/cli";
|
|
8
|
+
import { Config, Console, Effect, FileSystem, Layer, Option, Path, Predicate, Result } from "effect";
|
|
9
|
+
import { AppStore } from "@effected/app";
|
|
10
|
+
import { CliMessage, Doc, Fmt, SchemaIssueRenderer, Status } from "@effected/cli";
|
|
11
11
|
import { Command } from "effect/cli";
|
|
12
12
|
import { AppDirs } from "@effected/xdg";
|
|
13
13
|
import { GitHubClient } from "@effected/github";
|
|
@@ -157,69 +157,37 @@ const REQUIRED_PERMISSIONS = [
|
|
|
157
157
|
"resolve team slugs (org-owned repos only)"
|
|
158
158
|
]
|
|
159
159
|
];
|
|
160
|
-
const levenshtein = (a, b) => {
|
|
161
|
-
const matrix = [];
|
|
162
|
-
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
|
|
163
|
-
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
|
|
164
|
-
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
|
|
165
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
166
|
-
const row = matrix[i];
|
|
167
|
-
const prev = matrix[i - 1];
|
|
168
|
-
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + cost);
|
|
169
|
-
}
|
|
170
|
-
return matrix[a.length][b.length];
|
|
171
|
-
};
|
|
172
|
-
/** The nearest known key within edit distance 3, if any. */
|
|
173
|
-
const findClosestMatch = (key, known) => {
|
|
174
|
-
let best;
|
|
175
|
-
let bestDist = Number.POSITIVE_INFINITY;
|
|
176
|
-
for (const candidate of known) {
|
|
177
|
-
const dist = levenshtein(key, candidate);
|
|
178
|
-
if (dist < bestDist && dist <= 3) {
|
|
179
|
-
bestDist = dist;
|
|
180
|
-
best = candidate;
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return best;
|
|
184
|
-
};
|
|
185
160
|
/**
|
|
186
|
-
*
|
|
161
|
+
* The nearest known key within edit distance 3, if any.
|
|
187
162
|
*
|
|
188
163
|
* @remarks
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
*
|
|
196
|
-
* `ConfigCodecError` carries `codec` and `operation` and **no path**, so a TOML
|
|
197
|
-
* *syntax* error cannot name the file it failed to parse. That is the most
|
|
198
|
-
* ordinary config mistake there is, and this command exists to say which file
|
|
199
|
-
* is wrong — so it finds the file itself and parses it for the line and column.
|
|
164
|
+
* Three edits, the threshold this check had before it moved onto the kit;
|
|
165
|
+
* `Fmt.suggest` defaults to two, `effect/cli`'s threshold. Ties keep the
|
|
166
|
+
* first known key, as before.
|
|
167
|
+
*/
|
|
168
|
+
const findClosestMatch = (key, known) => Fmt.suggest(key, known, { maxDistance: 3 })[0];
|
|
169
|
+
/**
|
|
170
|
+
* The credential resolver `doctor`'s live token check runs through.
|
|
200
171
|
*
|
|
201
|
-
*
|
|
172
|
+
* @remarks
|
|
173
|
+
* Bound once at module scope and provided once around the whole check, not
|
|
174
|
+
* rebuilt per profile: a layer built inside the loop was a fresh reference on
|
|
175
|
+
* every iteration, so it built the resolver and its 1Password client once per
|
|
176
|
+
* profile.
|
|
177
|
+
*/
|
|
178
|
+
const ResolverLive = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
179
|
+
/**
|
|
180
|
+
* The file a failed discovery was reading.
|
|
202
181
|
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
182
|
+
* @remarks
|
|
183
|
+
* Every failure `discover` can raise names its file: `ConfigFileReadError`
|
|
184
|
+
* and `ConfigCodecError` carry `path` (the codec's is filled in by
|
|
185
|
+
* `ConfigFile`'s read pipeline), and `ConfigValidationError` an
|
|
186
|
+
* `Option` of it. That is what lets a TOML *syntax* error — the most ordinary
|
|
187
|
+
* config mistake there is — name the file it failed to parse, so this command
|
|
188
|
+
* no longer re-walks the resolver chain to find the file itself.
|
|
205
189
|
*/
|
|
206
|
-
const
|
|
207
|
-
const candidates = [];
|
|
208
|
-
if (configFlag !== void 0) candidates.push(configFlag, path.join(configFlag, CONFIG_FILENAME));
|
|
209
|
-
let dir = cwd;
|
|
210
|
-
for (;;) {
|
|
211
|
-
candidates.push(path.join(dir, CONFIG_FILENAME));
|
|
212
|
-
const parent = path.dirname(dir);
|
|
213
|
-
if (parent === dir) break;
|
|
214
|
-
dir = parent;
|
|
215
|
-
}
|
|
216
|
-
candidates.push(path.join(appDirs.dirs.config, CONFIG_FILENAME));
|
|
217
|
-
for (const candidate of candidates) {
|
|
218
|
-
const info = yield* fs.stat(candidate).pipe(Effect.option);
|
|
219
|
-
if (info._tag === "Some" && info.value.type === "File") return candidate;
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
190
|
+
const failedPath = (failure) => failure._tag === "ConfigValidationError" ? Option.getOrUndefined(failure.path) : failure.path;
|
|
223
191
|
/**
|
|
224
192
|
* `reposets doctor` — config diagnostics, typo detection, credential posture.
|
|
225
193
|
*
|
|
@@ -286,7 +254,7 @@ const describeIdentity = (name, profile, login) => {
|
|
|
286
254
|
text: `Token [${name}]: resolved, but authenticates as ${login} while the profile declares username = "${owner}"`
|
|
287
255
|
};
|
|
288
256
|
};
|
|
289
|
-
const doctorHandler =
|
|
257
|
+
const doctorHandler = Effect.gen(function* () {
|
|
290
258
|
const configFile = yield* ReposetsConfigFile;
|
|
291
259
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
292
260
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -297,7 +265,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
297
265
|
const sources = discovered._tag === "Success" ? discovered.success : [];
|
|
298
266
|
const schemaValid = discovered._tag === "Success" && sources.length > 0;
|
|
299
267
|
const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
|
|
300
|
-
const configPath = sources[0]?.path
|
|
268
|
+
const configPath = discovered._tag === "Success" ? sources[0]?.path : failedPath(discovered.failure);
|
|
301
269
|
if (configPath === void 0) {
|
|
302
270
|
yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
|
|
303
271
|
return;
|
|
@@ -309,7 +277,7 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
309
277
|
["Version: ", invocation.version],
|
|
310
278
|
["Config: ", Doc.file(configPath)],
|
|
311
279
|
["Credentials file: ", ...Array.isArray(credentialsLine) ? credentialsLine : [credentialsLine]],
|
|
312
|
-
["State database: ", Doc.file(
|
|
280
|
+
["State database: ", Doc.file(yield* AppStore.location(STORE_OPTIONS))]
|
|
313
281
|
])]);
|
|
314
282
|
const text = yield* fs.readFileString(configPath).pipe(Effect.option);
|
|
315
283
|
if (text._tag === "None") {
|
|
@@ -320,13 +288,13 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
320
288
|
const parsed = Toml.parseResult(text.value);
|
|
321
289
|
if (Result.isFailure(parsed)) {
|
|
322
290
|
yield* Doc.print([files]);
|
|
323
|
-
yield* CliMessage.failure(`TOML parse error: ${
|
|
291
|
+
yield* CliMessage.failure(`TOML parse error: ${Fmt.sanitize(Fmt.describe(parsed.failure))}`);
|
|
324
292
|
return;
|
|
325
293
|
}
|
|
326
|
-
const raw =
|
|
294
|
+
const raw = Predicate.isObject(parsed.success) ? parsed.success : {};
|
|
327
295
|
const warnings = [];
|
|
328
296
|
const checkKeys = (target, known, where) => {
|
|
329
|
-
if (!
|
|
297
|
+
if (!Predicate.isObject(target)) return;
|
|
330
298
|
for (const key of Object.keys(target)) {
|
|
331
299
|
if (known.has(key)) continue;
|
|
332
300
|
const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
|
|
@@ -340,9 +308,9 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
340
308
|
}
|
|
341
309
|
};
|
|
342
310
|
checkKeys(raw, KNOWN_CONFIG_KEYS, "");
|
|
343
|
-
const settingsSection =
|
|
311
|
+
const settingsSection = Predicate.isObject(raw.settings) ? raw.settings : {};
|
|
344
312
|
for (const [groupName, body] of Object.entries(settingsSection)) {
|
|
345
|
-
if (!
|
|
313
|
+
if (!Predicate.isObject(body)) continue;
|
|
346
314
|
for (const field of Object.keys(body)) {
|
|
347
315
|
if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
|
|
348
316
|
const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
|
|
@@ -351,11 +319,11 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
351
319
|
}
|
|
352
320
|
}
|
|
353
321
|
const groups = raw.groups;
|
|
354
|
-
if (
|
|
322
|
+
if (Predicate.isObject(groups)) for (const [groupName, group] of Object.entries(groups)) {
|
|
355
323
|
checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
|
|
356
|
-
if (!
|
|
324
|
+
if (!Predicate.isObject(group)) continue;
|
|
357
325
|
const cleanup = group.cleanup;
|
|
358
|
-
if (!
|
|
326
|
+
if (!Predicate.isObject(cleanup)) continue;
|
|
359
327
|
const prefix = `groups.${groupName}.cleanup`;
|
|
360
328
|
checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
|
|
361
329
|
checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
|
|
@@ -363,15 +331,15 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
363
331
|
}
|
|
364
332
|
const issueLines = schemaValid ? [] : SchemaIssueRenderer.render(validationIssue);
|
|
365
333
|
const schema = Doc.section("Schema", [schemaValid ? Doc.paragraph(Doc.status(Status.core, "success"), " Schema validation: passed") : Doc.paragraph(Doc.status(Status.core, "failure"), " Schema validation: FAILED — these values are rejected, not ignored"), ...issueLines.length === 0 ? [] : [Doc.verbatim(issueLines.join("\n"), { indent: 2 })]]);
|
|
366
|
-
const keys = Doc.section("Config keys", [...warnings.length === 0 ? [] : [Doc.list(warnings.map((warning) => Doc.paragraph(Doc.status(Status.core, "warning"), " ", warning)), { compact: true })], warnings.length === 0 ? Doc.paragraph(Doc.status(Status.core, "success"), " No unknown keys detected.") : Doc.paragraph(`${warnings.length
|
|
334
|
+
const keys = Doc.section("Config keys", [...warnings.length === 0 ? [] : [Doc.list(warnings.map((warning) => Doc.paragraph(Doc.status(Status.core, "warning"), " ", warning)), { compact: true })], warnings.length === 0 ? Doc.paragraph(Doc.status(Status.core, "success"), " No unknown keys detected.") : Doc.paragraph(`${Fmt.plural(warnings.length, "warning")} found.`)]);
|
|
367
335
|
const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
|
|
368
336
|
const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
|
|
369
337
|
const profileNames = Object.keys(credentials.profiles);
|
|
370
338
|
const credentialBlocks = [];
|
|
371
|
-
if (loaded._tag === "Failure") credentialBlocks.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Credentials: FAILED to load — ${
|
|
339
|
+
if (loaded._tag === "Failure") credentialBlocks.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Credentials: FAILED to load — ${Fmt.sanitize(Fmt.describe(loaded.failure))}`), Doc.paragraph("Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment."));
|
|
372
340
|
else if (profileNames.length === 0) credentialBlocks.push(Doc.paragraph("Credentials: no profiles configured"));
|
|
373
341
|
else {
|
|
374
|
-
credentialBlocks.push(Doc.paragraph(`Credentials: ${profileNames.length
|
|
342
|
+
credentialBlocks.push(Doc.paragraph(`Credentials: ${Fmt.plural(profileNames.length, "profile")}`));
|
|
375
343
|
let usesOnePassword = false;
|
|
376
344
|
const profileLines = [];
|
|
377
345
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
@@ -402,26 +370,27 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
402
370
|
keys,
|
|
403
371
|
Doc.section("Credentials", credentialBlocks)
|
|
404
372
|
])]);
|
|
405
|
-
const tokenLines =
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
373
|
+
const tokenLines = yield* Effect.gen(function* () {
|
|
374
|
+
const resolver = yield* CredentialResolver;
|
|
375
|
+
const lines = [];
|
|
376
|
+
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
377
|
+
const resolved = yield* resolver.resolveGitHubToken(profile).pipe(Effect.result);
|
|
378
|
+
if (resolved._tag === "Failure") {
|
|
379
|
+
lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: could not resolve — ${Fmt.sanitize(Fmt.describe(resolved.failure))}`));
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const identity = yield* Effect.gen(function* () {
|
|
383
|
+
return yield* (yield* GitHubClient).request("GET /user", {});
|
|
384
|
+
}).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
|
|
385
|
+
if (identity._tag !== "Success") {
|
|
386
|
+
lines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: resolved but REJECTED by GitHub — ${Fmt.sanitize(Fmt.describe(identity.failure))}`));
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
const line = describeIdentity(name, profile, identity.success.login);
|
|
390
|
+
lines.push(Doc.paragraph(Doc.status(Status.core, line.ok ? "success" : "failure"), " ", line.text));
|
|
421
391
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
392
|
+
return lines;
|
|
393
|
+
}).pipe(Effect.provide(ResolverLive));
|
|
425
394
|
const permissions = Doc.section("Required fine-grained token permissions", [
|
|
426
395
|
Doc.list(REQUIRED_PERMISSIONS.map(([scope, permission, why]) => Doc.paragraph(`${scope} > ${permission} — ${why}`)), { compact: true }),
|
|
427
396
|
Doc.paragraph(Doc.text("(Metadata: Read is mandatory and granted automatically)", "muted")),
|
|
@@ -435,9 +404,6 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
435
404
|
*
|
|
436
405
|
* @public
|
|
437
406
|
*/
|
|
438
|
-
const doctorCommand = Command.make("doctor", {}, () =>
|
|
439
|
-
const flag = yield* ConfigFlag;
|
|
440
|
-
yield* doctorHandler(Option.getOrUndefined(flag));
|
|
441
|
-
})).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
|
|
407
|
+
const doctorCommand = Command.make("doctor", {}, () => doctorHandler).pipe(Command.withDescription("Diagnose the config: unknown keys, credential posture, required token permissions"));
|
|
442
408
|
|
|
443
409
|
export { describeIdentity, doctorCommand, doctorHandler };
|
package/cli/commands/history.js
CHANGED
|
@@ -21,9 +21,7 @@ const formatDuration = (summary) => {
|
|
|
21
21
|
if (summary.finishedAt === null) return "—";
|
|
22
22
|
const millis = Date.parse(summary.finishedAt) - Date.parse(summary.startedAt);
|
|
23
23
|
if (Number.isNaN(millis) || millis < 0) return "—";
|
|
24
|
-
|
|
25
|
-
const seconds = millis / 1e3;
|
|
26
|
-
return seconds < 60 ? `${seconds.toFixed(1)}s` : `${Math.floor(seconds / 60)}m${String(Math.round(seconds % 60)).padStart(2, "0")}s`;
|
|
24
|
+
return Fmt.duration(millis);
|
|
27
25
|
};
|
|
28
26
|
/**
|
|
29
27
|
* What became of a run.
|
|
@@ -212,7 +210,7 @@ const pickRun = (runs, abbreviate) => Effect.gen(function* () {
|
|
|
212
210
|
const outcome = formatOutcome(run);
|
|
213
211
|
return {
|
|
214
212
|
label: `${abbreviate(run.id)} · ${formatWhen(run.startedAt)} · ${outcome} · ${Fmt.sanitize(run.group ?? "(all)")}`,
|
|
215
|
-
detail: `${formatMode(run)} · ${
|
|
213
|
+
detail: `${formatMode(run)} · ${Fmt.plural(run.changes, "change")} · ${formatDuration(run)}`,
|
|
216
214
|
value: run
|
|
217
215
|
};
|
|
218
216
|
})
|
|
@@ -286,7 +284,7 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
286
284
|
const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
|
|
287
285
|
const action = dryRun ? `would ${wouldForm(record.action)}` : record.action;
|
|
288
286
|
return [
|
|
289
|
-
Doc.text(
|
|
287
|
+
Doc.text(Fmt.pad(action, 18), actionToken(record.action, dryRun)),
|
|
290
288
|
" ",
|
|
291
289
|
what,
|
|
292
290
|
detail
|
|
@@ -312,7 +310,7 @@ const UNTOUCHED = "Applied state and the cache are untouched — drift detection
|
|
|
312
310
|
*/
|
|
313
311
|
const pruneHandler = (keep) => Effect.gen(function* () {
|
|
314
312
|
const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
|
|
315
|
-
yield* removed === 0 ? CliMessage.info(`Nothing to prune; ${keep} or fewer runs are recorded.`) : CliMessage.success(`Pruned ${removed
|
|
313
|
+
yield* removed === 0 ? CliMessage.info(`Nothing to prune; ${keep} or fewer runs are recorded.`) : CliMessage.success(`Pruned ${Fmt.plural(removed, "run")}, keeping the newest ${keep}.`);
|
|
316
314
|
yield* CliMessage.info(UNTOUCHED);
|
|
317
315
|
});
|
|
318
316
|
/**
|
|
@@ -320,7 +318,7 @@ const pruneHandler = (keep) => Effect.gen(function* () {
|
|
|
320
318
|
*/
|
|
321
319
|
const clearHandler = () => Effect.gen(function* () {
|
|
322
320
|
const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
|
|
323
|
-
yield* CliMessage.success(`Cleared ${removed
|
|
321
|
+
yield* CliMessage.success(`Cleared ${Fmt.plural(removed, "run")} from the journal.`);
|
|
324
322
|
yield* CliMessage.info(UNTOUCHED);
|
|
325
323
|
});
|
|
326
324
|
/**
|
package/cli/commands/init.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
2
1
|
import { Invocation } from "../../services/Invocation.js";
|
|
2
|
+
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
3
3
|
import { configSchemaHost, credentialsSchemaHost, schemaDirective } from "../../schemas/hosted.js";
|
|
4
4
|
import { Effect, FileSystem, Option, Path } from "effect";
|
|
5
5
|
import { CliExit, CliMessage } from "@effected/cli";
|
package/cli/commands/nuke.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
2
1
|
import { Invocation } from "../../services/Invocation.js";
|
|
3
|
-
import {
|
|
2
|
+
import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
|
|
3
|
+
import { CACHE_OPTIONS, STORE_OPTIONS } from "../../store/files.js";
|
|
4
4
|
import { Effect, FileSystem, Path } from "effect";
|
|
5
|
-
import {
|
|
5
|
+
import { AppCache, AppStore } from "@effected/app";
|
|
6
|
+
import { CliExit, CliInteractive, CliMessage, Doc, Fmt } from "@effected/cli";
|
|
6
7
|
import { CliError, Command, Flag } from "effect/cli";
|
|
7
8
|
import { CliUi, Confirm, MultiSelect } from "@effected/cli/ui";
|
|
8
9
|
import { AppDirs, Xdg } from "@effected/xdg";
|
|
10
|
+
import { Walker } from "@effected/walker";
|
|
9
11
|
|
|
10
12
|
// src/cli/commands/nuke.ts
|
|
11
13
|
const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Delete without asking. Intended for scripts; there is no undo"));
|
|
@@ -81,23 +83,18 @@ const isInitGitignore = (contents) => {
|
|
|
81
83
|
*/
|
|
82
84
|
const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
|
|
83
85
|
const files = [];
|
|
84
|
-
|
|
85
|
-
for (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
});
|
|
97
|
-
const parent = path.dirname(dir);
|
|
98
|
-
if (parent === dir) break;
|
|
99
|
-
dir = parent;
|
|
100
|
-
}
|
|
86
|
+
const chain = yield* Walker.ascend(from);
|
|
87
|
+
for (const dir of chain) files.push({
|
|
88
|
+
path: path.join(dir, CONFIG_FILENAME),
|
|
89
|
+
what: "project config",
|
|
90
|
+
cost: "loses your groups and settings",
|
|
91
|
+
location: "project"
|
|
92
|
+
}, {
|
|
93
|
+
path: path.join(dir, CREDENTIALS_FILENAME),
|
|
94
|
+
what: "project credentials",
|
|
95
|
+
cost: "loses token references, not tokens",
|
|
96
|
+
location: "project"
|
|
97
|
+
});
|
|
101
98
|
files.push({
|
|
102
99
|
path: path.join(appDirs.dirs.config, CONFIG_FILENAME),
|
|
103
100
|
what: "user config",
|
|
@@ -124,12 +121,12 @@ const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
|
|
|
124
121
|
location: "user"
|
|
125
122
|
});
|
|
126
123
|
const databases = [{
|
|
127
|
-
path:
|
|
124
|
+
path: yield* AppStore.location(STORE_OPTIONS),
|
|
128
125
|
what: "state database",
|
|
129
126
|
cost: "loses run history AND the drift baselines — drift detection restarts from nothing",
|
|
130
127
|
location: "state"
|
|
131
128
|
}, {
|
|
132
|
-
path:
|
|
129
|
+
path: yield* AppCache.location(CACHE_OPTIONS),
|
|
133
130
|
what: "cache database",
|
|
134
131
|
cost: "loses cached GitHub reads — rebuilt on the next sync",
|
|
135
132
|
location: "cache"
|
|
@@ -168,13 +165,12 @@ const removeEmptyDirs = (fs, appDirs) => Effect.gen(function* () {
|
|
|
168
165
|
if (entries._tag === "None" || entries.value.length > 0) continue;
|
|
169
166
|
const outcome = yield* fs.remove(dir, { recursive: true }).pipe(Effect.result);
|
|
170
167
|
if (outcome._tag === "Failure") {
|
|
171
|
-
yield* Effect.logWarning(`could not remove empty directory ${dir} — ${
|
|
168
|
+
yield* Effect.logWarning(`could not remove empty directory ${dir} — ${Fmt.describe(outcome.failure)}`);
|
|
172
169
|
continue;
|
|
173
170
|
}
|
|
174
171
|
yield* CliMessage.success(`removed empty directory ${dir}`);
|
|
175
172
|
}
|
|
176
173
|
});
|
|
177
|
-
const plural = (n) => `${n} file${n === 1 ? "" : "s"}`;
|
|
178
174
|
/**
|
|
179
175
|
* How many files a set of targets removes.
|
|
180
176
|
*
|
|
@@ -269,7 +265,7 @@ const choose = (targets, short) => Effect.gen(function* () {
|
|
|
269
265
|
sections: pickerSections(targets, short)
|
|
270
266
|
}));
|
|
271
267
|
if (chosen.length === 0) return [];
|
|
272
|
-
const { confirmed } = yield* CliUi.run(Confirm.screen({ message: `Delete ${plural(fileCount(chosen))}?` }));
|
|
268
|
+
const { confirmed } = yield* CliUi.run(Confirm.screen({ message: `Delete ${Fmt.plural(fileCount(chosen), "file")}?` }));
|
|
273
269
|
return confirmed ? chosen : [];
|
|
274
270
|
}).pipe(Effect.catchTag("NotInteractive", () => Effect.fail(refusal())));
|
|
275
271
|
/**
|
|
@@ -336,16 +332,16 @@ const nukeHandler = (force) => Effect.gen(function* () {
|
|
|
336
332
|
for (const target of targets) for (const file of target.paths) {
|
|
337
333
|
const outcome = yield* fs.remove(file).pipe(Effect.result);
|
|
338
334
|
if (outcome._tag === "Failure") {
|
|
339
|
-
yield* Effect.logError(`could not remove ${file} — ${
|
|
335
|
+
yield* Effect.logError(`could not remove ${file} — ${Fmt.describe(outcome.failure)}`);
|
|
340
336
|
continue;
|
|
341
337
|
}
|
|
342
338
|
yield* CliMessage.success(`removed ${file}`);
|
|
343
339
|
removed += 1;
|
|
344
340
|
}
|
|
345
341
|
yield* removeEmptyDirs(fs, appDirs);
|
|
346
|
-
if (removed === total) yield* CliMessage.success(`Done. ${plural(removed)} removed.`);
|
|
342
|
+
if (removed === total) yield* CliMessage.success(`Done. ${Fmt.plural(removed, "file")} removed.`);
|
|
347
343
|
else {
|
|
348
|
-
yield* CliMessage.failure(`Removed ${removed} of ${plural(total)}; the failures are listed above.`);
|
|
344
|
+
yield* CliMessage.failure(`Removed ${removed} of ${Fmt.plural(total, "file")}; the failures are listed above.`);
|
|
349
345
|
yield* CliExit.set(1);
|
|
350
346
|
}
|
|
351
347
|
});
|
package/cli/commands/sync.js
CHANGED
|
@@ -18,22 +18,6 @@ import { CliUi } from "@effected/cli/ui";
|
|
|
18
18
|
import { CodeScanning, DeploymentEnvironment, GitHubClient, GitHubRepository, RepositorySecret, RepositorySecurity, RepositoryVariable, Ruleset, WorkflowDispatch } from "@effected/github";
|
|
19
19
|
|
|
20
20
|
// src/cli/commands/sync.ts
|
|
21
|
-
const isPhaseName = (value) => PHASE_NAMES.includes(value);
|
|
22
|
-
const phaseSet = (values) => new Set(values.filter(isPhaseName));
|
|
23
|
-
/**
|
|
24
|
-
* Phase names the user typed that do not exist.
|
|
25
|
-
*
|
|
26
|
-
* @remarks
|
|
27
|
-
* Unrecognised names used to be filtered out silently, and an empty `only` set
|
|
28
|
-
* reads downstream as "no filter" — so `--only nonsense` ran **all eight
|
|
29
|
-
* phases**. The flag reached for to limit the blast radius of a destructive
|
|
30
|
-
* command did the opposite of what it says, and reported success.
|
|
31
|
-
*
|
|
32
|
-
* Any unknown name is refused, including alongside valid ones: a typo in
|
|
33
|
-
* `--only settings,secrts` means the user asked for two phases and would
|
|
34
|
-
* otherwise get one, silently. That is a narrower wrong answer, not a safer one.
|
|
35
|
-
*/
|
|
36
|
-
const unknownPhases = (values) => values.filter((v) => !isPhaseName(v));
|
|
37
21
|
/**
|
|
38
22
|
* Group names by the credential profile they authenticate as.
|
|
39
23
|
*
|
|
@@ -148,8 +132,6 @@ const runUnderSyncView = (view, work) => Effect.scoped(Effect.gen(function* () {
|
|
|
148
132
|
* @public
|
|
149
133
|
*/
|
|
150
134
|
const syncHandler = (input) => Effect.gen(function* () {
|
|
151
|
-
const badPhases = [...unknownPhases(input.only), ...unknownPhases(input.skip)];
|
|
152
|
-
if (badPhases.length > 0) return yield* Effect.fail(new CliError.UserError({ cause: `Unknown phase name(s): ${badPhases.join(", ")}. Valid phases: ${PHASE_NAMES.join(", ")}. Nothing was synced.` }));
|
|
153
135
|
const configFile = yield* ReposetsConfigFile;
|
|
154
136
|
const credentialsFile = yield* ReposetsCredentialsFile;
|
|
155
137
|
const source = (yield* configFile.discover)[0];
|
|
@@ -237,8 +219,8 @@ const syncHandler = (input) => Effect.gen(function* () {
|
|
|
237
219
|
configDir,
|
|
238
220
|
group: input.group,
|
|
239
221
|
repo: input.repo,
|
|
240
|
-
only:
|
|
241
|
-
skip:
|
|
222
|
+
only: new Set(input.only),
|
|
223
|
+
skip: new Set(input.skip)
|
|
242
224
|
});
|
|
243
225
|
}).pipe(Effect.provide(engineLayer));
|
|
244
226
|
repos += partial.repos;
|
|
@@ -278,8 +260,8 @@ const syncCommand = Command.make("sync", {
|
|
|
278
260
|
failOnDrift: Flag.Boolean("fail-on-drift").pipe(Flag.withDefault(false), Flag.withDescription("Exit non-zero when a resource was changed outside reposets")),
|
|
279
261
|
group: Flag.String("group").pipe(Flag.optional, Flag.withDescription("Sync only this group")),
|
|
280
262
|
repo: Flag.String("repo").pipe(Flag.optional, Flag.withDescription("Sync only this repository")),
|
|
281
|
-
only: Flag.
|
|
282
|
-
skip: Flag.
|
|
263
|
+
only: Flag.Literals("only", PHASE_NAMES).pipe(Flag.atLeast(0), Flag.withDescription("Run only these phases")),
|
|
264
|
+
skip: Flag.Literals("skip", PHASE_NAMES).pipe(Flag.atLeast(0), Flag.withDescription("Skip these phases")),
|
|
283
265
|
debug: Flag.Boolean("debug").pipe(Flag.withDefault(false), Flag.withDescription("Annotate output with value sources and the fingerprints behind a drift report"))
|
|
284
266
|
}, (input) => syncHandler({
|
|
285
267
|
dryRun: input.dryRun,
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { FileSystem, Layer, Path, Schema } from "effect";
|
|
1
|
+
import { Context, FileSystem, Layer, Path, Schema } from "effect";
|
|
2
2
|
import { AppDirs, Xdg } from "@effected/xdg";
|
|
3
3
|
/**
|
|
4
4
|
* The whole `reposets.config.toml` document.
|
|
@@ -323,6 +323,44 @@ export declare const ConfigSchema: Schema.Struct<{
|
|
|
323
323
|
* @public
|
|
324
324
|
*/
|
|
325
325
|
type Config = typeof ConfigSchema.Type;
|
|
326
|
+
declare const Invocation_base: Context.ServiceClass<Invocation, "reposets/Invocation", {
|
|
327
|
+
/** The directory the command was run from. */
|
|
328
|
+
readonly cwd: string;
|
|
329
|
+
/** The build's version, substituted by the bundler at build time. */
|
|
330
|
+
readonly version: string;
|
|
331
|
+
}>;
|
|
332
|
+
/**
|
|
333
|
+
* The facts about this process a command needs, read once at the entrypoint.
|
|
334
|
+
*
|
|
335
|
+
* @remarks
|
|
336
|
+
* Only the CLI entrypoint (`src/cli/index.ts`) reads `process`. Everything
|
|
337
|
+
* below it receives the working directory and the build's version through this
|
|
338
|
+
* service instead, so a handler can be driven by a test that hands it a
|
|
339
|
+
* different directory without `process.chdir`.
|
|
340
|
+
*
|
|
341
|
+
* The environment is deliberately NOT here. Environment variables are read
|
|
342
|
+
* through Effect's `Config`, which resolves them from the ambient
|
|
343
|
+
* `ConfigProvider` — the process environment by default, an explicit record
|
|
344
|
+
* under `ConfigProvider.layer` in a test. That is core's own seam for the
|
|
345
|
+
* environment, and `@effected/xdg` already reads `HOME` and `XDG_*` through it;
|
|
346
|
+
* a second, hand-rolled one here would let the two disagree.
|
|
347
|
+
*
|
|
348
|
+
* The whole shape is one immutable value with no behaviour, so `Layer.succeed`
|
|
349
|
+
* is the complete double — there is nothing for a partial mock to stub. The
|
|
350
|
+
* moment a method appears here it stops being a value and becomes a service.
|
|
351
|
+
*
|
|
352
|
+
* @public
|
|
353
|
+
*/
|
|
354
|
+
export declare class Invocation extends Invocation_base {
|
|
355
|
+
/**
|
|
356
|
+
* A fixed invocation, for tests and for the entrypoint alike.
|
|
357
|
+
*
|
|
358
|
+
* @remarks
|
|
359
|
+
* Each call mints a fresh layer; bind the result to a `const` rather than
|
|
360
|
+
* calling this at more than one provide site.
|
|
361
|
+
*/
|
|
362
|
+
static readonly layer: (value: Context.Service.Shape<typeof Invocation>) => Layer.Layer<Invocation>;
|
|
363
|
+
}
|
|
326
364
|
/**
|
|
327
365
|
* The config file's fixed name, in every tier of the resolver chain.
|
|
328
366
|
*
|
|
@@ -747,6 +785,6 @@ export declare class ConfigFlagMissingConfig extends ConfigFlagMissingConfig_bas
|
|
|
747
785
|
*
|
|
748
786
|
* @public
|
|
749
787
|
*/
|
|
750
|
-
export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg>;
|
|
788
|
+
export declare const makeConfigFilesLive: (configFlag: string | undefined) => Layer.Layer<ReposetsConfigFile, ConfigFlagNotFound | ConfigFlagMissingConfig, FileSystem.FileSystem | Path.Path | AppDirs | Xdg | Invocation>;
|
|
751
789
|
export type { Config };
|
|
752
790
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConfigSchema } from "./schemas/config.js";
|
|
2
|
+
import { Invocation } from "./services/Invocation.js";
|
|
2
3
|
import { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ReposetsConfigFile, makeConfigFilesLive } from "./services/ConfigFiles.js";
|
|
3
4
|
|
|
4
|
-
export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, ReposetsConfigFile, makeConfigFilesLive };
|
|
5
|
+
export { CONFIG_FILENAME, ConfigFlagMissingConfig, ConfigFlagNotFound, ConfigSchema, Invocation, ReposetsConfigFile, makeConfigFilesLive };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reposets",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "CLI tool to sync GitHub repo settings, secrets and rulesets across personal repositories",
|
|
6
6
|
"keywords": [
|
|
@@ -48,20 +48,22 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@1password/sdk": "^0.5.0",
|
|
51
|
-
"@effect/platform-node": "^4.0.
|
|
52
|
-
"@effected/app": "^0.
|
|
53
|
-
"@effected/cli": "^0.
|
|
54
|
-
"@effected/config-file": "^0.
|
|
55
|
-
"@effected/env": "^0.
|
|
56
|
-
"@effected/github": "^0.
|
|
57
|
-
"@effected/glob": "^0.
|
|
58
|
-
"@effected/
|
|
59
|
-
"@effected/
|
|
60
|
-
"@effected/
|
|
61
|
-
"@effected/
|
|
62
|
-
"@effected/
|
|
51
|
+
"@effect/platform-node": "^4.0.7",
|
|
52
|
+
"@effected/app": "^0.29.0",
|
|
53
|
+
"@effected/cli": "^0.24.0",
|
|
54
|
+
"@effected/config-file": "^0.22.0",
|
|
55
|
+
"@effected/env": "^0.7.0",
|
|
56
|
+
"@effected/github": "^0.26.0",
|
|
57
|
+
"@effected/glob": "^0.16.0",
|
|
58
|
+
"@effected/jsonc": "^0.21.0",
|
|
59
|
+
"@effected/schemastore": "^0.27.0",
|
|
60
|
+
"@effected/store": "^0.21.0",
|
|
61
|
+
"@effected/toml": "^0.17.0",
|
|
62
|
+
"@effected/walker": "^0.22.0",
|
|
63
|
+
"@effected/xdg": "^0.17.0",
|
|
64
|
+
"@effected/yaml": "^0.26.0",
|
|
63
65
|
"blakejs": "^1.2.1",
|
|
64
|
-
"effect": "^4.0.
|
|
66
|
+
"effect": "^4.0.7",
|
|
65
67
|
"ink": "^8.0.0",
|
|
66
68
|
"react": "^19.3.1"
|
|
67
69
|
},
|
package/services/ConfigFiles.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ConfigSchema } from "../schemas/config.js";
|
|
2
2
|
import { CredentialsSchema } from "../schemas/credentials.js";
|
|
3
|
+
import { Invocation } from "./Invocation.js";
|
|
3
4
|
import { Data, Effect, FileSystem, Layer, Path } from "effect";
|
|
4
5
|
import { AppConfig } from "@effected/app";
|
|
5
6
|
import { ConfigFile, ConfigResolver } from "@effected/config-file";
|
|
@@ -73,15 +74,30 @@ const STRICT_KEYS = {
|
|
|
73
74
|
* `AppConfig.layer` appends its own XDG tier after the resolvers given here, so
|
|
74
75
|
* the upward walk wins and the XDG fallback comes free.
|
|
75
76
|
*
|
|
77
|
+
* The walk starts at {@link Invocation}'s `cwd`, never the process's own:
|
|
78
|
+
* `upwardWalk` defaults to `process.cwd()` when given no `cwd`, which made
|
|
79
|
+
* config discovery the one reader of `process` below the entrypoint.
|
|
80
|
+
*
|
|
81
|
+
* **Writes go back to the file that was found.** `AppConfig` pins
|
|
82
|
+
* `saveTarget: "default"`, the XDG path, which would split reads from writes
|
|
83
|
+
* when a project-local `reposets.credentials.toml` exists: `credentials create`
|
|
84
|
+
* would land in XDG while `list` and `sync` keep reading the local file.
|
|
85
|
+
* `"source"` writes to the highest-priority file the chain found, and to XDG
|
|
86
|
+
* only when there is none.
|
|
87
|
+
*
|
|
76
88
|
* @public
|
|
77
89
|
*/
|
|
78
|
-
const CredentialsFilesLive = AppConfig.layer(ReposetsCredentialsFile, {
|
|
90
|
+
const CredentialsFilesLive = Layer.unwrap(Effect.map(Invocation, ({ cwd }) => AppConfig.layer(ReposetsCredentialsFile, {
|
|
79
91
|
filename: CREDENTIALS_FILENAME,
|
|
80
92
|
schema: CredentialsSchema,
|
|
81
93
|
codec: TomlCodec,
|
|
82
|
-
resolvers: [ConfigResolver.upwardWalk({
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
resolvers: [ConfigResolver.upwardWalk({
|
|
95
|
+
filename: CREDENTIALS_FILENAME,
|
|
96
|
+
cwd
|
|
97
|
+
})],
|
|
98
|
+
parseOptions: STRICT_KEYS,
|
|
99
|
+
saveTarget: "source"
|
|
100
|
+
})));
|
|
85
101
|
/**
|
|
86
102
|
* Raised when `--config` names a path that does not exist.
|
|
87
103
|
*
|
|
@@ -144,14 +160,23 @@ var ConfigFlagMissingConfig = class extends Data.TaggedError("ConfigFlagMissingC
|
|
|
144
160
|
* A directory is checked for the config file by existence only, not decoded:
|
|
145
161
|
* a file that is present but invalid must still resolve, so `doctor` can
|
|
146
162
|
* diagnose it and a command can report its decode error against the right path.
|
|
163
|
+
*
|
|
164
|
+
* Everything relative is taken against {@link Invocation}'s `cwd`: the upward
|
|
165
|
+
* walk starts there, and a relative `--config` is resolved there before it is
|
|
166
|
+
* checked, so a test that hands a command another directory moves both.
|
|
147
167
|
*/
|
|
148
|
-
const resolversFor = (
|
|
149
|
-
|
|
168
|
+
const resolversFor = (flag) => Effect.gen(function* () {
|
|
169
|
+
const { cwd } = yield* Invocation;
|
|
170
|
+
if (flag === void 0) return [ConfigResolver.upwardWalk({
|
|
171
|
+
filename: CONFIG_FILENAME,
|
|
172
|
+
cwd
|
|
173
|
+
})];
|
|
150
174
|
const fs = yield* FileSystem.FileSystem;
|
|
175
|
+
const path = yield* Path.Path;
|
|
176
|
+
const configFlag = path.resolve(cwd, flag);
|
|
151
177
|
const info = yield* fs.stat(configFlag).pipe(Effect.option);
|
|
152
178
|
if (info._tag === "None") return yield* new ConfigFlagNotFound({ path: configFlag });
|
|
153
179
|
if (info.value.type !== "Directory") return [ConfigResolver.explicitPath(configFlag)];
|
|
154
|
-
const path = yield* Path.Path;
|
|
155
180
|
if (!(yield* fs.exists(path.join(configFlag, "reposets.config.toml")).pipe(Effect.orElseSucceed(() => false)))) return yield* new ConfigFlagMissingConfig({
|
|
156
181
|
dir: configFlag,
|
|
157
182
|
filename: CONFIG_FILENAME
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { OnePasswordClient } from "./OnePasswordClient.js";
|
|
2
|
-
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { Config, Context, Data, Effect, FileSystem, Layer, Option, Path, Redacted } from "effect";
|
|
5
3
|
|
|
6
4
|
// src/services/CredentialResolver.ts
|
|
7
5
|
/**
|
|
@@ -44,18 +42,21 @@ const fromEnv = (label, variable) => Config.option(Config.String(variable)).pipe
|
|
|
44
42
|
})),
|
|
45
43
|
onSome: (value) => Effect.succeed(Redacted.make(value, { label }))
|
|
46
44
|
})));
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Read a file, failing in terms of its path.
|
|
47
|
+
*
|
|
48
|
+
* @remarks
|
|
49
|
+
* Through core's `FileSystem`, so a test reads from a seeded in-memory volume
|
|
50
|
+
* rather than the disk. A relative path resolves against `basePath`. The
|
|
51
|
+
* failure reason is the `PlatformError` message — `NotFound:
|
|
52
|
+
* FileSystem.readFile (/abs/path)` — which names the path and never the
|
|
53
|
+
* contents.
|
|
54
|
+
*/
|
|
55
|
+
const fromFile = (fs, path, label, filePath, basePath) => fs.readFileString(path.isAbsolute(filePath) ? filePath : path.resolve(basePath, filePath)).pipe(Effect.map((text) => Redacted.make(text.trim(), { label })), Effect.mapError((error) => new ResolveError({
|
|
56
|
+
label,
|
|
57
|
+
source: "file",
|
|
58
|
+
reason: error.message
|
|
59
|
+
})));
|
|
59
60
|
/**
|
|
60
61
|
* Turns the references in a credential profile into usable values.
|
|
61
62
|
*
|
|
@@ -84,6 +85,8 @@ const fromFile = (label, filePath, basePath) => Effect.try({
|
|
|
84
85
|
*/
|
|
85
86
|
var CredentialResolver = class extends Context.Service()("reposets/CredentialResolver", { make: Effect.gen(function* () {
|
|
86
87
|
const onePassword = yield* OnePasswordClient;
|
|
88
|
+
const fs = yield* FileSystem.FileSystem;
|
|
89
|
+
const path = yield* Path.Path;
|
|
87
90
|
/** One `op://` reference, with 1Password's failure restated in this service's terms. */
|
|
88
91
|
const fromOnePassword = (label, reference) => onePassword.resolve(reference).pipe(Effect.mapError((error) => new ResolveError({
|
|
89
92
|
label,
|
|
@@ -99,14 +102,15 @@ var CredentialResolver = class extends Context.Service()("reposets/CredentialRes
|
|
|
99
102
|
if (section === void 0) return resolved;
|
|
100
103
|
for (const [label, reference] of Object.entries(section.op ?? {})) resolved.set(label, yield* fromOnePassword(label, reference));
|
|
101
104
|
for (const [label, variable] of Object.entries(section.env ?? {})) resolved.set(label, yield* fromEnv(label, variable));
|
|
102
|
-
for (const [label, filePath] of Object.entries(section.file ?? {})) resolved.set(label, yield* fromFile(label, filePath, basePath));
|
|
105
|
+
for (const [label, filePath] of Object.entries(section.file ?? {})) resolved.set(label, yield* fromFile(fs, path, label, filePath, basePath));
|
|
103
106
|
for (const [label, value] of Object.entries(section.value ?? {})) resolved.set(label, Redacted.make(typeof value === "string" ? value : JSON.stringify(value), { label }));
|
|
104
107
|
return resolved;
|
|
105
108
|
})
|
|
106
109
|
};
|
|
107
110
|
}) }) {};
|
|
108
111
|
/**
|
|
109
|
-
* Live resolver, over the ambient {@link OnePasswordClient}
|
|
112
|
+
* Live resolver, over the ambient {@link OnePasswordClient} and the platform's
|
|
113
|
+
* `FileSystem` and `Path`.
|
|
110
114
|
*
|
|
111
115
|
* @public
|
|
112
116
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Config, Context, Data, Effect, Layer, Option, Redacted } from "effect";
|
|
2
|
+
import { Fmt } from "@effected/cli";
|
|
2
3
|
|
|
3
4
|
// src/services/OnePasswordClient.ts
|
|
4
5
|
/**
|
|
@@ -76,7 +77,7 @@ var OnePasswordClient = class OnePasswordClient extends Context.Service()("repos
|
|
|
76
77
|
},
|
|
77
78
|
catch: (error) => new OnePasswordError({
|
|
78
79
|
reference,
|
|
79
|
-
reason:
|
|
80
|
+
reason: Fmt.describe(error)
|
|
80
81
|
})
|
|
81
82
|
});
|
|
82
83
|
return Redacted.make(value, { label: reference });
|
package/services/SyncLogger.js
CHANGED
|
@@ -129,7 +129,7 @@ function SyncLoggerLive(config) {
|
|
|
129
129
|
* 8; the dry-run `would <present>` forms pad to 14. The result is
|
|
130
130
|
* concatenated directly, with no separating space.
|
|
131
131
|
*/
|
|
132
|
-
const formatVerb = (pastTense, presentTense) => dryRun ? `would ${presentTense}
|
|
132
|
+
const formatVerb = (pastTense, presentTense) => dryRun ? Fmt.pad(`would ${presentTense}`, 14) : Fmt.pad(pastTense, 8);
|
|
133
133
|
/** A change made reads as done; the same change on a dry run, as information. */
|
|
134
134
|
const changeStatus = dryRun ? "info" : "success";
|
|
135
135
|
return {
|
|
@@ -139,7 +139,7 @@ function SyncLoggerLive(config) {
|
|
|
139
139
|
dryRun
|
|
140
140
|
}),
|
|
141
141
|
groupStart: (name, selected, declared) => {
|
|
142
|
-
const scope = selected === declared ?
|
|
142
|
+
const scope = selected === declared ? Fmt.plural(selected, "repo") : `${selected} of ${Fmt.plural(declared, "repo")}`;
|
|
143
143
|
return emit(`group: ${name} (${scope})`).pipe(Effect.andThen(publish({
|
|
144
144
|
_tag: "GroupStarted",
|
|
145
145
|
name,
|
|
@@ -187,7 +187,7 @@ function SyncLoggerLive(config) {
|
|
|
187
187
|
driftDetected: (resource, name, drift) => {
|
|
188
188
|
const consequence = !drift.needsApply ? "already matches config, nothing written" : dryRun ? "would overwrite" : "overwritten";
|
|
189
189
|
const fingerprints = debug ? ` <- applied ${drift.applied} live ${drift.live}` : "";
|
|
190
|
-
return action("warning", `${"drift"
|
|
190
|
+
return action("warning", `${Fmt.pad("drift", 8)}${resource} ${name} changed outside reposets — ${consequence}${fingerprints}`).pipe(Effect.andThen(publish({
|
|
191
191
|
_tag: "Drift",
|
|
192
192
|
resource,
|
|
193
193
|
name,
|
|
@@ -213,7 +213,7 @@ function SyncLoggerLive(config) {
|
|
|
213
213
|
const errs = yield* Ref.get(errors);
|
|
214
214
|
if (errs.length === 0) yield* Console.log(`${mark("success")} Sync complete!`);
|
|
215
215
|
else {
|
|
216
|
-
yield* emitFailure(`Sync complete with ${
|
|
216
|
+
yield* emitFailure(`Sync complete with ${Fmt.plural(errs.length, "error")}:`);
|
|
217
217
|
for (const err of errs) yield* emitError(Fmt.sanitize(err.repo === "" ? ` ${err.context} — ${err.message}` : ` ${err.repo}: ${err.context} — ${err.message}`));
|
|
218
218
|
}
|
|
219
219
|
yield* publish({
|
package/store/files.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { migrations } from "./migrations.js";
|
|
2
|
+
|
|
1
3
|
// src/store/files.ts
|
|
2
4
|
/**
|
|
3
5
|
* The state database's file name, under the XDG state directory.
|
|
@@ -20,5 +22,29 @@ const STATE_DB_FILENAME = "store.db";
|
|
|
20
22
|
* @public
|
|
21
23
|
*/
|
|
22
24
|
const CACHE_DB_FILENAME = "cache.db";
|
|
25
|
+
/**
|
|
26
|
+
* The state database's options: what `AppStore.layer` opens and what
|
|
27
|
+
* `AppStore.location` reports.
|
|
28
|
+
*
|
|
29
|
+
* @remarks
|
|
30
|
+
* One value for both, so the path `doctor` prints and `nuke` deletes is
|
|
31
|
+
* derived by the same code from the same options as the path the layer opens
|
|
32
|
+
* — `AppStore.location` is that derivation, not a copy of it. The directory
|
|
33
|
+
* is left to its default, `state`.
|
|
34
|
+
*
|
|
35
|
+
* @public
|
|
36
|
+
*/
|
|
37
|
+
const STORE_OPTIONS = {
|
|
38
|
+
migrations,
|
|
39
|
+
filename: STATE_DB_FILENAME
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The cache database's options, for `AppCache.layer` and `AppCache.location`,
|
|
43
|
+
* for the same reason as {@link STORE_OPTIONS}. The directory is left to its
|
|
44
|
+
* default, `cache`.
|
|
45
|
+
*
|
|
46
|
+
* @public
|
|
47
|
+
*/
|
|
48
|
+
const CACHE_OPTIONS = { filename: CACHE_DB_FILENAME };
|
|
23
49
|
|
|
24
|
-
export { CACHE_DB_FILENAME, STATE_DB_FILENAME };
|
|
50
|
+
export { CACHE_DB_FILENAME, CACHE_OPTIONS, STATE_DB_FILENAME, STORE_OPTIONS };
|
package/sync/phase.js
CHANGED
|
@@ -5,6 +5,7 @@ import { read } from "./resource.js";
|
|
|
5
5
|
import { fingerprint } from "../../lib/fingerprint.js";
|
|
6
6
|
import { decide, isDrift, needsApply } from "../decide.js";
|
|
7
7
|
import { Effect, Option } from "effect";
|
|
8
|
+
import { Fmt } from "@effected/cli";
|
|
8
9
|
import { CodeScanning, WorkflowDispatch } from "@effected/github";
|
|
9
10
|
|
|
10
11
|
// src/sync/phases/code-scanning.ts
|
|
@@ -151,7 +152,7 @@ const codeScanningPhase = Effect.gen(function* () {
|
|
|
151
152
|
errors
|
|
152
153
|
};
|
|
153
154
|
if (!ctx.dryRun) {
|
|
154
|
-
const failure = yield* codeScanning.configure(desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(
|
|
155
|
+
const failure = yield* codeScanning.configure(desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
|
|
155
156
|
if (Option.isSome(failure)) return {
|
|
156
157
|
changes: [],
|
|
157
158
|
errors: [...errors, {
|
|
@@ -3,6 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
|
3
3
|
import { fingerprint } from "../../lib/fingerprint.js";
|
|
4
4
|
import { decide, isDrift, needsApply } from "../decide.js";
|
|
5
5
|
import { Effect, Option } from "effect";
|
|
6
|
+
import { Fmt } from "@effected/cli";
|
|
6
7
|
import { DeploymentEnvironment } from "@effected/github";
|
|
7
8
|
|
|
8
9
|
// src/sync/phases/environments.ts
|
|
@@ -68,7 +69,7 @@ const environmentsPhase = Effect.gen(function* () {
|
|
|
68
69
|
if (isDrift(decision)) yield* logger.driftDetected(KIND, name, decision);
|
|
69
70
|
if (!needsApply(decision)) continue;
|
|
70
71
|
if (!ctx.dryRun) {
|
|
71
|
-
const failure = yield* environments.upsert(name, desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(
|
|
72
|
+
const failure = yield* environments.upsert(name, desired).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
|
|
72
73
|
if (Option.isSome(failure)) {
|
|
73
74
|
errors.push({
|
|
74
75
|
context: `environment ${name}`,
|
package/sync/phases/resource.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { Effect, Redacted } from "effect";
|
|
2
|
-
import {
|
|
3
|
-
import { isAbsolute, resolve } from "node:path";
|
|
2
|
+
import { Fmt } from "@effected/cli";
|
|
4
3
|
|
|
5
4
|
// src/sync/phases/resource.ts
|
|
6
5
|
/**
|
|
@@ -25,7 +24,7 @@ import { isAbsolute, resolve } from "node:path";
|
|
|
25
24
|
* this runs on the secret-loading path, and the error is the most likely thing
|
|
26
25
|
* to reach a log.
|
|
27
26
|
*/
|
|
28
|
-
const resolveOne = (group, label, ctx) => {
|
|
27
|
+
const resolveOne = (io, group, label, ctx) => Effect.gen(function* () {
|
|
29
28
|
const entries = /* @__PURE__ */ new Map();
|
|
30
29
|
const errors = [];
|
|
31
30
|
if ("value" in group) {
|
|
@@ -53,21 +52,19 @@ const resolveOne = (group, label, ctx) => {
|
|
|
53
52
|
};
|
|
54
53
|
}
|
|
55
54
|
for (const [name, filePath] of Object.entries(group.file)) {
|
|
56
|
-
const fullPath = isAbsolute(filePath) ? filePath : resolve(ctx.configDir, filePath);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
});
|
|
64
|
-
}
|
|
55
|
+
const fullPath = io.path.isAbsolute(filePath) ? filePath : io.path.resolve(ctx.configDir, filePath);
|
|
56
|
+
const text = yield* io.fs.readFileString(fullPath).pipe(Effect.result);
|
|
57
|
+
if (text._tag === "Success") entries.set(name, Redacted.make(text.success.trim(), { label: name }));
|
|
58
|
+
else errors.push({
|
|
59
|
+
context: `${label}.${name}`,
|
|
60
|
+
message: text.failure.message
|
|
61
|
+
});
|
|
65
62
|
}
|
|
66
63
|
return {
|
|
67
64
|
entries,
|
|
68
65
|
errors
|
|
69
66
|
};
|
|
70
|
-
};
|
|
67
|
+
});
|
|
71
68
|
/**
|
|
72
69
|
* Resolve every group a scope references, later groups winning.
|
|
73
70
|
*
|
|
@@ -79,13 +76,13 @@ const resolveOne = (group, label, ctx) => {
|
|
|
79
76
|
*
|
|
80
77
|
* @public
|
|
81
78
|
*/
|
|
82
|
-
const resolveGroups = (refs, definitions, ctx) => {
|
|
79
|
+
const resolveGroups = (io, refs, definitions, ctx) => Effect.gen(function* () {
|
|
83
80
|
const entries = /* @__PURE__ */ new Map();
|
|
84
81
|
const errors = [];
|
|
85
82
|
for (const ref of refs) {
|
|
86
83
|
const group = definitions[ref];
|
|
87
84
|
if (group === void 0) continue;
|
|
88
|
-
const outcome = resolveOne(group, ref, ctx);
|
|
85
|
+
const outcome = yield* resolveOne(io, group, ref, ctx);
|
|
89
86
|
for (const [name, value] of outcome.entries) entries.set(name, value);
|
|
90
87
|
errors.push(...outcome.errors);
|
|
91
88
|
}
|
|
@@ -93,7 +90,7 @@ const resolveGroups = (refs, definitions, ctx) => {
|
|
|
93
90
|
entries,
|
|
94
91
|
errors
|
|
95
92
|
};
|
|
96
|
-
};
|
|
93
|
+
});
|
|
97
94
|
/**
|
|
98
95
|
* The names a set of groups declares, without resolving any value.
|
|
99
96
|
*
|
|
@@ -133,7 +130,7 @@ const declaredNames = (refs, definitions) => {
|
|
|
133
130
|
*
|
|
134
131
|
* @public
|
|
135
132
|
*/
|
|
136
|
-
const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed:
|
|
133
|
+
const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.catch((error) => Effect.succeed({ failed: Fmt.describe(error) })));
|
|
137
134
|
/**
|
|
138
135
|
* Wrap an operation so a failure becomes a reportable error rather than a
|
|
139
136
|
* failed run.
|
|
@@ -142,7 +139,7 @@ const read = (effect) => effect.pipe(Effect.map((value) => ({ value })), Effect.
|
|
|
142
139
|
*/
|
|
143
140
|
const capture = (context, effect) => effect.pipe(Effect.as(void 0), Effect.catch((error) => Effect.succeed({
|
|
144
141
|
context,
|
|
145
|
-
message:
|
|
142
|
+
message: Fmt.describe(error)
|
|
146
143
|
})));
|
|
147
144
|
|
|
148
145
|
export { capture, declaredNames, read, resolveGroups };
|
package/sync/phases/secrets.js
CHANGED
|
@@ -3,7 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
|
3
3
|
import { capture, resolveGroups } from "./resource.js";
|
|
4
4
|
import { fingerprint } from "../../lib/fingerprint.js";
|
|
5
5
|
import { decide, isDrift, needsApply } from "../decide.js";
|
|
6
|
-
import { Effect, Option, Redacted } from "effect";
|
|
6
|
+
import { Effect, FileSystem, Option, Path, Redacted } from "effect";
|
|
7
7
|
import { RepositorySecret } from "@effected/github";
|
|
8
8
|
|
|
9
9
|
// src/sync/phases/secrets.ts
|
|
@@ -53,6 +53,10 @@ const secretsPhase = Effect.gen(function* () {
|
|
|
53
53
|
const secrets = yield* RepositorySecret;
|
|
54
54
|
const applied = yield* AppliedState;
|
|
55
55
|
const logger = yield* SyncLogger;
|
|
56
|
+
const io = {
|
|
57
|
+
fs: yield* FileSystem.FileSystem,
|
|
58
|
+
path: yield* Path.Path
|
|
59
|
+
};
|
|
56
60
|
const run = (ctx) => Effect.gen(function* () {
|
|
57
61
|
const scopes = ctx.config.groups[ctx.group]?.secrets;
|
|
58
62
|
if (scopes === void 0) return {
|
|
@@ -109,12 +113,12 @@ const secretsPhase = Effect.gen(function* () {
|
|
|
109
113
|
}
|
|
110
114
|
});
|
|
111
115
|
for (const scope of SCOPES) {
|
|
112
|
-
const resolved = resolveGroups(scopes[scope] ?? [], ctx.config.secrets, ctx);
|
|
116
|
+
const resolved = yield* resolveGroups(io, scopes[scope] ?? [], ctx.config.secrets, ctx);
|
|
113
117
|
errors.push(...resolved.errors);
|
|
114
118
|
yield* syncStore(resolved.entries, scope, void 0);
|
|
115
119
|
}
|
|
116
120
|
for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
|
|
117
|
-
const resolved = resolveGroups(refs, ctx.config.secrets, ctx);
|
|
121
|
+
const resolved = yield* resolveGroups(io, refs, ctx.config.secrets, ctx);
|
|
118
122
|
errors.push(...resolved.errors);
|
|
119
123
|
yield* syncStore(resolved.entries, "actions", environment);
|
|
120
124
|
}
|
package/sync/phases/security.js
CHANGED
|
@@ -3,6 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
|
3
3
|
import { fingerprint } from "../../lib/fingerprint.js";
|
|
4
4
|
import { decide, isDrift, needsApply } from "../decide.js";
|
|
5
5
|
import { Effect, Option } from "effect";
|
|
6
|
+
import { Fmt } from "@effected/cli";
|
|
6
7
|
import { RepositorySecurity } from "@effected/github";
|
|
7
8
|
|
|
8
9
|
// src/sync/phases/security.ts
|
|
@@ -62,7 +63,7 @@ const securityPhase = Effect.gen(function* () {
|
|
|
62
63
|
return (toggle === "vulnerability_alerts" ? security.vulnerabilityAlerts() : toggle === "automated_security_fixes" ? security.automatedSecurityFixes() : security.privateVulnerabilityReporting()).pipe(Effect.map(Option.some), Effect.orElseSucceed(() => Option.none()));
|
|
63
64
|
};
|
|
64
65
|
const write = (toggle, value) => {
|
|
65
|
-
return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(
|
|
66
|
+
return (toggle === "vulnerability_alerts" ? security.setVulnerabilityAlerts(value) : toggle === "automated_security_fixes" ? security.setAutomatedSecurityFixes(value) : security.setPrivateVulnerabilityReporting(value)).pipe(Effect.as(Option.none()), Effect.catch((error) => Effect.succeed(Option.some(Fmt.describe(error)))));
|
|
66
67
|
};
|
|
67
68
|
const run = (ctx) => Effect.gen(function* () {
|
|
68
69
|
const merged = mergeSecurity(ctx);
|
package/sync/phases/settings.js
CHANGED
|
@@ -237,7 +237,7 @@ const settingsPhase = Effect.gen(function* () {
|
|
|
237
237
|
};
|
|
238
238
|
let sent;
|
|
239
239
|
if (!ctx.dryRun) {
|
|
240
|
-
const outcome = yield* settingsClient.applySettings(desired
|
|
240
|
+
const outcome = yield* read(settingsClient.applySettings(desired, { unknownKeys: "send" }));
|
|
241
241
|
if ("failed" in outcome) return {
|
|
242
242
|
changes: [],
|
|
243
243
|
errors: [...errors, {
|
|
@@ -245,7 +245,8 @@ const settingsPhase = Effect.gen(function* () {
|
|
|
245
245
|
message: outcome.failed
|
|
246
246
|
}]
|
|
247
247
|
};
|
|
248
|
-
|
|
248
|
+
for (const key of outcome.value.unverified) yield* logger.syncOperation("apply", "setting", key, "(unconfirmed: GitHub does not describe this field)");
|
|
249
|
+
sent = [...outcome.value.rest, ...outcome.value.graphql];
|
|
249
250
|
yield* applied.record({
|
|
250
251
|
repo: ctx.slug,
|
|
251
252
|
kind: KIND,
|
package/sync/phases/variables.js
CHANGED
|
@@ -3,7 +3,7 @@ import { AppliedState, lookup } from "../../store/AppliedState.js";
|
|
|
3
3
|
import { capture, resolveGroups } from "./resource.js";
|
|
4
4
|
import { fingerprint } from "../../lib/fingerprint.js";
|
|
5
5
|
import { decide, isDrift, needsApply } from "../decide.js";
|
|
6
|
-
import { Effect, Option, Redacted } from "effect";
|
|
6
|
+
import { Effect, FileSystem, Option, Path, Redacted } from "effect";
|
|
7
7
|
import { RepositoryVariable } from "@effected/github";
|
|
8
8
|
|
|
9
9
|
// src/sync/phases/variables.ts
|
|
@@ -46,6 +46,10 @@ const variablesPhase = Effect.gen(function* () {
|
|
|
46
46
|
const variables = yield* RepositoryVariable;
|
|
47
47
|
const applied = yield* AppliedState;
|
|
48
48
|
const logger = yield* SyncLogger;
|
|
49
|
+
const io = {
|
|
50
|
+
fs: yield* FileSystem.FileSystem,
|
|
51
|
+
path: yield* Path.Path
|
|
52
|
+
};
|
|
49
53
|
const run = (ctx) => Effect.gen(function* () {
|
|
50
54
|
const scopes = ctx.config.groups[ctx.group]?.variables;
|
|
51
55
|
if (scopes === void 0) return {
|
|
@@ -54,12 +58,12 @@ const variablesPhase = Effect.gen(function* () {
|
|
|
54
58
|
};
|
|
55
59
|
const changes = [];
|
|
56
60
|
const errors = [];
|
|
57
|
-
const actions = resolveGroups(scopes.actions ?? [], ctx.config.variables, ctx);
|
|
61
|
+
const actions = yield* resolveGroups(io, scopes.actions ?? [], ctx.config.variables, ctx);
|
|
58
62
|
errors.push(...actions.errors);
|
|
59
63
|
/** Every environment's resolved entries, by environment name. */
|
|
60
64
|
const byEnvironment = /* @__PURE__ */ new Map();
|
|
61
65
|
for (const [environment, refs] of Object.entries(scopes.environments ?? {})) {
|
|
62
|
-
const resolved = resolveGroups(refs, ctx.config.variables, ctx);
|
|
66
|
+
const resolved = yield* resolveGroups(io, refs, ctx.config.variables, ctx);
|
|
63
67
|
errors.push(...resolved.errors);
|
|
64
68
|
byEnvironment.set(environment, resolved.entries);
|
|
65
69
|
}
|