reposets 2.0.3 → 3.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 +16 -8
- package/bin/reposets.js +103 -45
- package/cli/commands/credentials.js +214 -34
- package/cli/commands/doctor.js +64 -54
- package/cli/commands/drift.js +4 -0
- package/cli/commands/history.js +222 -68
- package/cli/commands/init.js +65 -18
- package/cli/commands/list.js +23 -10
- package/cli/commands/nuke.js +279 -43
- package/cli/commands/sync.js +175 -81
- package/cli/commands/validate.js +54 -31
- package/cli/views/sync-progress-model.js +131 -0
- package/cli/views/sync-progress.js +68 -0
- package/index.d.ts +35 -1
- package/index.js +2 -2
- package/package.json +16 -10
- package/schemas/hosted.js +79 -0
- package/services/ConfigFiles.js +53 -13
- package/services/SyncLogger.js +109 -23
- package/store/SyncJournal.js +1 -0
- package/store/files.js +25 -0
package/cli/commands/doctor.js
CHANGED
|
@@ -2,13 +2,14 @@ import { SettingsGroupSchema } from "../../schemas/config.js";
|
|
|
2
2
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
3
3
|
import { CONFIG_FILENAME, CREDENTIALS_FILENAME, ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
4
4
|
import { Invocation } from "../../services/Invocation.js";
|
|
5
|
+
import { STATE_DB_FILENAME } from "../../store/files.js";
|
|
5
6
|
import { OP_SERVICE_ACCOUNT_TOKEN, OnePasswordClientLive } from "../../services/OnePasswordClient.js";
|
|
6
7
|
import { CredentialResolver, CredentialResolverLive } from "../../services/CredentialResolver.js";
|
|
7
8
|
import { ConfigFlag } from "../flags.js";
|
|
8
9
|
import { Config, Console, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
|
|
9
|
-
import { SchemaIssueRenderer } from "@effected/cli";
|
|
10
|
-
import { Command } from "effect/cli";
|
|
10
|
+
import { CliMessage, Doc, SchemaIssueRenderer, Status } from "@effected/cli";
|
|
11
11
|
import { AppDirs } from "@effected/xdg";
|
|
12
|
+
import { Command } from "effect/cli";
|
|
12
13
|
import { GitHubClient } from "@effected/github";
|
|
13
14
|
import { Toml } from "@effected/toml";
|
|
14
15
|
|
|
@@ -234,10 +235,15 @@ const isRecord = (value) => typeof value === "object" && value !== null && !Arra
|
|
|
234
235
|
* never resolves a reference, so there is no value in scope to leak.
|
|
235
236
|
*
|
|
236
237
|
* The report — failing sections included — is the command's output and goes to
|
|
237
|
-
* stdout
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
238
|
+
* stdout, so `reposets doctor > report.txt` captures all of it. It is a `Doc`
|
|
239
|
+
* of sections (files, schema, config keys, credentials, the live token check,
|
|
240
|
+
* required permissions) printed with `Doc.print`: plain text for an agent,
|
|
241
|
+
* painted with status glyphs for a person, folded groups under GitHub Actions.
|
|
242
|
+
* The file-only sections are printed before the live check, so a slow GitHub
|
|
243
|
+
* never holds them back. Only the three early stops (no config, unreadable,
|
|
244
|
+
* unparseable) go to stderr, as `CliMessage.failure` lines no log level can
|
|
245
|
+
* hide. `doctor` always exits 0: it describes, it does not gate — `validate`
|
|
246
|
+
* is the gate.
|
|
241
247
|
*
|
|
242
248
|
* @public
|
|
243
249
|
*/
|
|
@@ -293,39 +299,47 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
293
299
|
const validationIssue = discovered._tag === "Failure" ? discovered.failure.issue : void 0;
|
|
294
300
|
const configPath = sources[0]?.path ?? (yield* locateConfig(fs, path, appDirs, configFlag, invocation.cwd));
|
|
295
301
|
if (configPath === void 0) {
|
|
296
|
-
yield*
|
|
302
|
+
yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
|
|
297
303
|
return;
|
|
298
304
|
}
|
|
299
|
-
yield*
|
|
305
|
+
const credentialDiscovery = yield* credentialsFile.discover.pipe(Effect.result);
|
|
306
|
+
const credentialSources = credentialDiscovery._tag === "Success" ? credentialDiscovery.success : [];
|
|
307
|
+
const credentialsLine = credentialDiscovery._tag === "Failure" ? Doc.text("(failed to load — the Credentials section names the file)", "failure") : credentialSources.length === 0 ? [Doc.file(path.join(appDirs.dirs.config, CREDENTIALS_FILENAME)), Doc.text(" (not created yet)", "muted")] : Doc.file(credentialSources[0]?.path ?? "");
|
|
308
|
+
const files = Doc.section("Files", [Doc.lines([
|
|
309
|
+
["Version: ", invocation.version],
|
|
310
|
+
["Config: ", Doc.file(configPath)],
|
|
311
|
+
["Credentials file: ", ...Array.isArray(credentialsLine) ? credentialsLine : [credentialsLine]],
|
|
312
|
+
["State database: ", Doc.file(path.join(appDirs.dirs.state, STATE_DB_FILENAME))]
|
|
313
|
+
])]);
|
|
300
314
|
const text = yield* fs.readFileString(configPath).pipe(Effect.option);
|
|
301
315
|
if (text._tag === "None") {
|
|
302
|
-
yield*
|
|
316
|
+
yield* Doc.print([files]);
|
|
317
|
+
yield* CliMessage.failure(`Could not read ${configPath}`);
|
|
303
318
|
return;
|
|
304
319
|
}
|
|
305
320
|
const parsed = Toml.parseResult(text.value);
|
|
306
321
|
if (Result.isFailure(parsed)) {
|
|
307
|
-
yield*
|
|
322
|
+
yield* Doc.print([files]);
|
|
323
|
+
yield* CliMessage.failure(`TOML parse error: ${String(parsed.failure)}`);
|
|
308
324
|
return;
|
|
309
325
|
}
|
|
310
326
|
const raw = isRecord(parsed.success) ? parsed.success : {};
|
|
311
|
-
|
|
312
|
-
const checkKeys = (target, known, where) =>
|
|
327
|
+
const warnings = [];
|
|
328
|
+
const checkKeys = (target, known, where) => {
|
|
313
329
|
if (!isRecord(target)) return;
|
|
314
330
|
for (const key of Object.keys(target)) {
|
|
315
331
|
if (known.has(key)) continue;
|
|
316
332
|
const removedFrom = REMOVED_KEYS[where === "" ? key : `${key} (in groups)`] ?? REMOVED_KEYS[key];
|
|
317
333
|
if (removedFrom !== void 0) {
|
|
318
|
-
|
|
319
|
-
warnings += 1;
|
|
334
|
+
warnings.push(`'${key}'${where} ${removedFrom}`);
|
|
320
335
|
continue;
|
|
321
336
|
}
|
|
322
337
|
const suggestion = findClosestMatch(key, known);
|
|
323
338
|
const hint = suggestion === void 0 ? "" : ` — did you mean '${suggestion}'?`;
|
|
324
|
-
|
|
325
|
-
warnings += 1;
|
|
339
|
+
warnings.push(`unknown key '${key}'${where}${hint}`);
|
|
326
340
|
}
|
|
327
|
-
}
|
|
328
|
-
|
|
341
|
+
};
|
|
342
|
+
checkKeys(raw, KNOWN_CONFIG_KEYS, "");
|
|
329
343
|
const settingsSection = isRecord(raw.settings) ? raw.settings : {};
|
|
330
344
|
for (const [groupName, body] of Object.entries(settingsSection)) {
|
|
331
345
|
if (!isRecord(body)) continue;
|
|
@@ -333,37 +347,33 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
333
347
|
if (KNOWN_SETTINGS_FIELDS.has(field)) continue;
|
|
334
348
|
const suggestion = findClosestMatch(field, KNOWN_SETTINGS_FIELDS);
|
|
335
349
|
const hint = suggestion === void 0 ? " — it will be sent to GitHub as-is, which ignores fields it does not recognise" : ` — did you mean '${suggestion}'?`;
|
|
336
|
-
|
|
337
|
-
warnings += 1;
|
|
350
|
+
warnings.push(`unrecognised setting '${field}' in settings.${groupName}${hint}`);
|
|
338
351
|
}
|
|
339
352
|
}
|
|
340
353
|
const groups = raw.groups;
|
|
341
354
|
if (isRecord(groups)) for (const [groupName, group] of Object.entries(groups)) {
|
|
342
|
-
|
|
355
|
+
checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
|
|
343
356
|
if (!isRecord(group)) continue;
|
|
344
357
|
const cleanup = group.cleanup;
|
|
345
358
|
if (!isRecord(cleanup)) continue;
|
|
346
359
|
const prefix = `groups.${groupName}.cleanup`;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
}
|
|
351
|
-
if (schemaValid) yield* Console.log("Schema validation: passed");
|
|
352
|
-
else {
|
|
353
|
-
yield* Console.log("Schema validation: FAILED — these values are rejected, not ignored");
|
|
354
|
-
for (const line of SchemaIssueRenderer.render(validationIssue)) yield* Console.log(` ${line}`);
|
|
360
|
+
checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
|
|
361
|
+
checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
|
|
362
|
+
checkKeys(cleanup.variables, KNOWN_CLEANUP_VARIABLES_KEYS, ` in ${prefix}.variables`);
|
|
355
363
|
}
|
|
356
|
-
|
|
364
|
+
const issueLines = schemaValid ? [] : SchemaIssueRenderer.render(validationIssue);
|
|
365
|
+
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} warning(s) found.`)]);
|
|
357
367
|
const loaded = yield* credentialsFile.loadOrDefault({ profiles: {} }).pipe(Effect.result);
|
|
358
368
|
const credentials = loaded._tag === "Success" ? loaded.success : { profiles: {} };
|
|
359
369
|
const profileNames = Object.keys(credentials.profiles);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
} else if (profileNames.length === 0) yield* Console.log("Credentials: no profiles configured");
|
|
370
|
+
const credentialBlocks = [];
|
|
371
|
+
if (loaded._tag === "Failure") credentialBlocks.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Credentials: FAILED to load — ${String(loaded.failure.message ?? 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
|
+
else if (profileNames.length === 0) credentialBlocks.push(Doc.paragraph("Credentials: no profiles configured"));
|
|
364
373
|
else {
|
|
365
|
-
|
|
374
|
+
credentialBlocks.push(Doc.paragraph(`Credentials: ${profileNames.length} profile(s)`));
|
|
366
375
|
let usesOnePassword = false;
|
|
376
|
+
const profileLines = [];
|
|
367
377
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
368
378
|
const token = "op" in profile.github_token ? `op ${profile.github_token.op}` : `env ${profile.github_token.env}`;
|
|
369
379
|
if ("op" in profile.github_token) usesOnePassword = true;
|
|
@@ -380,45 +390,45 @@ const doctorHandler = (configFlag) => Effect.gen(function* () {
|
|
|
380
390
|
}
|
|
381
391
|
}
|
|
382
392
|
const resolveSummary = sections.length === 0 ? "" : `, resolve ${sections.join(" ")}`;
|
|
383
|
-
|
|
393
|
+
profileLines.push(Doc.paragraph(`[${name}] github_token: ${token}${resolveSummary}`));
|
|
384
394
|
}
|
|
395
|
+
credentialBlocks.push(Doc.list(profileLines, { compact: true }));
|
|
385
396
|
const hasServiceAccount = Option.isSome(yield* Config.option(Config.String(OP_SERVICE_ACCOUNT_TOKEN)).pipe(Effect.orElseSucceed(() => Option.none())));
|
|
386
|
-
if (usesOnePassword)
|
|
397
|
+
if (usesOnePassword) credentialBlocks.push(hasServiceAccount ? Doc.paragraph(Doc.status(Status.core, "success"), ` ${OP_SERVICE_ACCOUNT_TOKEN}: set`) : Doc.paragraph(Doc.status(Status.core, "warning"), ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`));
|
|
387
398
|
}
|
|
388
|
-
yield*
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
yield* Console.log("");
|
|
399
|
+
yield* Doc.print([Doc.section(void 0, [
|
|
400
|
+
files,
|
|
401
|
+
schema,
|
|
402
|
+
keys,
|
|
403
|
+
Doc.section("Credentials", credentialBlocks)
|
|
404
|
+
])]);
|
|
405
|
+
const tokenLines = [];
|
|
396
406
|
for (const [name, profile] of Object.entries(credentials.profiles)) {
|
|
397
407
|
const resolverLayer = Layer.provide(CredentialResolverLive, OnePasswordClientLive);
|
|
398
408
|
const resolved = yield* Effect.gen(function* () {
|
|
399
409
|
return yield* (yield* CredentialResolver).resolveGitHubToken(profile);
|
|
400
410
|
}).pipe(Effect.provide(resolverLayer), Effect.result);
|
|
401
411
|
if (resolved._tag === "Failure") {
|
|
402
|
-
|
|
412
|
+
tokenLines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: could not resolve — ${String(resolved.failure)}`));
|
|
403
413
|
continue;
|
|
404
414
|
}
|
|
405
415
|
const identity = yield* Effect.gen(function* () {
|
|
406
416
|
return yield* (yield* GitHubClient).request("GET /user", {});
|
|
407
417
|
}).pipe(Effect.provide(GitHubClient.layerFromToken({ token: resolved.success })), Effect.result);
|
|
408
418
|
if (identity._tag !== "Success") {
|
|
409
|
-
|
|
419
|
+
tokenLines.push(Doc.paragraph(Doc.status(Status.core, "failure"), ` Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`));
|
|
410
420
|
continue;
|
|
411
421
|
}
|
|
412
422
|
const line = describeIdentity(name, profile, identity.success.login);
|
|
413
|
-
|
|
423
|
+
tokenLines.push(Doc.paragraph(Doc.status(Status.core, line.ok ? "success" : "failure"), " ", line.text));
|
|
414
424
|
}
|
|
425
|
+
const permissions = Doc.section("Required fine-grained token permissions", [
|
|
426
|
+
Doc.list(REQUIRED_PERMISSIONS.map(([scope, permission, why]) => Doc.paragraph(`${scope} > ${permission} — ${why}`)), { compact: true }),
|
|
427
|
+
Doc.paragraph(Doc.text("(Metadata: Read is mandatory and granted automatically)", "muted")),
|
|
428
|
+
Doc.callout("note", [Doc.paragraph("These are NOT verified — GitHub does not expose a fine-grained token's own scopes.")])
|
|
429
|
+
]);
|
|
415
430
|
yield* Console.log("");
|
|
416
|
-
yield*
|
|
417
|
-
for (const [scope, permission, why] of REQUIRED_PERMISSIONS) yield* Console.log(` ${scope} > ${permission} — ${why}`);
|
|
418
|
-
yield* Console.log(" (Metadata: Read is mandatory and granted automatically)");
|
|
419
|
-
yield* Console.log(" These are NOT verified — GitHub does not expose a fine-grained token's own scopes.");
|
|
420
|
-
yield* Console.log("");
|
|
421
|
-
yield* Console.log(warnings === 0 ? "No unknown keys detected." : `${warnings} warning(s) found.`);
|
|
431
|
+
yield* Doc.print([Doc.section(void 0, tokenLines.length === 0 ? [permissions] : [Doc.section("Token check", tokenLines), permissions])]);
|
|
422
432
|
});
|
|
423
433
|
/**
|
|
424
434
|
* `reposets doctor`.
|
package/cli/commands/drift.js
CHANGED
|
@@ -27,6 +27,10 @@ import { Command, Flag } from "effect/cli";
|
|
|
27
27
|
*
|
|
28
28
|
* Exits non-zero when drift is found, so it works as a CI gate without a flag.
|
|
29
29
|
*
|
|
30
|
+
* Being the same handler, it shows progress the same way: a live footer for a
|
|
31
|
+
* person at a terminal, labelled as a dry run, and a static summary line for
|
|
32
|
+
* a pipe, an agent or CI.
|
|
33
|
+
*
|
|
30
34
|
* @public
|
|
31
35
|
*/
|
|
32
36
|
const driftCommand = Command.make("drift", {
|
package/cli/commands/history.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SyncJournal } from "../../store/SyncJournal.js";
|
|
2
|
-
import {
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
import { CliInteractive, CliMessage, Doc, Fmt, Status } from "@effected/cli";
|
|
3
4
|
import { CliError, Command, Flag } from "effect/cli";
|
|
5
|
+
import { CliUi, Select } from "@effected/cli/ui";
|
|
4
6
|
|
|
5
7
|
//#region src/cli/commands/history.ts
|
|
6
8
|
const limitFlag = Flag.Int("limit").pipe(Flag.withDefault(20), Flag.withDescription("How many runs to show, newest first"));
|
|
@@ -35,38 +37,103 @@ const formatOutcome = (summary) => {
|
|
|
35
37
|
if (summary.outcome !== null) return summary.outcome;
|
|
36
38
|
return summary.finishedAt === null ? "interrupted" : "unknown";
|
|
37
39
|
};
|
|
38
|
-
/** Pad, but never truncate — a long group name is worth more than the alignment. */
|
|
39
|
-
const pad = (value, width) => value.padEnd(width);
|
|
40
40
|
/**
|
|
41
|
-
*
|
|
41
|
+
* The status glyph an outcome is drawn with.
|
|
42
42
|
*
|
|
43
43
|
* @remarks
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*/
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
44
|
+
* `interrupted` is a failure, not a skip: the process died mid-run, which is
|
|
45
|
+
* more alarming than a recorded failure, and must not read as benign. Only an
|
|
46
|
+
* outcome this code does not recognise is drawn as `skip`.
|
|
47
|
+
*/
|
|
48
|
+
const outcomeStatus = (outcome) => outcome === "success" ? "success" : outcome === "partial" ? "warning" : outcome === "failed" || outcome === "interrupted" ? "failure" : "skip";
|
|
49
|
+
const formatMode = (summary) => summary.dryRun === 1 ? "dry-run" : "applied";
|
|
50
|
+
/** The fewest characters a displayed run id is cut to. */
|
|
51
|
+
const MIN_ID_LENGTH = 8;
|
|
52
|
+
/** How many leading characters two strings share. */
|
|
53
|
+
const commonPrefixLength = (a, b) => {
|
|
54
|
+
let i = 0;
|
|
55
|
+
while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
|
|
56
|
+
return i;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* The handle `history show` takes: each run id cut to the shortest prefix no
|
|
60
|
+
* other run in the journal shares, and never shorter than eight characters.
|
|
61
|
+
*
|
|
62
|
+
* @remarks
|
|
63
|
+
* Without a printed id the two commands do not compose: the table was the only
|
|
64
|
+
* place a run id could come from, so there was no way to name a run.
|
|
65
|
+
*
|
|
66
|
+
* A fixed eight characters was the first answer and it was wrong. Run ids are
|
|
67
|
+
* UUIDv7, whose leading 48 bits are a millisecond timestamp, so the first eight
|
|
68
|
+
* hex digits only change about every 65 seconds — two runs a minute apart
|
|
69
|
+
* printed the same id, and `show --run` with it was refused as ambiguous. The
|
|
70
|
+
* advertised handle did not work for exactly the runs someone just made.
|
|
71
|
+
*
|
|
72
|
+
* So this is git's abbreviated-hash rule: eight characters while that is
|
|
73
|
+
* enough, longer where it is not. In sorted order the id sharing the longest
|
|
74
|
+
* prefix with any id is one of its two neighbours, so one sort and one pass
|
|
75
|
+
* give every id its length. `ids` must be **every** run in the journal, not the
|
|
76
|
+
* page being printed: a prefix unique within twenty rows can still match a
|
|
77
|
+
* twenty-first run, and `show` matches against the journal, not the page.
|
|
78
|
+
* A hyphen never ends an abbreviation — UUID hyphens sit at fixed positions,
|
|
79
|
+
* so they are always shared and the first differing character is a digit.
|
|
80
|
+
*/
|
|
81
|
+
const abbreviator = (ids) => {
|
|
82
|
+
const sorted = [...new Set(ids)].sort();
|
|
83
|
+
const lengths = /* @__PURE__ */ new Map();
|
|
84
|
+
sorted.forEach((id, i) => {
|
|
85
|
+
const before = i > 0 ? commonPrefixLength(sorted[i - 1], id) : 0;
|
|
86
|
+
const after = i < sorted.length - 1 ? commonPrefixLength(id, sorted[i + 1]) : 0;
|
|
87
|
+
lengths.set(id, Math.max(MIN_ID_LENGTH, Math.max(before, after) + 1));
|
|
88
|
+
});
|
|
89
|
+
return (id) => id.slice(0, lengths.get(id) ?? MIN_ID_LENGTH);
|
|
68
90
|
};
|
|
69
91
|
/**
|
|
92
|
+
* The {@link abbreviator} for this journal.
|
|
93
|
+
*
|
|
94
|
+
* @remarks
|
|
95
|
+
* The runs about to be printed are folded in alongside the journal's ids, so a
|
|
96
|
+
* run that lands between the two reads still gets a length rather than the
|
|
97
|
+
* eight-character fallback.
|
|
98
|
+
*/
|
|
99
|
+
const journalAbbreviator = (journal, shown) => journal.runIds().pipe(Effect.map((ids) => abbreviator([...ids, ...shown.map((run) => run.id)])));
|
|
100
|
+
/**
|
|
101
|
+
* The run table, as a document.
|
|
102
|
+
*
|
|
103
|
+
* @remarks
|
|
104
|
+
* A `Doc.table` sizes each column to its widest cell, which matters because
|
|
105
|
+
* group and repository names are user-supplied and unbounded; it pads but never
|
|
106
|
+
* truncates, so a long group name is worth more than the alignment. Plain for
|
|
107
|
+
* an agent, painted for a person.
|
|
108
|
+
*/
|
|
109
|
+
const runTable = (summaries, abbreviate) => Doc.table([
|
|
110
|
+
{ header: "RUN" },
|
|
111
|
+
{ header: "WHEN (UTC)" },
|
|
112
|
+
{ header: "OUTCOME" },
|
|
113
|
+
{ header: "MODE" },
|
|
114
|
+
{ header: "GROUP" },
|
|
115
|
+
{
|
|
116
|
+
header: "CHANGES",
|
|
117
|
+
align: "right"
|
|
118
|
+
},
|
|
119
|
+
{ header: "TOOK" }
|
|
120
|
+
], summaries.map((summary) => {
|
|
121
|
+
const outcome = formatOutcome(summary);
|
|
122
|
+
return [
|
|
123
|
+
abbreviate(summary.id),
|
|
124
|
+
formatWhen(summary.startedAt),
|
|
125
|
+
[
|
|
126
|
+
Doc.status(Status.core, outcomeStatus(outcome)),
|
|
127
|
+
" ",
|
|
128
|
+
outcome
|
|
129
|
+
],
|
|
130
|
+
formatMode(summary),
|
|
131
|
+
summary.group ?? "(all)",
|
|
132
|
+
String(summary.changes),
|
|
133
|
+
formatDuration(summary)
|
|
134
|
+
];
|
|
135
|
+
}));
|
|
136
|
+
/**
|
|
70
137
|
* `reposets history` — what previous runs did.
|
|
71
138
|
*
|
|
72
139
|
* @remarks
|
|
@@ -82,15 +149,17 @@ const renderRows = (summaries) => {
|
|
|
82
149
|
* @public
|
|
83
150
|
*/
|
|
84
151
|
const historyHandler = (input) => Effect.gen(function* () {
|
|
85
|
-
const
|
|
152
|
+
const journal = yield* SyncJournal;
|
|
153
|
+
const summaries = yield* journal.history({
|
|
86
154
|
limit: input.limit,
|
|
87
155
|
...input.repo === void 0 ? {} : { repo: input.repo }
|
|
88
156
|
});
|
|
89
157
|
if (summaries.length === 0) {
|
|
90
|
-
yield*
|
|
158
|
+
yield* CliMessage.info(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
|
|
91
159
|
return;
|
|
92
160
|
}
|
|
93
|
-
|
|
161
|
+
const abbreviate = yield* journalAbbreviator(journal, summaries);
|
|
162
|
+
yield* Doc.print([runTable(summaries, abbreviate)]);
|
|
94
163
|
if (summaries.length === input.limit) yield* Effect.log(`Showing the most recent ${input.limit}. Pass --limit for more.`);
|
|
95
164
|
});
|
|
96
165
|
/**
|
|
@@ -102,53 +171,131 @@ const historyHandler = (input) => Effect.gen(function* () {
|
|
|
102
171
|
* `would `. Only the three verbs that describe a write are rewritten.
|
|
103
172
|
*/
|
|
104
173
|
const wouldForm = (action) => action === "created" ? "create" : action === "updated" ? "update" : action === "deleted" ? "delete" : action;
|
|
174
|
+
/** The token an action is painted with: what it did to the resource, at a glance. */
|
|
175
|
+
const actionToken = (action, dryRun) => dryRun ? "info" : action === "created" ? "success" : action === "deleted" ? "failure" : action === "drift-overwritten" ? "warning" : action === "unchanged" ? "muted" : "info";
|
|
176
|
+
/**
|
|
177
|
+
* The refusal for a missing `--run` where nobody can be asked.
|
|
178
|
+
*
|
|
179
|
+
* @remarks
|
|
180
|
+
* A usage error (exit 64) naming the flag, and the command that lists the ids
|
|
181
|
+
* to pass to it — the same answer a person gets from the picker, spelled out
|
|
182
|
+
* for a script.
|
|
183
|
+
*/
|
|
184
|
+
const runRequired = () => new CliError.UserError({ cause: "Pass --run <id> (a run id or a unique prefix); `reposets history` lists them." });
|
|
185
|
+
/**
|
|
186
|
+
* How many recent runs the picker offers.
|
|
187
|
+
*
|
|
188
|
+
* @remarks
|
|
189
|
+
* The question it answers is "what did that run just now do", so the newest
|
|
190
|
+
* runs are the ones worth scrolling; an older run is still reachable by
|
|
191
|
+
* `--run` with its id from `reposets history --limit`.
|
|
192
|
+
*/
|
|
193
|
+
const PICKER_RUNS = 50;
|
|
194
|
+
/**
|
|
195
|
+
* Which run to show, when `--run` was not given.
|
|
196
|
+
*
|
|
197
|
+
* @remarks
|
|
198
|
+
* Interactive, a `Select` over the recent runs, each labelled the way the
|
|
199
|
+
* table names it — short id, when, outcome, group — with mode, change count and
|
|
200
|
+
* duration as detail. Esc is the kit's `Cancelled`, left to propagate so
|
|
201
|
+
* `CliRuntime.main` prints its one line and exits 130; a cancelled question is
|
|
202
|
+
* not a successful answer.
|
|
203
|
+
*
|
|
204
|
+
* Called only for an interactive run: `showHandler` refuses a
|
|
205
|
+
* non-interactive one (an agent, CI, a pipe) before reaching here, exactly as a
|
|
206
|
+
* missing required flag would — exit 64, naming `--run`.
|
|
207
|
+
*/
|
|
208
|
+
const pickRun = (runs, abbreviate) => Effect.gen(function* () {
|
|
209
|
+
return yield* CliUi.prompt(Select.screen({
|
|
210
|
+
message: "Which run?",
|
|
211
|
+
choices: runs.slice(0, PICKER_RUNS).map((run) => {
|
|
212
|
+
const outcome = formatOutcome(run);
|
|
213
|
+
return {
|
|
214
|
+
label: `${abbreviate(run.id)} · ${formatWhen(run.startedAt)} · ${outcome} · ${Fmt.sanitize(run.group ?? "(all)")}`,
|
|
215
|
+
detail: `${formatMode(run)} · ${run.changes} change${run.changes === 1 ? "" : "s"} · ${formatDuration(run)}`,
|
|
216
|
+
value: run
|
|
217
|
+
};
|
|
218
|
+
})
|
|
219
|
+
})).pipe(Effect.catchTag("NotInteractive", () => Effect.fail(runRequired())));
|
|
220
|
+
});
|
|
221
|
+
/**
|
|
222
|
+
* Resolve a prefix to exactly one run, or refuse.
|
|
223
|
+
*
|
|
224
|
+
* @remarks
|
|
225
|
+
* Both refusals are usage errors — the user named a run that is not there, or
|
|
226
|
+
* not uniquely — so they fail with `UserError` and exit 64. `Command.runWith`
|
|
227
|
+
* prints the message once, on stderr.
|
|
228
|
+
*/
|
|
229
|
+
const matchRun = (runs, prefix) => {
|
|
230
|
+
const matches = runs.filter((run) => run.id.startsWith(prefix));
|
|
231
|
+
if (matches.length === 0) return Effect.fail(new CliError.UserError({ cause: `No run matches '${prefix}'.` }));
|
|
232
|
+
if (matches.length > 1) {
|
|
233
|
+
const candidates = matches.slice(0, 5).map((run) => ` ${run.id} ${formatWhen(run.startedAt)}`);
|
|
234
|
+
return Effect.fail(new CliError.UserError({ cause: [`'${prefix}' matches ${matches.length} runs. Use more of the id:`, ...candidates].join("\n") }));
|
|
235
|
+
}
|
|
236
|
+
return Effect.succeed(matches[0]);
|
|
237
|
+
};
|
|
105
238
|
/**
|
|
106
|
-
* `reposets history show
|
|
239
|
+
* `reposets history show [--run <id>]` — every resource one run touched.
|
|
107
240
|
*
|
|
108
241
|
* @remarks
|
|
109
242
|
* The journal has recorded these from the start and nothing surfaced them, so
|
|
110
243
|
* `12 changes` was a number with no way to ask *which twelve*.
|
|
111
244
|
*
|
|
112
245
|
* The id may be given in full or as a unique prefix, because nobody is going to
|
|
113
|
-
* retype a UUID from a table.
|
|
246
|
+
* retype a UUID from a table. Without `--run`, a person at a terminal picks
|
|
247
|
+
* from the recent runs and anyone else is refused with exit 64 (see
|
|
248
|
+
* {@link pickRun}). The refusal comes first and does not look at the journal,
|
|
249
|
+
* so a script's `history show` without `--run` is exit 64 whether or not any
|
|
250
|
+
* run is recorded. Only a person with an empty journal is told "No runs
|
|
251
|
+
* recorded yet" and exits 0 — there is nothing to pick, as `reposets history`
|
|
252
|
+
* says.
|
|
253
|
+
*
|
|
254
|
+
* The report is a `Doc` — the run header, its error, then the changes grouped
|
|
255
|
+
* by repository — printed for whoever is reading.
|
|
114
256
|
*/
|
|
115
257
|
const showHandler = (prefix) => Effect.gen(function* () {
|
|
116
258
|
const journal = yield* SyncJournal;
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
259
|
+
const runs = yield* journal.history({ limit: 1e3 }).pipe(Effect.orElseSucceed(() => []));
|
|
260
|
+
let run;
|
|
261
|
+
if (prefix === void 0) {
|
|
262
|
+
if (!(yield* CliInteractive)) return yield* Effect.fail(runRequired());
|
|
263
|
+
if (runs.length === 0) {
|
|
264
|
+
yield* CliMessage.info("No runs recorded yet. The journal fills in as you sync.");
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const abbreviate = yield* journalAbbreviator(journal, runs).pipe(Effect.orElseSucceed(() => abbreviator(runs.map((r) => r.id))));
|
|
268
|
+
run = yield* pickRun(runs, abbreviate);
|
|
269
|
+
} else run = yield* matchRun(runs, prefix);
|
|
124
270
|
const changes = yield* journal.changesFor(run.id).pipe(Effect.orElseSucceed(() => []));
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (existing === void 0) byRepo.set(change.repo, [change]);
|
|
140
|
-
else existing.push(change);
|
|
141
|
-
}
|
|
142
|
-
for (const [repo, records] of byRepo) {
|
|
143
|
-
yield* Console.log(` ${repo}`);
|
|
144
|
-
for (const record of records) {
|
|
271
|
+
const outcome = run.outcome ?? "unfinished";
|
|
272
|
+
const failed = run.error !== null && run.error !== "";
|
|
273
|
+
const body = [Doc.paragraph(Doc.status(Status.core, outcomeStatus(run.outcome ?? "interrupted")), " ", `${formatWhen(run.startedAt)} · ${formatMode(run)} · ${outcome} · ${formatDuration(run)}`)];
|
|
274
|
+
if (failed) body.push(Doc.paragraph(Doc.text("error: ", "failure"), run.error ?? ""));
|
|
275
|
+
if (changes.length === 0) body.push(Doc.paragraph(failed ? "No resources changed." : "No resources changed (nothing to do)."));
|
|
276
|
+
else {
|
|
277
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
278
|
+
for (const change of changes) {
|
|
279
|
+
const existing = byRepo.get(change.repo);
|
|
280
|
+
if (existing === void 0) byRepo.set(change.repo, [change]);
|
|
281
|
+
else existing.push(change);
|
|
282
|
+
}
|
|
283
|
+
const dryRun = run.dryRun === 1;
|
|
284
|
+
body.push(Doc.list([...byRepo].map(([repo, records]) => Doc.section(repo, [Doc.lines(records.map((record) => {
|
|
145
285
|
const detail = record.detail === void 0 || record.detail === null ? "" : ` — ${record.detail}`;
|
|
146
286
|
const what = record.kind === record.name ? record.kind : `${record.kind} ${record.name}`;
|
|
147
|
-
const action =
|
|
148
|
-
|
|
149
|
-
|
|
287
|
+
const action = dryRun ? `would ${wouldForm(record.action)}` : record.action;
|
|
288
|
+
return [
|
|
289
|
+
Doc.text(action.padEnd(18), actionToken(record.action, dryRun)),
|
|
290
|
+
" ",
|
|
291
|
+
what,
|
|
292
|
+
detail
|
|
293
|
+
];
|
|
294
|
+
}))])), { compact: true }));
|
|
150
295
|
}
|
|
296
|
+
yield* Doc.print([Doc.section(`run ${run.id}`, body)]);
|
|
151
297
|
});
|
|
298
|
+
const UNTOUCHED = "Applied state and the cache are untouched — drift detection still works.";
|
|
152
299
|
/**
|
|
153
300
|
* `reposets history prune|clear` — the journal, and only the journal.
|
|
154
301
|
*
|
|
@@ -158,16 +305,23 @@ const showHandler = (prefix) => Effect.gen(function* () {
|
|
|
158
305
|
* against; deleting those does not tidy anything, it disarms the check, so the
|
|
159
306
|
* next run reports a first sync where an out-of-band edit actually happened.
|
|
160
307
|
* A command that quietly did both would be the most dangerous thing in this CLI.
|
|
308
|
+
*
|
|
309
|
+
* The result is a `CliMessage` line, and so is the statement that the
|
|
310
|
+
* baselines survived: said every time, because the sentence matters as much as
|
|
311
|
+
* the deletion.
|
|
161
312
|
*/
|
|
162
313
|
const pruneHandler = (keep) => Effect.gen(function* () {
|
|
163
314
|
const removed = yield* (yield* SyncJournal).prune(keep).pipe(Effect.orElseSucceed(() => 0));
|
|
164
|
-
yield*
|
|
165
|
-
yield*
|
|
315
|
+
yield* removed === 0 ? CliMessage.info(`Nothing to prune; ${keep} or fewer runs are recorded.`) : CliMessage.success(`Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
|
|
316
|
+
yield* CliMessage.info(UNTOUCHED);
|
|
166
317
|
});
|
|
318
|
+
/**
|
|
319
|
+
* `reposets history clear` — every run, and nothing else; see {@link pruneHandler}.
|
|
320
|
+
*/
|
|
167
321
|
const clearHandler = () => Effect.gen(function* () {
|
|
168
322
|
const removed = yield* (yield* SyncJournal).clear().pipe(Effect.orElseSucceed(() => 0));
|
|
169
|
-
yield*
|
|
170
|
-
yield*
|
|
323
|
+
yield* CliMessage.success(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
|
|
324
|
+
yield* CliMessage.info(UNTOUCHED);
|
|
171
325
|
});
|
|
172
326
|
/**
|
|
173
327
|
* `reposets history`.
|
|
@@ -181,7 +335,7 @@ const historyCommand = Command.make("history", {
|
|
|
181
335
|
limit,
|
|
182
336
|
repo: repo._tag === "Some" ? repo.value : void 0
|
|
183
337
|
})).pipe(Command.withDescription("Show what previous sync runs did, newest first"), Command.withSubcommands([
|
|
184
|
-
Command.make("show", { run: Flag.String("run").pipe(Flag.withDescription("A run id, or a unique prefix")) }, ({ run }) => showHandler(run)).pipe(Command.withDescription("Show every resource one run touched")),
|
|
338
|
+
Command.make("show", { run: Flag.String("run").pipe(Flag.optional, Flag.withDescription("A run id, or a unique prefix; omitted, a terminal picks from recent runs")) }, ({ run }) => showHandler(Option.getOrUndefined(run))).pipe(Command.withDescription("Show every resource one run touched")),
|
|
185
339
|
Command.make("prune", { keep: Flag.Int("keep").pipe(Flag.withDefault(50), Flag.withDescription("How many of the newest runs to keep")) }, ({ keep }) => pruneHandler(keep)).pipe(Command.withDescription("Delete all but the newest runs from the journal")),
|
|
186
340
|
Command.make("clear", {}, () => clearHandler()).pipe(Command.withDescription("Delete every run from the journal"))
|
|
187
341
|
]));
|