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.
@@ -1,12 +1,23 @@
1
1
  import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
2
  import { Invocation } from "../../services/Invocation.js";
3
- import { Console, Effect, FileSystem, Path } from "effect";
4
- import { CliExit } from "@effected/cli";
3
+ import { configSchemaHost, credentialsSchemaHost, schemaDirective } from "../../schemas/hosted.js";
4
+ import { Effect, FileSystem, Option, Path } from "effect";
5
+ import { CliExit, CliMessage } from "@effected/cli";
5
6
  import { Command, Flag } from "effect/cli";
7
+ import { CliUi, Select } from "@effected/cli/ui";
6
8
  import { AppDirs } from "@effected/xdg";
7
9
 
8
10
  //#region src/cli/commands/init.ts
9
- const projectFlag = Flag.Boolean("project").pipe(Flag.withDefault(false), Flag.withDescription("Scaffold into the current directory instead of the XDG config directory"));
11
+ /**
12
+ * `--project` scaffolds into the working directory, `--no-project` (or
13
+ * `--project=false`) into the XDG config directory.
14
+ *
15
+ * @remarks
16
+ * Optional rather than defaulted to `false`, so the handler can tell "not
17
+ * given" from "given as false": only the former asks a person where to put the
18
+ * files. A run that cannot ask takes the XDG directory, as it always has.
19
+ */
20
+ const projectFlag = Flag.Boolean("project").pipe(Flag.optional, Flag.withDescription("Scaffold into the current directory (--no-project: the XDG config directory). Asked when omitted on a terminal"));
10
21
  const CONFIG_TEMPLATE = `# reposets configuration
11
22
  # See: https://github.com/spencerbeggs/reposets
12
23
 
@@ -101,19 +112,53 @@ const CREDENTIALS_TEMPLATE = `# reposets credentials
101
112
  # MY_CERT = "./certs/bot.pem"
102
113
  `;
103
114
  /**
115
+ * Where the files go when `--project` was not given.
116
+ *
117
+ * @remarks
118
+ * An interactive run asks, showing both resolved paths so the choice is about
119
+ * a directory and not a word; the XDG directory is first and so the default.
120
+ * A run that cannot ask (an agent, CI, a pipe) answers XDG without loading the
121
+ * screen, which is exactly what `init` did before it could ask. A cancel is the
122
+ * kit's `Cancelled` and propagates: exit 130, nothing written.
123
+ */
124
+ const chooseProject = (configDir, cwd) => CliUi.prompt(Select.screen({
125
+ message: "Where should reposets keep its config?",
126
+ choices: [{
127
+ label: "XDG config directory",
128
+ value: false,
129
+ detail: configDir
130
+ }, {
131
+ label: "This directory",
132
+ value: true,
133
+ detail: cwd
134
+ }]
135
+ }), { otherwise: false });
136
+ /**
104
137
  * `reposets init` — scaffold the config and credentials files.
105
138
  *
106
139
  * @remarks
107
- * Writes into the XDG config directory by default, or the current directory
108
- * with `--project`. Existing files are reported and never overwritten — this
109
- * command must be safe to re-run against a configured machine.
140
+ * Writes into the XDG config directory with `--no-project`, or the current
141
+ * directory with `--project`. Without either, an interactive run asks (see
142
+ * {@link chooseProject}) and a non-interactive one uses the XDG directory.
143
+ * Existing files are reported and never overwritten — this command must be
144
+ * safe to re-run against a configured machine.
145
+ *
146
+ * Each scaffolded file opens with a `#:schema` directive naming the versioned
147
+ * JSON Schema it was written against, taken from the same `HostedSchema` the
148
+ * schema build writes that document's `$id` from (`src/schemas/hosted.ts`).
149
+ * Taplo and Tombi bind the file to that exact version without consulting
150
+ * SchemaStore's catalog, and to the TOML decoder the line is a comment.
110
151
  *
111
152
  * The credentials file is added to `.gitignore` in both modes. It contains only
112
153
  * references and so is not catastrophic to commit, but it still names a
113
154
  * person's vault layout, and the habit is worth keeping.
114
155
  *
115
- * What was created or found goes to stdout; the next-steps guidance to stderr.
116
- * A file that could not be written is reported on stderr and exits 1.
156
+ * What was created or found goes to stdout as status lines; the next-steps
157
+ * guidance to stderr. A file that could not be written is reported on stderr
158
+ * and exits 1.
159
+ *
160
+ * @param project - `true` for the working directory, `false` for XDG,
161
+ * `undefined` when the flag was omitted
117
162
  *
118
163
  * @public
119
164
  */
@@ -121,7 +166,8 @@ const initHandler = (project) => Effect.gen(function* () {
121
166
  const fs = yield* FileSystem.FileSystem;
122
167
  const path = yield* Path.Path;
123
168
  const appDirs = yield* AppDirs;
124
- const targetDir = project ? (yield* Invocation).cwd : yield* appDirs.ensureConfig;
169
+ const { cwd } = yield* Invocation;
170
+ const targetDir = project ?? (yield* chooseProject(appDirs.dirs.config, cwd)) ? cwd : yield* appDirs.ensureConfig;
125
171
  yield* fs.makeDirectory(targetDir, { recursive: true });
126
172
  /**
127
173
  * A write that fails is a real failure of this command — the user asked
@@ -134,22 +180,23 @@ const initHandler = (project) => Effect.gen(function* () {
134
180
  const scaffold = (name, contents) => Effect.gen(function* () {
135
181
  const target = path.join(targetDir, name);
136
182
  if (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false))) {
137
- yield* Console.log(`Already exists: ${target}`);
183
+ yield* CliMessage.info(`Already exists: ${target}`);
138
184
  return;
139
185
  }
140
- yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ? Console.log(`Created: ${target}`) : failed(target);
186
+ yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ? CliMessage.success(`Created: ${target}`) : failed(target);
141
187
  });
142
- yield* scaffold(CONFIG_FILENAME, CONFIG_TEMPLATE);
143
- yield* scaffold(CREDENTIALS_FILENAME, CREDENTIALS_TEMPLATE);
188
+ yield* scaffold(CONFIG_FILENAME, `${schemaDirective(configSchemaHost)}${CONFIG_TEMPLATE}`);
189
+ yield* scaffold(CREDENTIALS_FILENAME, `${schemaDirective(credentialsSchemaHost)}${CREDENTIALS_TEMPLATE}`);
144
190
  const gitignorePath = path.join(targetDir, ".gitignore");
145
191
  const existing = yield* fs.readFileString(gitignorePath).pipe(Effect.orElseSucceed(() => void 0));
146
- if (existing === void 0) yield* (yield* fs.writeFileString(gitignorePath, `${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? Console.log(`Created .gitignore with ${CREDENTIALS_FILENAME}`) : failed(gitignorePath);
192
+ if (existing === void 0) yield* (yield* fs.writeFileString(gitignorePath, `${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? CliMessage.success(`Created .gitignore with ${CREDENTIALS_FILENAME}`) : failed(gitignorePath);
147
193
  else if (!existing.includes("reposets.credentials.toml")) {
148
194
  const separator = existing.endsWith("\n") ? "" : "\n";
149
- yield* (yield* fs.writeFileString(gitignorePath, `${existing}${separator}${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? Console.log(`Added ${CREDENTIALS_FILENAME} to .gitignore`) : failed(gitignorePath);
195
+ yield* (yield* fs.writeFileString(gitignorePath, `${existing}${separator}${"reposets.credentials.toml"}\n`).pipe(Effect.option))._tag === "Some" ? CliMessage.success(`Added ${CREDENTIALS_FILENAME} to .gitignore`) : failed(gitignorePath);
150
196
  }
151
197
  yield* Effect.log("");
152
- yield* Effect.log("Done. Edit the config, then add a credential reference with:");
198
+ yield* Effect.log("Done. Edit the config, then add a credential reference:");
199
+ yield* Effect.log(" reposets credentials create (on a terminal, asks for each value)");
153
200
  yield* Effect.log(" reposets credentials create --profile personal --username YOU --op \"op://Vault/item/field\"");
154
201
  });
155
202
  /**
@@ -157,7 +204,7 @@ const initHandler = (project) => Effect.gen(function* () {
157
204
  *
158
205
  * @public
159
206
  */
160
- const initCommand = Command.make("init", { project: projectFlag }, ({ project }) => initHandler(project)).pipe(Command.withDescription("Scaffold reposets.config.toml and reposets.credentials.toml"));
207
+ const initCommand = Command.make("init", { project: projectFlag }, ({ project }) => initHandler(Option.getOrUndefined(project))).pipe(Command.withDescription("Scaffold reposets.config.toml and reposets.credentials.toml"));
161
208
 
162
209
  //#endregion
163
210
  export { initCommand, initHandler };
@@ -1,7 +1,7 @@
1
1
  import { profileOwner } from "../../schemas/credentials.js";
2
2
  import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
3
- import { Console, Effect } from "effect";
4
- import { CliExit } from "@effected/cli";
3
+ import { Effect } from "effect";
4
+ import { CliExit, CliMessage, Doc } from "@effected/cli";
5
5
  import { Command } from "effect/cli";
6
6
 
7
7
  //#region src/cli/commands/list.ts
@@ -30,36 +30,49 @@ const scopeParts = (scopes) => {
30
30
  * entry, not a wall of blanks. The summary is the command's output, so it is
31
31
  * written to stdout.
32
32
  *
33
+ * The summary is a `Doc` — one section per group — printed with `Doc.print`,
34
+ * so the same report is plain text for an agent, painted for a person and a
35
+ * folded log under GitHub Actions. Every name in it is user-supplied, and the
36
+ * document sanitises it on the way out.
37
+ *
33
38
  * @public
34
39
  */
35
40
  const listHandler = Effect.gen(function* () {
36
41
  const configFile = yield* ReposetsConfigFile;
37
42
  const credentialsFile = yield* ReposetsCredentialsFile;
38
43
  if ((yield* configFile.discover).length === 0) {
39
- yield* Effect.logError("No config file found. Run 'reposets init' to create one.");
44
+ yield* CliMessage.failure("No config found. Run 'reposets init' to create one.");
40
45
  return yield* CliExit.set(1);
41
46
  }
42
47
  const config = yield* configFile.load;
43
48
  const credentials = yield* credentialsFile.loadOrDefault({ profiles: {} });
49
+ const sections = [];
44
50
  for (const [groupName, group] of Object.entries(config.groups)) {
45
51
  const profile = credentials.profiles[group.credentials];
46
- const acts = profile === void 0 ? `credentials: ${group.credentials} — NOT FOUND` : `owner: ${profileOwner(profile).owner}, credentials: ${group.credentials}`;
47
- yield* Console.log(`[${groupName}] (${acts})`);
52
+ const acts = profile === void 0 ? Doc.text(`credentials: ${group.credentials} — NOT FOUND`, "failure") : Doc.text(`owner: ${profileOwner(profile).owner}, credentials: ${group.credentials}`, "muted");
48
53
  const owner = profile === void 0 ? "(unknown)" : profileOwner(profile).owner;
49
- for (const repo of group.repos) yield* Console.log(` - ${owner}/${repo}`);
54
+ const children = [];
55
+ if (group.repos.length > 0) children.push(Doc.list(group.repos.map((repo) => Doc.paragraph(`${owner}/${repo}`)), { compact: true }));
56
+ const assignments = [];
50
57
  for (const [label, names] of [
51
58
  ["settings", group.settings],
52
59
  ["environments", group.environments],
53
60
  ["rulesets", group.rulesets],
54
61
  ["security", group.security],
55
62
  ["code_scanning", group.code_scanning]
56
- ]) if (names !== void 0 && names.length > 0) yield* Console.log(` ${label}: ${names.join(", ")}`);
63
+ ]) if (names !== void 0 && names.length > 0) assignments.push(`${label}: ${names.join(", ")}`);
57
64
  const secrets = scopeParts(group.secrets);
58
- if (secrets.length > 0) yield* Console.log(` secrets: ${secrets.join(", ")}`);
65
+ if (secrets.length > 0) assignments.push(`secrets: ${secrets.join(", ")}`);
59
66
  const variables = scopeParts(group.variables);
60
- if (variables.length > 0) yield* Console.log(` variables: ${variables.join(", ")}`);
61
- yield* Console.log("");
67
+ if (variables.length > 0) assignments.push(`variables: ${variables.join(", ")}`);
68
+ if (assignments.length > 0) children.push(Doc.lines(assignments));
69
+ sections.push(Doc.section([
70
+ `[${groupName}] (`,
71
+ acts,
72
+ ")"
73
+ ], children));
62
74
  }
75
+ yield* Doc.print([Doc.section(void 0, sections)]);
63
76
  });
64
77
  /**
65
78
  * `reposets list`.
@@ -1,13 +1,67 @@
1
1
  import { CONFIG_FILENAME, CREDENTIALS_FILENAME } from "../../services/ConfigFiles.js";
2
2
  import { Invocation } from "../../services/Invocation.js";
3
- import { Console, Effect, FileSystem, Path, Stdio } from "effect";
4
- import { CliExit } from "@effected/cli";
5
- import { CliError, Command, Flag, Prompt } from "effect/cli";
6
- import { AppDirs } from "@effected/xdg";
3
+ import { CACHE_DB_FILENAME, STATE_DB_FILENAME } from "../../store/files.js";
4
+ import { Effect, FileSystem, Path } from "effect";
5
+ import { CliExit, CliInteractive, CliMessage, Doc } from "@effected/cli";
6
+ import { CliError, Command, Flag } from "effect/cli";
7
+ import { CliUi, Confirm, MultiSelect } from "@effected/cli/ui";
8
+ import { AppDirs, Xdg } from "@effected/xdg";
7
9
 
8
10
  //#region src/cli/commands/nuke.ts
9
11
  const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withDescription("Delete without asking. Intended for scripts; there is no undo"));
10
12
  /**
13
+ * The suffixes SQLite gives a database's companion files.
14
+ *
15
+ * @remarks
16
+ * `-wal` and `-shm` in write-ahead-log mode, which is what the store and cache
17
+ * use; `-journal` in rollback mode. Each is part of the database, not a file
18
+ * of its own: a `-wal` holds committed transactions not yet folded into the
19
+ * main file.
20
+ */
21
+ const SQLITE_COMPANIONS = [
22
+ "-wal",
23
+ "-shm",
24
+ "-journal"
25
+ ];
26
+ /**
27
+ * Whether a regular file exists at a path. A path that cannot be stat'd reads
28
+ * as absent.
29
+ */
30
+ const isFile = (fs, file) => fs.stat(file).pipe(Effect.map((info) => info.type === "File"), Effect.orElseSucceed(() => false));
31
+ /**
32
+ * A database as one target: the main file and whichever companions exist.
33
+ *
34
+ * @remarks
35
+ * Listed once because it is one thing to a person, and removed as a unit
36
+ * because removing the main file alone strands its `-wal` and `-shm` beside the
37
+ * fresh database the next sync creates. The target is present when **any** of
38
+ * the files is, so companions orphaned by an earlier version — which deleted
39
+ * `store.db` while its own process held it open — are found and cleaned up
40
+ * too. The row shows the main file's path either way: it is the name a person
41
+ * recognises.
42
+ */
43
+ const databaseTarget = (fs, candidate) => Effect.gen(function* () {
44
+ const paths = [];
45
+ for (const file of [candidate.path, ...SQLITE_COMPANIONS.map((suffix) => `${candidate.path}${suffix}`)]) if (yield* isFile(fs, file)) paths.push(file);
46
+ return paths.length === 0 ? void 0 : {
47
+ ...candidate,
48
+ paths
49
+ };
50
+ });
51
+ /**
52
+ * Whether a `.gitignore` holds nothing but what `init` writes.
53
+ *
54
+ * @remarks
55
+ * `init` writes the credentials filename, one line, into the directory it
56
+ * scaffolds. A file with any other non-blank line was written or edited by
57
+ * someone else, and deleting it would take their rules with it, so it is not a
58
+ * target. An empty or blank-only file is not one `init` wrote either.
59
+ */
60
+ const isInitGitignore = (contents) => {
61
+ const lines = contents.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
62
+ return lines.length > 0 && lines.every((line) => line === "reposets.credentials.toml");
63
+ };
64
+ /**
11
65
  * Everything reposets has written to this machine.
12
66
  *
13
67
  * @remarks
@@ -20,45 +74,215 @@ const forceFlag = Flag.Boolean("force").pipe(Flag.withDefault(false), Flag.withD
20
74
  * file anywhere between the working directory and the filesystem root — because
21
75
  * the file a user is thinking of is the one the CLI would load, and that is not
22
76
  * always the one in the current directory.
77
+ *
78
+ * The `.gitignore` considered is only the one in the user config directory,
79
+ * where `init` writes it and nobody else is expected to: a project directory's
80
+ * `.gitignore` belongs to that repository, whatever it contains.
23
81
  */
24
82
  const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
25
- const candidates = [];
83
+ const files = [];
26
84
  let dir = from;
27
85
  for (;;) {
28
- candidates.push({
86
+ files.push({
29
87
  path: path.join(dir, CONFIG_FILENAME),
30
88
  what: "project config",
31
- cost: "your groups and settings"
89
+ cost: "loses your groups and settings",
90
+ location: "project"
32
91
  }, {
33
92
  path: path.join(dir, CREDENTIALS_FILENAME),
34
93
  what: "project credentials",
35
- cost: "token references, not tokens"
94
+ cost: "loses token references, not tokens",
95
+ location: "project"
36
96
  });
37
97
  const parent = path.dirname(dir);
38
98
  if (parent === dir) break;
39
99
  dir = parent;
40
100
  }
41
- candidates.push({
101
+ files.push({
42
102
  path: path.join(appDirs.dirs.config, CONFIG_FILENAME),
43
103
  what: "user config",
44
- cost: "your groups and settings"
104
+ cost: "loses your groups and settings",
105
+ location: "user"
45
106
  }, {
46
107
  path: path.join(appDirs.dirs.config, CREDENTIALS_FILENAME),
47
108
  what: "user credentials",
48
- cost: "token references, not tokens"
49
- }, {
50
- path: path.join(appDirs.dirs.state, "store.db"),
51
- what: "state database",
52
- cost: "run history AND the drift baselines — drift detection restarts from nothing"
109
+ cost: "loses token references, not tokens",
110
+ location: "user"
53
111
  });
54
112
  const present = [];
55
- for (const candidate of candidates) {
56
- const info = yield* fs.stat(candidate.path).pipe(Effect.option);
57
- if (info._tag === "Some" && info.value.type === "File") present.push(candidate);
113
+ for (const candidate of files) if (yield* isFile(fs, candidate.path)) present.push({
114
+ ...candidate,
115
+ paths: [candidate.path]
116
+ });
117
+ const gitignore = path.join(appDirs.dirs.config, ".gitignore");
118
+ const contents = yield* fs.readFileString(gitignore).pipe(Effect.option);
119
+ if (contents._tag === "Some" && isInitGitignore(contents.value)) present.push({
120
+ path: gitignore,
121
+ paths: [gitignore],
122
+ what: "user .gitignore",
123
+ cost: "written by init",
124
+ location: "user"
125
+ });
126
+ const databases = [{
127
+ path: path.join(appDirs.dirs.state, STATE_DB_FILENAME),
128
+ what: "state database",
129
+ cost: "loses run history AND the drift baselines — drift detection restarts from nothing",
130
+ location: "state"
131
+ }, {
132
+ path: path.join(appDirs.dirs.cache, CACHE_DB_FILENAME),
133
+ what: "cache database",
134
+ cost: "loses cached GitHub reads — rebuilt on the next sync",
135
+ location: "cache"
136
+ }];
137
+ for (const candidate of databases) {
138
+ const target = yield* databaseTarget(fs, candidate);
139
+ if (target !== void 0) present.push(target);
58
140
  }
59
141
  return present;
60
142
  });
61
143
  /**
144
+ * Remove each of reposets' own directories that is now empty.
145
+ *
146
+ * @remarks
147
+ * Run only after a confirmed or forced removal, so a run that deleted nothing
148
+ * leaves the directories as it found them. The directories are never listed as
149
+ * targets: they are containers, and listing them would ask a person to decide
150
+ * something that follows from the files. A directory with anything left in it
151
+ * — a file deselected in the picker, or one reposets did not write — is kept.
152
+ *
153
+ * Core's `FileSystem.remove` without `recursive` refuses a directory even when
154
+ * it is empty (it is Node's `rm`, not `rmdir`), so emptiness is checked with
155
+ * `readDirectory` first and the removal then passes `recursive`, which on a
156
+ * directory just seen empty removes only the directory. A directory that cannot
157
+ * be read or removed is a warning, not a failure: nobody asked for it by name.
158
+ */
159
+ const removeEmptyDirs = (fs, appDirs) => Effect.gen(function* () {
160
+ const { config, state, cache, data } = appDirs.dirs;
161
+ for (const dir of /* @__PURE__ */ new Set([
162
+ config,
163
+ state,
164
+ cache,
165
+ data
166
+ ])) {
167
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.option);
168
+ if (entries._tag === "None" || entries.value.length > 0) continue;
169
+ const outcome = yield* fs.remove(dir, { recursive: true }).pipe(Effect.result);
170
+ if (outcome._tag === "Failure") {
171
+ yield* Effect.logWarning(`could not remove empty directory ${dir} — ${String(outcome.failure)}`);
172
+ continue;
173
+ }
174
+ yield* CliMessage.success(`removed empty directory ${dir}`);
175
+ }
176
+ });
177
+ const plural = (n) => `${n} file${n === 1 ? "" : "s"}`;
178
+ /**
179
+ * How many files a set of targets removes.
180
+ *
181
+ * @remarks
182
+ * Counted in files, not targets, because files are what the run reports: a
183
+ * database is one target but up to four files (`-wal`, `-shm`, `-journal`),
184
+ * each with its own `removed` line, and a summary counting targets would read
185
+ * "1 file removed" under three of them.
186
+ */
187
+ const fileCount = (targets) => targets.reduce((n, target) => n + target.paths.length, 0);
188
+ /**
189
+ * The list of what was found, as a document.
190
+ *
191
+ * @remarks
192
+ * Printed before any question and whether or not one is asked, so a `--force`
193
+ * run in a script leaves the same record in its log that a person reads before
194
+ * answering. When the state database is among the targets its loss is said a
195
+ * second time, as a warning callout: of everything listed it is the only
196
+ * deletion that cannot be undone by restoring or rewriting a file.
197
+ */
198
+ const targetsDoc = (targets) => [
199
+ Doc.section("This will delete:", [Doc.list(targets.map((target) => Doc.lines([Doc.file(target.path), target.location === "state" ? [`${target.what} — `, Doc.text(target.cost, "warning")] : `${target.what} — ${target.cost}`])), { compact: true })]),
200
+ ...targets.some((target) => target.location === "state") ? [Doc.callout("warning", [Doc.paragraph("Deleting the state database cannot be undone: the drift baselines it holds cannot be rebuilt.")])] : [],
201
+ Doc.paragraph("Nothing on GitHub is touched. Everything reposets applied stays applied.")
202
+ ];
203
+ const SECTION_TITLES = {
204
+ project: "Project files",
205
+ user: "User files",
206
+ state: "State",
207
+ cache: "Cache"
208
+ };
209
+ /**
210
+ * A short, still unambiguous name for a target, for the picker's rows.
211
+ *
212
+ * @remarks
213
+ * A picker row is truncated to the terminal, and an absolute path under a
214
+ * temp or home directory loses exactly the part that tells two files apart.
215
+ * A project file is shown relative to the working directory — `./` or a run of
216
+ * `../` — which is short and distinguishes the same filename at different
217
+ * levels of the upward walk; anything under the home directory is shown with
218
+ * `~`. The absolute path stays in the row's detail and in the list printed
219
+ * above the picker.
220
+ */
221
+ const shortPath = (path, cwd, home, target) => {
222
+ if (target.location === "project") {
223
+ const relative = path.relative(cwd, target.path);
224
+ return relative.startsWith("..") ? relative : `.${path.sep}${relative}`;
225
+ }
226
+ const homePrefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
227
+ return target.path.startsWith(homePrefix) ? `~${path.sep}${target.path.slice(homePrefix.length)}` : target.path;
228
+ };
229
+ /**
230
+ * The picker's sections: every target pre-selected, grouped by where it lives.
231
+ *
232
+ * @remarks
233
+ * Pre-selected because the command's name is the request — a person who ran
234
+ * `nuke` and presses enter gets what they asked for, and still has the
235
+ * confirmation after it. An empty section is left out rather than drawn as a
236
+ * heading with nothing under it. Each row reads `what: short path` (see
237
+ * {@link shortPath}); the highlighted row's detail is the absolute path.
238
+ */
239
+ const pickerSections = (targets, short) => [
240
+ "project",
241
+ "user",
242
+ "state",
243
+ "cache"
244
+ ].map((location) => ({
245
+ title: SECTION_TITLES[location],
246
+ items: targets.filter((target) => target.location === location).map((target) => ({
247
+ key: target.path,
248
+ label: `${target.what}: ${short(target)}`,
249
+ value: target,
250
+ detail: target.path,
251
+ selected: true
252
+ }))
253
+ })).filter((section) => section.items.length > 0);
254
+ /**
255
+ * Ask a person which of the targets to delete, then whether to go ahead.
256
+ *
257
+ * @remarks
258
+ * Returns the targets to remove — empty when the person deselected everything
259
+ * or answered no. Both screens run only when {@link nukeHandler} has already
260
+ * established the run is interactive; the `NotInteractive` the kit could still
261
+ * report is mapped to the same refusal, so there is one answer for "nobody is
262
+ * there" however it is detected. A cancel (Esc, `q`, Ctrl-C) is the kit's
263
+ * `Cancelled`, left to propagate: `CliRuntime.main` renders it as one line and
264
+ * exits 130, which is not the same outcome as a deliberate "no".
265
+ */
266
+ const choose = (targets, short) => Effect.gen(function* () {
267
+ const chosen = yield* CliUi.run(MultiSelect.screen({
268
+ message: "Delete which files?",
269
+ sections: pickerSections(targets, short)
270
+ }));
271
+ if (chosen.length === 0) return [];
272
+ const { confirmed } = yield* CliUi.run(Confirm.screen({ message: `Delete ${plural(fileCount(chosen))}?` }));
273
+ return confirmed ? chosen : [];
274
+ }).pipe(Effect.catchTag("NotInteractive", () => Effect.fail(refusal())));
275
+ /**
276
+ * The invocation needed `--force`. A usage error, so exit 64.
277
+ *
278
+ * @remarks
279
+ * A non-interactive run — a pipe, CI, an agent — is refused rather than
280
+ * assumed: prompting there either hangs or reads whatever happens to be on
281
+ * stdin, and proceeding because nobody was there to answer is the one failure
282
+ * mode worth engineering against.
283
+ */
284
+ const refusal = () => new CliError.UserError({ cause: "Refusing: not an interactive terminal, and --force was not given." });
285
+ /**
62
286
  * `reposets nuke` — remove everything reposets has written locally.
63
287
  *
64
288
  * @remarks
@@ -67,10 +291,22 @@ const findTargets = (fs, path, appDirs, from) => Effect.gen(function* () {
67
291
  * files and the local database, which is what "leave no trace on this machine"
68
292
  * means.
69
293
  *
70
- * Without `--force` it lists what it found and asks. The prompt defaults to
71
- * **no**, and a non-interactive shell is refused rather than assumed: a
72
- * destructive command that proceeds because nobody was there to answer is the
73
- * one failure mode worth engineering against.
294
+ * It always prints what it found first, or "Nothing to remove" when it found
295
+ * nothing. Without `--force`, an interactive run then offers the targets as a
296
+ * pre-selected checklist grouped project / user / state / cache, and asks "Delete N files?" defaulting to **no**; deselecting
297
+ * everything or answering no deletes nothing and succeeds. A non-interactive
298
+ * run without `--force` is refused (exit 64) — see {@link refusal}. Whether the
299
+ * run may prompt is `CliInteractive`, decided once by `CliRuntime.main`'s
300
+ * environment (a human audience on a terminal), not by probing stdin here.
301
+ *
302
+ * Each removal is reported as it happens, a database's companion files each
303
+ * on their own line; one that fails is logged on stderr and the run exits 1
304
+ * after every target has been tried. Then the `reposets` directories under the
305
+ * XDG config, state, cache and data homes are removed if they are now empty —
306
+ * see {@link removeEmptyDirs}.
307
+ *
308
+ * This command never opens a database: the platform provides only the
309
+ * directories, so the files it deletes are not held open by its own process.
74
310
  *
75
311
  * @public
76
312
  */
@@ -79,39 +315,39 @@ const nukeHandler = (force) => Effect.gen(function* () {
79
315
  const path = yield* Path.Path;
80
316
  const appDirs = yield* AppDirs;
81
317
  const { cwd } = yield* Invocation;
82
- const targets = yield* findTargets(fs, path, appDirs, cwd);
83
- if (targets.length === 0) {
84
- yield* Console.log("Nothing to remove — no reposets files found on this machine.");
318
+ const found = yield* findTargets(fs, path, appDirs, cwd);
319
+ if (found.length === 0) {
320
+ yield* CliMessage.info("Nothing to remove — no reposets files found on this machine.");
85
321
  return;
86
322
  }
87
- yield* Console.log("This will delete:");
88
- for (const target of targets) {
89
- yield* Console.log(` ${target.path}`);
90
- yield* Console.log(` ${target.what} — loses ${target.cost}`);
91
- }
92
- yield* Console.log("");
93
- yield* Console.log("Nothing on GitHub is touched. Everything reposets applied stays applied.");
323
+ yield* Doc.print(targetsDoc(found));
324
+ let targets = found;
94
325
  if (!force) {
95
- if (!(yield* (yield* Stdio.Stdio).stdinIsTerminal)) return yield* Effect.fail(new CliError.UserError({ cause: "Refusing: not an interactive terminal, and --force was not given." }));
96
- yield* Console.log("");
97
- if (!(yield* Prompt.Confirm({ message: `Delete ${targets.length} file${targets.length === 1 ? "" : "s"}?` }).pipe(Effect.orElseSucceed(() => false)))) {
98
- yield* Console.log("Nothing was deleted.");
326
+ if (!(yield* CliInteractive)) return yield* Effect.fail(refusal());
327
+ const { home } = yield* Xdg;
328
+ targets = yield* choose(found, (target) => shortPath(path, cwd, home, target));
329
+ if (targets.length === 0) {
330
+ yield* CliMessage.info("Nothing was deleted.");
99
331
  return;
100
332
  }
101
333
  }
334
+ const total = fileCount(targets);
102
335
  let removed = 0;
103
- for (const target of targets) {
104
- const outcome = yield* fs.remove(target.path).pipe(Effect.result);
336
+ for (const target of targets) for (const file of target.paths) {
337
+ const outcome = yield* fs.remove(file).pipe(Effect.result);
105
338
  if (outcome._tag === "Failure") {
106
- yield* Effect.logError(` could not remove ${target.path} — ${String(outcome.failure)}`);
339
+ yield* Effect.logError(`could not remove ${file} — ${String(outcome.failure)}`);
107
340
  continue;
108
341
  }
342
+ yield* CliMessage.success(`removed ${file}`);
109
343
  removed += 1;
110
- yield* Console.log(` removed ${target.path}`);
111
344
  }
112
- yield* Console.log("");
113
- yield* Console.log(removed === targets.length ? `Done. ${removed} file${removed === 1 ? "" : "s"} removed.` : `Removed ${removed} of ${targets.length}; the rest are listed above.`);
114
- if (removed < targets.length) yield* CliExit.set(1);
345
+ yield* removeEmptyDirs(fs, appDirs);
346
+ if (removed === total) yield* CliMessage.success(`Done. ${plural(removed)} removed.`);
347
+ else {
348
+ yield* CliMessage.failure(`Removed ${removed} of ${plural(total)}; the failures are listed above.`);
349
+ yield* CliExit.set(1);
350
+ }
115
351
  });
116
352
  /**
117
353
  * The `nuke` command.