reposets 2.0.4 → 3.1.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.
@@ -2,11 +2,12 @@ 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 { CliMessage, Doc, SchemaIssueRenderer, Status } from "@effected/cli";
10
11
  import { Command } from "effect/cli";
11
12
  import { AppDirs } from "@effected/xdg";
12
13
  import { GitHubClient } from "@effected/github";
@@ -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 in one piece, so `reposets doctor > report.txt` captures all of it.
238
- * Only the three early stops (no config, unreadable, unparseable) are
239
- * diagnostics on stderr. `doctor` always exits 0: it describes, it does not
240
- * gate — `validate` is the gate.
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* Effect.logError("No config found. Run 'reposets init' to create one.");
302
+ yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
297
303
  return;
298
304
  }
299
- yield* Console.log(`Config: ${configPath}`);
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* Effect.logError(`Could not read ${configPath}`);
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* Effect.logError(`TOML parse error: ${String(parsed.failure)}`);
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
- let warnings = 0;
312
- const checkKeys = (target, known, where) => Effect.gen(function* () {
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
- yield* Console.log(`Warning: '${key}'${where} ${removedFrom}`);
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
- yield* Console.log(`Warning: unknown key '${key}'${where}${hint}`);
325
- warnings += 1;
339
+ warnings.push(`unknown key '${key}'${where}${hint}`);
326
340
  }
327
- });
328
- yield* checkKeys(raw, KNOWN_CONFIG_KEYS, "");
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
- yield* Console.log(`Warning: unrecognised setting '${field}' in settings.${groupName}${hint}`);
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
- yield* checkKeys(group, KNOWN_GROUP_KEYS, ` in groups.${groupName}`);
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
- yield* checkKeys(cleanup, KNOWN_CLEANUP_KEYS, ` in ${prefix}`);
348
- yield* checkKeys(cleanup.secrets, KNOWN_CLEANUP_SECRETS_KEYS, ` in ${prefix}.secrets`);
349
- yield* checkKeys(cleanup.variables, KNOWN_CLEANUP_VARIABLES_KEYS, ` in ${prefix}.variables`);
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`);
350
363
  }
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}`);
355
- }
356
- yield* Console.log("");
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
- if (loaded._tag === "Failure") {
361
- yield* Console.log(`Credentials: FAILED to load — ${String(loaded.failure.message ?? loaded.failure)}`);
362
- yield* Console.log(" Unknown keys are rejected. `op_service_account_token` was removed — that token now comes from OP_SERVICE_ACCOUNT_TOKEN in the environment.");
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
- yield* Console.log(`Credentials: ${profileNames.length} profile(s)`);
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
- yield* Console.log(` [${name}] github_token: ${token}${resolveSummary}`);
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) yield* Console.log(hasServiceAccount ? ` ${OP_SERVICE_ACCOUNT_TOKEN}: set` : ` ${OP_SERVICE_ACCOUNT_TOKEN}: NOT SET — op:// references cannot be resolved`);
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* Console.log("");
389
- yield* Console.log(`Version: ${invocation.version}`);
390
- const credentialDiscovery = yield* credentialsFile.discover.pipe(Effect.result);
391
- const credentialSources = credentialDiscovery._tag === "Success" ? credentialDiscovery.success : [];
392
- const credentialsLine = credentialDiscovery._tag === "Failure" ? "(failed to load — the error above names the file)" : credentialSources.length === 0 ? `${path.join(appDirs.dirs.config, CREDENTIALS_FILENAME)} (not created yet)` : credentialSources[0]?.path ?? "";
393
- yield* Console.log(`Credentials file: ${credentialsLine}`);
394
- yield* Console.log(`State database: ${path.join(appDirs.dirs.state, "store.db")}`);
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
- yield* Console.log(`Token [${name}]: could not resolve — ${String(resolved.failure)}`);
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
- yield* Console.log(`Token [${name}]: resolved but REJECTED by GitHub — ${String(identity.failure)}`);
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
- yield* Console.log(line.text);
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* Console.log("Required fine-grained token permissions:");
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`.
@@ -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", {
@@ -1,6 +1,8 @@
1
1
  import { SyncJournal } from "../../store/SyncJournal.js";
2
- import { Console, Effect } from "effect";
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
- * Render the table, sizing each column to its widest cell.
41
+ * The status glyph an outcome is drawn with.
42
42
  *
43
43
  * @remarks
44
- * Computed rather than fixed because group names and repository names are
45
- * user-supplied and unbounded. Each line is written verbatim to stdout, so
46
- * alignment computed here survives to the terminal.
47
- */
48
- const renderRows = (summaries) => {
49
- const all = [{
50
- id: "RUN",
51
- when: "WHEN (UTC)",
52
- outcome: "OUTCOME",
53
- mode: "MODE",
54
- group: "GROUP",
55
- changes: "CHANGES",
56
- took: "TOOK"
57
- }, ...summaries.map((summary) => ({
58
- id: summary.id.slice(0, 8),
59
- when: formatWhen(summary.startedAt),
60
- outcome: formatOutcome(summary),
61
- mode: summary.dryRun === 1 ? "dry-run" : "applied",
62
- group: summary.group ?? "(all)",
63
- changes: String(summary.changes),
64
- took: formatDuration(summary)
65
- }))];
66
- const width = (key) => Math.max(...all.map((row) => row[key].length));
67
- return all.map((row) => `${pad(row.id, width("id"))} ${pad(row.when, width("when"))} ${pad(row.outcome, width("outcome"))} ${pad(row.mode, width("mode"))} ${pad(row.group, width("group"))} ${row.changes.padStart(width("changes"))} ${row.took}`);
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 summaries = yield* (yield* SyncJournal).history({
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* Console.log(input.repo === void 0 ? "No runs recorded yet. The journal fills in as you sync." : `No recorded run has touched ${input.repo}.`);
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
- for (const line of renderRows(summaries)) yield* Console.log(line);
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 <run>` — every resource one run touched.
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 matches = (yield* journal.history({ limit: 1e3 }).pipe(Effect.orElseSucceed(() => []))).filter((run) => run.id.startsWith(prefix));
118
- if (matches.length === 0) return yield* Effect.fail(new CliError.UserError({ cause: `No run matches '${prefix}'.` }));
119
- if (matches.length > 1) {
120
- const candidates = matches.slice(0, 5).map((run) => ` ${run.id} ${formatWhen(run.startedAt)}`);
121
- return yield* Effect.fail(new CliError.UserError({ cause: [`'${prefix}' matches ${matches.length} runs. Use more of the id:`, ...candidates].join("\n") }));
122
- }
123
- const run = matches[0];
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
- yield* Console.log(`run ${run.id}`);
126
- yield* Console.log(` ${formatWhen(run.startedAt)} · ${run.dryRun === 1 ? "dry-run" : "applied"} · ${run.outcome ?? "unfinished"} · ${formatDuration(run)}`);
127
- yield* Console.log("");
128
- if (run.error !== null && run.error !== "") {
129
- yield* Console.log(` error: ${run.error}`);
130
- yield* Console.log("");
131
- }
132
- if (changes.length === 0) {
133
- yield* Console.log(run.error !== null && run.error !== "" ? " No resources changed." : " No resources changed (nothing to do).");
134
- return;
135
- }
136
- const byRepo = /* @__PURE__ */ new Map();
137
- for (const change of changes) {
138
- const existing = byRepo.get(change.repo);
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 = run.dryRun === 1 ? `would ${wouldForm(record.action)}` : record.action;
148
- yield* Console.log(` ${action.padEnd(18)} ${what}${detail}`);
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* Console.log(removed === 0 ? `Nothing to prune; ${keep} or fewer runs are recorded.` : `Pruned ${removed} run${removed === 1 ? "" : "s"}, keeping the newest ${keep}.`);
165
- yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
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* Console.log(`Cleared ${removed} run${removed === 1 ? "" : "s"} from the journal.`);
170
- yield* Console.log("Applied state and the cache are untouched — drift detection still works.");
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
  ]));