reposets 2.0.4 → 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 +8 -2
- 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/init.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { configSchemaHost, credentialsSchemaHost, schemaDirective } from "../../schemas/hosted.js";
|
|
4
|
+
import { Effect, FileSystem, Option, Path } from "effect";
|
|
5
|
+
import { CliExit, CliMessage } from "@effected/cli";
|
|
6
6
|
import { AppDirs } from "@effected/xdg";
|
|
7
|
+
import { Command, Flag } from "effect/cli";
|
|
8
|
+
import { CliUi, Select } from "@effected/cli/ui";
|
|
7
9
|
|
|
8
10
|
//#region src/cli/commands/init.ts
|
|
9
|
-
|
|
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
|
|
108
|
-
* with `--project`.
|
|
109
|
-
*
|
|
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
|
|
116
|
-
* A file that could not be written is reported on stderr
|
|
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
|
|
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*
|
|
183
|
+
yield* CliMessage.info(`Already exists: ${target}`);
|
|
138
184
|
return;
|
|
139
185
|
}
|
|
140
|
-
yield* (yield* fs.writeFileString(target, contents).pipe(Effect.option))._tag === "Some" ?
|
|
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" ?
|
|
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" ?
|
|
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
|
|
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 };
|
package/cli/commands/list.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { profileOwner } from "../../schemas/credentials.js";
|
|
2
2
|
import { ReposetsConfigFile, ReposetsCredentialsFile } from "../../services/ConfigFiles.js";
|
|
3
|
-
import {
|
|
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*
|
|
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
|
|
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
|
-
|
|
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)
|
|
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)
|
|
65
|
+
if (secrets.length > 0) assignments.push(`secrets: ${secrets.join(", ")}`);
|
|
59
66
|
const variables = scopeParts(group.variables);
|
|
60
|
-
if (variables.length > 0)
|
|
61
|
-
|
|
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`.
|
package/cli/commands/nuke.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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 { AppDirs, Xdg } from "@effected/xdg";
|
|
7
|
+
import { CliError, Command, Flag } from "effect/cli";
|
|
8
|
+
import { CliUi, Confirm, MultiSelect } from "@effected/cli/ui";
|
|
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
|
|
83
|
+
const files = [];
|
|
26
84
|
let dir = from;
|
|
27
85
|
for (;;) {
|
|
28
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
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
|
|
83
|
-
if (
|
|
84
|
-
yield*
|
|
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*
|
|
88
|
-
|
|
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*
|
|
96
|
-
yield*
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
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(`
|
|
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*
|
|
113
|
-
|
|
114
|
-
|
|
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.
|