@coffre/cli 0.1.13 → 0.1.15
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 +3 -0
- package/dist/{connect-BXDkiWOL.js → connect-k2qEwjqZ.js} +1 -1
- package/dist/database-url-CJ9yqoi_.js +213 -0
- package/dist/deployment-Di8BG9qY.js +1456 -0
- package/dist/init-2xGelVG1.js +71 -0
- package/dist/{keys-BCKUEb-Y.js → keys-DDdMwm0N.js} +2 -352
- package/dist/main.js +361 -202
- package/dist/migrate-ERNNgYO0.js +163 -0
- package/dist/{setup-BMmFAR3N.js → setup-Bs8q8Ily.js} +17 -1810
- package/dist/templates/node/README.md +4 -2
- package/dist/templates/node/package.json +4 -3
- package/dist/templates/workers/README.md +4 -2
- package/dist/templates/workers/package.json +5 -4
- package/dist/tty-BDa14JX2.js +283 -0
- package/dist/update-eYzLTNoL.js +334 -0
- package/dist/version-CPk6zTTK.js +317 -0
- package/package.json +5 -5
- package/dist/{connect-tekAM4hC.js → connect-C4wevMJ5.js} +34 -34
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { i as templateDir } from "./init-2xGelVG1.js";
|
|
2
|
+
import { a as openTerminal, c as release, d as style, i as listed, t as Cancelled } from "./tty-BDa14JX2.js";
|
|
3
|
+
import { n as StepFailed, r as Steps, t as cliVersion } from "./version-CPk6zTTK.js";
|
|
4
|
+
import { c as minimumReleaseAge, f as removeCleared, i as deploymentKind, l as pinPackageManager, n as bumpPins, o as excludeUntil, r as coffrePins, s as install, t as HeldBack } from "./deployment-Di8BG9qY.js";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
8
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join, sep } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
/**
|
|
12
|
+
* Every migration this version ships, oldest first, per engine: read from
|
|
13
|
+
* the journals at build time, so a Worker, which has no files, knows them
|
|
14
|
+
* too. A database that has applied fewer is behind this code, whatever
|
|
15
|
+
* `REQUIRED_MIGRATIONS` lets it run on.
|
|
16
|
+
*/
|
|
17
|
+
const KNOWN_MIGRATIONS = {
|
|
18
|
+
postgres: [{
|
|
19
|
+
"idx": 0,
|
|
20
|
+
"version": "7",
|
|
21
|
+
"when": 1790930587507,
|
|
22
|
+
"tag": "0000_baseline",
|
|
23
|
+
"breakpoints": true
|
|
24
|
+
}, {
|
|
25
|
+
"idx": 1,
|
|
26
|
+
"version": "7",
|
|
27
|
+
"when": 1791062292678,
|
|
28
|
+
"tag": "0001_remove_syncs",
|
|
29
|
+
"breakpoints": true
|
|
30
|
+
}].map((entry) => entry.tag),
|
|
31
|
+
sqlite: [{
|
|
32
|
+
"idx": 0,
|
|
33
|
+
"version": "6",
|
|
34
|
+
"when": 1790874462505,
|
|
35
|
+
"tag": "0000_baseline",
|
|
36
|
+
"breakpoints": true
|
|
37
|
+
}, {
|
|
38
|
+
"idx": 1,
|
|
39
|
+
"version": "6",
|
|
40
|
+
"when": 1791062293531,
|
|
41
|
+
"tag": "0001_remove_syncs",
|
|
42
|
+
"breakpoints": true
|
|
43
|
+
}].map((entry) => entry.tag)
|
|
44
|
+
};
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/update.ts
|
|
47
|
+
const PACKAGE = "@coffre/cli";
|
|
48
|
+
/**
|
|
49
|
+
* How the CLI at `path` was installed: under npx's cache, a global root
|
|
50
|
+
* (`npm root -g`, `pnpm root -g`), a project's node_modules, or a checkout.
|
|
51
|
+
*/
|
|
52
|
+
function installOf(path, roots) {
|
|
53
|
+
if (path.includes(`${sep}_npx${sep}`)) return { kind: "npx" };
|
|
54
|
+
if (roots.pnpm !== null && path.startsWith(dirname(roots.pnpm) + sep)) return { kind: "pnpm" };
|
|
55
|
+
if (roots.npm !== null && path.startsWith(roots.npm + sep)) return { kind: "npm" };
|
|
56
|
+
const modules = path.lastIndexOf(`${sep}node_modules${sep}`);
|
|
57
|
+
if (modules !== -1) return {
|
|
58
|
+
kind: "project",
|
|
59
|
+
dir: path.slice(0, modules)
|
|
60
|
+
};
|
|
61
|
+
if (path.includes(`${sep}packages${sep}cli${sep}`)) return { kind: "checkout" };
|
|
62
|
+
return { kind: "unknown" };
|
|
63
|
+
}
|
|
64
|
+
/** The migrations a deployment's installed coffre ships, through its server's own @coffre/db; null before an install. */
|
|
65
|
+
function deploymentMigrations(dir) {
|
|
66
|
+
try {
|
|
67
|
+
const server = createRequire(realpathSync(join(dir, "node_modules", "@coffre", "server", "package.json")));
|
|
68
|
+
return journalTags(join(dirname(server.resolve("@coffre/db/package.json")), "dist", "migrations", "postgres"));
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function journalTags(folder) {
|
|
74
|
+
return JSON.parse(readFileSync(join(folder, "meta", "_journal.json"), "utf8")).entries.map((entry) => entry.tag);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* What moving from version `from` to `to` asks of the database, said once it
|
|
78
|
+
* is installed: the migrations `after` has and `before` did not.
|
|
79
|
+
*/
|
|
80
|
+
function migrationsAdded(from, to, before, after) {
|
|
81
|
+
const added = after.filter((tag) => !before.includes(tag));
|
|
82
|
+
if (added.length === 0) return `coffre ${to} adds no migration to ${from}'s: deploying it is all.`;
|
|
83
|
+
return `coffre ${to} adds ${added.length === 1 ? "1 migration" : `${added.length} migrations`} to ${from}'s (${listed(added, "and")}): after deploying, run \`coffre migrate\`.`;
|
|
84
|
+
}
|
|
85
|
+
/** coffre's latest release, as the registry has it. */
|
|
86
|
+
async function latestVersion() {
|
|
87
|
+
const registry = (process.env.npm_config_registry ?? "https://registry.npmjs.org/").replace(/\/?$/, "/");
|
|
88
|
+
const response = await fetch(`${registry}${PACKAGE}/latest`, { headers: { accept: "application/json" } });
|
|
89
|
+
if (!response.ok) throw new Error(`${registry} answered ${response.status} for ${PACKAGE}`);
|
|
90
|
+
return (await response.json()).version;
|
|
91
|
+
}
|
|
92
|
+
function globalRoot(manager) {
|
|
93
|
+
const ran = spawnSync(manager, ["root", "-g"], {
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
timeout: 1e4
|
|
96
|
+
});
|
|
97
|
+
const root = ran.status === 0 ? ran.stdout.trim() : "";
|
|
98
|
+
if (root === "") return null;
|
|
99
|
+
try {
|
|
100
|
+
return realpathSync(root);
|
|
101
|
+
} catch {
|
|
102
|
+
return root;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** A command, its output kept to explain a failure. */
|
|
106
|
+
function command(name, args, cwd) {
|
|
107
|
+
return new Promise((resolve, reject) => {
|
|
108
|
+
const child = spawn(name, args, {
|
|
109
|
+
cwd,
|
|
110
|
+
stdio: [
|
|
111
|
+
"ignore",
|
|
112
|
+
"pipe",
|
|
113
|
+
"pipe"
|
|
114
|
+
],
|
|
115
|
+
env: {
|
|
116
|
+
...process.env,
|
|
117
|
+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
let output = "";
|
|
121
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => output += chunk);
|
|
122
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => output += chunk);
|
|
123
|
+
child.on("error", () => reject(/* @__PURE__ */ new Error(`${name} is not installed`)));
|
|
124
|
+
child.on("close", (code) => {
|
|
125
|
+
if (code === 0) return resolve();
|
|
126
|
+
if (/minimumReleaseAge|minimum-release-age/i.test(output)) return reject(/* @__PURE__ */ new Error(`${name} holds back releases newer than its minimumReleaseAge, and this one is new: wait, or allow @coffre/* (minimumReleaseAgeExclude), as a deployment coffre init writes does`));
|
|
127
|
+
reject(/* @__PURE__ */ new Error(`${name} ${args.join(" ")} failed: ${output.trim().split("\n").slice(-3).join(" ")}`));
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
async function update(args) {
|
|
132
|
+
let yes;
|
|
133
|
+
try {
|
|
134
|
+
({yes} = parseArgs({
|
|
135
|
+
args,
|
|
136
|
+
options: { yes: {
|
|
137
|
+
type: "boolean",
|
|
138
|
+
default: false
|
|
139
|
+
} },
|
|
140
|
+
strict: true
|
|
141
|
+
}).values);
|
|
142
|
+
} catch {
|
|
143
|
+
process.stderr.write("usage: coffre update [--yes]\n");
|
|
144
|
+
process.exit(2);
|
|
145
|
+
}
|
|
146
|
+
const terminal = openTerminal();
|
|
147
|
+
const out = terminal?.out ?? process.stderr;
|
|
148
|
+
const s = style(out);
|
|
149
|
+
const keys = () => terminal?.keys ?? null;
|
|
150
|
+
const ask = async (question, step) => {
|
|
151
|
+
if (yes) return true;
|
|
152
|
+
if (keys() === null) throw new Error("nothing here to confirm on: run coffre update on a terminal, or pass --yes");
|
|
153
|
+
return step.ask(question);
|
|
154
|
+
};
|
|
155
|
+
const current = cliVersion();
|
|
156
|
+
const self = realpathSync(fileURLToPath(import.meta.url));
|
|
157
|
+
const how = installOf(self, {
|
|
158
|
+
npm: globalRoot("npm"),
|
|
159
|
+
pnpm: globalRoot("pnpm")
|
|
160
|
+
});
|
|
161
|
+
const dir = process.cwd();
|
|
162
|
+
const kind = deploymentKind(dir);
|
|
163
|
+
const deployment = kind === "workers" || kind === "node" ? dir : null;
|
|
164
|
+
const pins = deployment === null ? {} : coffrePins(deployment);
|
|
165
|
+
const pinned = [...new Set(Object.values(pins))];
|
|
166
|
+
if (s.ansi) out.write(`\n ${s.bold("coffre update")} ${s.dim(`this CLI${deployment === null ? "" : ", and this deployment"}`)}\n\n`);
|
|
167
|
+
const steps = new Steps(out, [
|
|
168
|
+
"Look up coffre's latest release",
|
|
169
|
+
"Update this CLI",
|
|
170
|
+
...deployment === null ? [] : ["Update this deployment's coffre packages"]
|
|
171
|
+
], keys, (error) => error instanceof Error ? error.message : String(error));
|
|
172
|
+
let before = (deployment === null ? null : deploymentMigrations(deployment)) ?? KNOWN_MIGRATIONS.postgres;
|
|
173
|
+
const from = deployment !== null && pinned.length === 1 ? pinned[0] : current;
|
|
174
|
+
let after = null;
|
|
175
|
+
let latest = current;
|
|
176
|
+
try {
|
|
177
|
+
await steps.run(0, async () => {
|
|
178
|
+
latest = await latestVersion();
|
|
179
|
+
return `coffre ${latest} is the latest release; this CLI is ${current}`;
|
|
180
|
+
});
|
|
181
|
+
await steps.run(1, async (step) => {
|
|
182
|
+
if (current === latest) return `This CLI is up to date, at ${current}`;
|
|
183
|
+
switch (how.kind) {
|
|
184
|
+
case "npx": return {
|
|
185
|
+
text: "Nothing to update: npx runs the version it is given",
|
|
186
|
+
details: [`npx ${PACKAGE}@${latest} … runs ${latest}`]
|
|
187
|
+
};
|
|
188
|
+
case "checkout": return {
|
|
189
|
+
text: "Nothing to update here: this CLI runs from a checkout of coffre",
|
|
190
|
+
details: ["git pull, then pnpm install and pnpm build"]
|
|
191
|
+
};
|
|
192
|
+
case "project": return how.dir === deployment ? {
|
|
193
|
+
text: `This CLI is one of the deployment's packages, updated with them below`,
|
|
194
|
+
details: []
|
|
195
|
+
} : {
|
|
196
|
+
text: `Nothing updated: this CLI is a dependency of ${how.dir}`,
|
|
197
|
+
details: [`update it there: its @coffre/cli pin, to ${latest}`]
|
|
198
|
+
};
|
|
199
|
+
case "unknown": return {
|
|
200
|
+
text: "Nothing updated: where this CLI was installed from is unclear",
|
|
201
|
+
details: [`npm install -g ${PACKAGE}@${latest}, or pnpm add -g ${PACKAGE}@${latest}`]
|
|
202
|
+
};
|
|
203
|
+
default: {
|
|
204
|
+
const run = how.kind === "npm" ? ["npm", [
|
|
205
|
+
"install",
|
|
206
|
+
"-g",
|
|
207
|
+
`${PACKAGE}@${latest}`
|
|
208
|
+
]] : ["pnpm", [
|
|
209
|
+
"add",
|
|
210
|
+
"-g",
|
|
211
|
+
`${PACKAGE}@${latest}`
|
|
212
|
+
]];
|
|
213
|
+
if (!await ask(`Update this CLI from ${current} to ${latest}, with ${run[0]} ${run[1].join(" ")}?`, step)) return {
|
|
214
|
+
text: `This CLI stays at ${current}`,
|
|
215
|
+
details: []
|
|
216
|
+
};
|
|
217
|
+
step.note(`Updating this CLI, with ${run[0]}`);
|
|
218
|
+
await command(run[0], [...run[1]], dir);
|
|
219
|
+
after ??= journalTags(join(dirname(self), "migrations", "postgres"));
|
|
220
|
+
return `Updated this CLI from ${current} to ${latest}, with ${run[0]}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
if (deployment !== null) await steps.run(2, async (step) => {
|
|
225
|
+
const details = [];
|
|
226
|
+
const cleared = removeCleared(deployment, /* @__PURE__ */ new Date());
|
|
227
|
+
if (cleared.length > 0) details.push(`No longer excluded from minimumReleaseAge, now old enough: ${listed(cleared, "and")}`);
|
|
228
|
+
const pnpm = templatePackageManager(kind);
|
|
229
|
+
const repin = pnpm !== null && coffrePackageManager(deployment) !== pnpm;
|
|
230
|
+
const current = pinned.length === 1 && pinned[0] === latest;
|
|
231
|
+
if (current && !repin) return {
|
|
232
|
+
text: `This deployment's coffre packages are at ${latest} already`,
|
|
233
|
+
details
|
|
234
|
+
};
|
|
235
|
+
const was = pinned.length === 1 ? pinned[0] : listed(pinned, "and");
|
|
236
|
+
const on = pnpm?.replace("@", " ");
|
|
237
|
+
const question = current ? `Pin this deployment to ${on}, as coffre installs with, and install it?` : `Move this deployment from ${was} to ${latest}${repin ? `, on ${on},` : ""} and install it?`;
|
|
238
|
+
if (!await ask(question, step)) return {
|
|
239
|
+
text: `This deployment stays as it is, at ${was}`,
|
|
240
|
+
details
|
|
241
|
+
};
|
|
242
|
+
before = deploymentMigrations(deployment) ?? before;
|
|
243
|
+
const manifest = join(deployment, "package.json");
|
|
244
|
+
const saved = readFileSync(manifest, "utf8");
|
|
245
|
+
bumpPins(deployment, latest);
|
|
246
|
+
if (repin) {
|
|
247
|
+
const replaced = pinPackageManager(deployment, pnpm);
|
|
248
|
+
details.push(`Pinned ${pnpm}${replaced ? `, not ${replaced}` : ""}: every install, here, in CI and on Workers Builds, holds minimumReleaseAge alike`);
|
|
249
|
+
}
|
|
250
|
+
step.note(`Installing coffre ${latest}'s packages, with pnpm`);
|
|
251
|
+
try {
|
|
252
|
+
await install(deployment);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
if (!(error instanceof HeldBack)) throw error;
|
|
255
|
+
const age = minimumReleaseAge(deployment);
|
|
256
|
+
const clears = error.held.map(({ spec, publishedAt }) => ({
|
|
257
|
+
spec,
|
|
258
|
+
clears: new Date(publishedAt.getTime() + age * 6e4)
|
|
259
|
+
})).sort((x, y) => x.clears.getTime() - y.clears.getTime());
|
|
260
|
+
const last = clears.at(-1).clears;
|
|
261
|
+
const lines = clears.map(({ spec, clears: at }) => `${spec}, old enough from ${when(at)}`);
|
|
262
|
+
const held = `pnpm holds back ${clears.length === 1 ? "1 package" : `${clears.length} packages`} younger than this deployment's minimumReleaseAge, ${days(age)}`;
|
|
263
|
+
if (yes || keys() === null) {
|
|
264
|
+
writeFileSync(manifest, saved);
|
|
265
|
+
throw new Error(`${held}: ${lines.join("; ")}. Run coffre update after ${when(last)}, or on a terminal to exclude them until then`);
|
|
266
|
+
}
|
|
267
|
+
step.under(lines);
|
|
268
|
+
const choice = await step.choose(held, [`Wait: stay at ${was}, and run coffre update again after ${when(last)}`, `Proceed: let ${clears.length === 1 ? "it" : "these"} through until each is old enough, named in pnpm-workspace.yaml`]);
|
|
269
|
+
step.under([]);
|
|
270
|
+
if (choice === 0) {
|
|
271
|
+
writeFileSync(manifest, saved);
|
|
272
|
+
return {
|
|
273
|
+
text: `This deployment stays at ${was}, until ${when(last)}`,
|
|
274
|
+
details: [
|
|
275
|
+
...details,
|
|
276
|
+
...lines,
|
|
277
|
+
`Run coffre update again after ${when(last)}`
|
|
278
|
+
]
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
excludeUntil(deployment, clears, `for coffre ${latest}`);
|
|
282
|
+
step.note(`Installing coffre ${latest}'s packages, with pnpm`);
|
|
283
|
+
await install(deployment);
|
|
284
|
+
details.push("Let through until each is old enough, named in pnpm-workspace.yaml:", ...clears.map(({ spec, clears: at }) => ` ${spec}, until ${when(at)}`), `The first coffre update after ${when(last)} removes them`);
|
|
285
|
+
}
|
|
286
|
+
after = deploymentMigrations(deployment) ?? after;
|
|
287
|
+
return {
|
|
288
|
+
text: current ? `Pinned ${pnpm}, and installed it` : `Moved this deployment from ${was} to ${latest}, and installed it`,
|
|
289
|
+
details
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (error instanceof Cancelled) finish(out, `${s.red("✗")} cancelled; nothing after the last step done was changed`, 130);
|
|
294
|
+
if (error instanceof StepFailed) finish(out, "", 1);
|
|
295
|
+
finish(out, `${s.red("✗")} ${error instanceof Error ? error.message : String(error)}`, 1);
|
|
296
|
+
} finally {
|
|
297
|
+
steps.end();
|
|
298
|
+
if (terminal !== null) release(terminal.keys);
|
|
299
|
+
}
|
|
300
|
+
out.write("\n");
|
|
301
|
+
if (after !== null) {
|
|
302
|
+
out.write(` ${migrationsAdded(from, latest, before, after)}\n`);
|
|
303
|
+
if (deployment !== null) {
|
|
304
|
+
const deploy = kind === "workers" ? "`pnpm run deploy`, or a push for Workers Builds" : "restarting the server";
|
|
305
|
+
out.write(` ${s.dim(`Deploy it as you do: ${deploy}.`)}\n`);
|
|
306
|
+
}
|
|
307
|
+
out.write("\n");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/** The pnpm coffre installs with, as the template of a deployment of `kind` pins it. */
|
|
311
|
+
function templatePackageManager(kind) {
|
|
312
|
+
try {
|
|
313
|
+
return JSON.parse(readFileSync(join(templateDir(kind), "package.json"), "utf8")).packageManager ?? null;
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function coffrePackageManager(dir) {
|
|
319
|
+
return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).packageManager;
|
|
320
|
+
}
|
|
321
|
+
/** A moment, to the minute, in UTC: when a package is old enough. */
|
|
322
|
+
function when(date) {
|
|
323
|
+
return `${date.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
|
324
|
+
}
|
|
325
|
+
function days(minutes) {
|
|
326
|
+
const d = minutes / 1440;
|
|
327
|
+
return Number.isInteger(d) ? `${d} day${d === 1 ? "" : "s"}` : `${minutes} minutes`;
|
|
328
|
+
}
|
|
329
|
+
function finish(out, message, code) {
|
|
330
|
+
if (message !== "") out.write(`${message}\n`);
|
|
331
|
+
process.exit(code);
|
|
332
|
+
}
|
|
333
|
+
//#endregion
|
|
334
|
+
export { update };
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { d as style, g as yes, h as wrap, m as width, n as edit, p as truncate, s as readKeys, t as Cancelled } from "./tty-BDa14JX2.js";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
//#region src/steps.ts
|
|
4
|
+
const SPINNER = [
|
|
5
|
+
"⠋",
|
|
6
|
+
"⠙",
|
|
7
|
+
"⠹",
|
|
8
|
+
"⠸",
|
|
9
|
+
"⠼",
|
|
10
|
+
"⠴",
|
|
11
|
+
"⠦",
|
|
12
|
+
"⠧",
|
|
13
|
+
"⠇",
|
|
14
|
+
"⠏"
|
|
15
|
+
];
|
|
16
|
+
const FRAME_MS = 80;
|
|
17
|
+
/** A step failed, and its line says why: nothing more to show. */
|
|
18
|
+
var StepFailed = class extends Error {};
|
|
19
|
+
var Steps = class {
|
|
20
|
+
#out;
|
|
21
|
+
#s;
|
|
22
|
+
#steps;
|
|
23
|
+
#keys;
|
|
24
|
+
#describe;
|
|
25
|
+
#frame = 0;
|
|
26
|
+
#drawn = 0;
|
|
27
|
+
/** The first step still redrawn: those above it have settled, and been printed for good. */
|
|
28
|
+
#from = 0;
|
|
29
|
+
#timer = null;
|
|
30
|
+
/** Whether the failed step has been printed, where nothing is redrawn. */
|
|
31
|
+
#reported = false;
|
|
32
|
+
#restoreCursor = () => this.#out.write("\x1B[?25h");
|
|
33
|
+
/** Ctrl-C while a step runs, the terminal in its usual mode: the cursor back, then out, with nothing more to show. */
|
|
34
|
+
#interrupt = () => {
|
|
35
|
+
this.#stop();
|
|
36
|
+
this.end();
|
|
37
|
+
this.#out.write("\n");
|
|
38
|
+
process.exit(130);
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* `titles`, what each step does while it runs; `keys`, the keyboard for
|
|
42
|
+
* questions; `describe`, an error as it may be shown, a secret it quotes
|
|
43
|
+
* taken out.
|
|
44
|
+
*/
|
|
45
|
+
constructor(out, titles, keys, describe) {
|
|
46
|
+
this.#out = out;
|
|
47
|
+
this.#s = style(out);
|
|
48
|
+
this.#keys = keys;
|
|
49
|
+
this.#describe = describe;
|
|
50
|
+
this.#steps = titles.map((text) => ({
|
|
51
|
+
text,
|
|
52
|
+
status: "pending",
|
|
53
|
+
details: [],
|
|
54
|
+
under: [],
|
|
55
|
+
input: null,
|
|
56
|
+
choice: null
|
|
57
|
+
}));
|
|
58
|
+
if (this.#s.ansi) {
|
|
59
|
+
out.write("\x1B[?25l");
|
|
60
|
+
process.once("exit", this.#restoreCursor);
|
|
61
|
+
process.on("SIGINT", this.#interrupt);
|
|
62
|
+
this.#draw();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Run step `i`: its line is the outcome `work` returns, or the error it
|
|
67
|
+
* throws, which comes out as StepFailed, unless it is Cancelled.
|
|
68
|
+
*/
|
|
69
|
+
async run(i, work) {
|
|
70
|
+
const state = this.#steps[i];
|
|
71
|
+
state.status = "running";
|
|
72
|
+
this.#tick();
|
|
73
|
+
const step = {
|
|
74
|
+
note: (text) => {
|
|
75
|
+
state.text = text;
|
|
76
|
+
this.#draw();
|
|
77
|
+
},
|
|
78
|
+
under: (lines) => {
|
|
79
|
+
state.under = lines;
|
|
80
|
+
this.#draw();
|
|
81
|
+
},
|
|
82
|
+
ask: (question) => this.#ask(state, question),
|
|
83
|
+
choose: (question, options) => this.#choose(state, question, options),
|
|
84
|
+
paste: (prompt, accept, signal) => this.#paste(state, prompt, accept, signal)
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
const outcome = await work(step);
|
|
88
|
+
Object.assign(state, typeof outcome === "string" ? {
|
|
89
|
+
text: outcome,
|
|
90
|
+
details: []
|
|
91
|
+
} : outcome, {
|
|
92
|
+
status: "done",
|
|
93
|
+
under: [],
|
|
94
|
+
input: null
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
Object.assign(state, {
|
|
98
|
+
under: [],
|
|
99
|
+
input: null,
|
|
100
|
+
choice: null
|
|
101
|
+
});
|
|
102
|
+
state.status = "failed";
|
|
103
|
+
state.details = error instanceof Cancelled ? [] : [this.#describe(error)];
|
|
104
|
+
if (error instanceof Cancelled) state.text = `${state.text}: cancelled`;
|
|
105
|
+
this.#stop();
|
|
106
|
+
throw error instanceof Cancelled ? error : new StepFailed(state.details[0]);
|
|
107
|
+
}
|
|
108
|
+
this.#stop();
|
|
109
|
+
if (!this.#s.ansi) this.#out.write(`✓ ${state.text}\n${state.details.map((line) => ` ${line}\n`).join("")}`);
|
|
110
|
+
else if (this.#steps.every((each) => each.status === "done")) this.end();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Lines that stay, such as an address to open, printed whole, so that a
|
|
114
|
+
* terminal can wrap them, and they can be copied or clicked. They go
|
|
115
|
+
* under the steps done, above the one running: those done are printed
|
|
116
|
+
* for good first, and only the rest is redrawn from then on.
|
|
117
|
+
*/
|
|
118
|
+
print(text) {
|
|
119
|
+
if (!this.#s.ansi) {
|
|
120
|
+
this.#out.write(`${text}\n`);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const running = this.#steps.findIndex((each, i) => i >= this.#from && each.status !== "done");
|
|
124
|
+
const settled = this.#lines(this.#from, running === -1 ? this.#steps.length : running);
|
|
125
|
+
this.#from = running === -1 ? this.#steps.length : running;
|
|
126
|
+
const up = this.#drawn > 0 ? `\x1b[${this.#drawn}F` : "";
|
|
127
|
+
this.#out.write(`${up}\x1b[J${settled.map((line) => `${line}\n`).join("")}${text}\n`);
|
|
128
|
+
this.#drawn = 0;
|
|
129
|
+
this.#draw();
|
|
130
|
+
}
|
|
131
|
+
/** A remark that stays, dim, wrapped to the terminal's width. */
|
|
132
|
+
aside(text) {
|
|
133
|
+
this.print(this.#s.dim(wrap(text, Math.max(20, (this.#out.columns ?? 80) - 3)).map((line) => ` ${line}`).join("\n")));
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* An address to open, after what it is for: on one line when both fit;
|
|
137
|
+
* otherwise the address on a line of its own, from the first column, so
|
|
138
|
+
* that the terminal wraps it in one piece, to copy whole.
|
|
139
|
+
*/
|
|
140
|
+
link(label, address) {
|
|
141
|
+
const fits = width(` ${label} ${address}`) < (this.#out.columns ?? 80);
|
|
142
|
+
this.print(fits ? ` ${this.#s.dim(label)} ${address}` : ` ${this.#s.dim(label)}\n${address}`);
|
|
143
|
+
}
|
|
144
|
+
/** Leave the list as it stands, the cursor below it. */
|
|
145
|
+
end() {
|
|
146
|
+
this.#stop();
|
|
147
|
+
this.#release();
|
|
148
|
+
}
|
|
149
|
+
/** The cursor back, and Ctrl-C the terminal's again. */
|
|
150
|
+
#release() {
|
|
151
|
+
if (this.#s.ansi) {
|
|
152
|
+
this.#restoreCursor();
|
|
153
|
+
process.off("exit", this.#restoreCursor);
|
|
154
|
+
process.off("SIGINT", this.#interrupt);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async #ask(state, question) {
|
|
158
|
+
const keys = this.#keys();
|
|
159
|
+
if (keys === null) return false;
|
|
160
|
+
this.#stop();
|
|
161
|
+
const before = state.text;
|
|
162
|
+
state.text = `${question} ${this.#s.dim("y/N")}`;
|
|
163
|
+
state.status = "asking";
|
|
164
|
+
this.#draw();
|
|
165
|
+
try {
|
|
166
|
+
return await yes(keys);
|
|
167
|
+
} finally {
|
|
168
|
+
state.text = before;
|
|
169
|
+
state.status = "running";
|
|
170
|
+
this.#tick();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async #choose(state, question, options) {
|
|
174
|
+
const keys = this.#keys();
|
|
175
|
+
if (keys === null) return 0;
|
|
176
|
+
this.#stop();
|
|
177
|
+
const before = state.text;
|
|
178
|
+
Object.assign(state, {
|
|
179
|
+
text: `${question} ${this.#s.dim("↑↓ then Enter")}`,
|
|
180
|
+
status: "asking",
|
|
181
|
+
choice: {
|
|
182
|
+
options,
|
|
183
|
+
at: 0
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
let cancelled = false;
|
|
187
|
+
try {
|
|
188
|
+
const chosen = readKeys(keys, (key) => {
|
|
189
|
+
const choice = state.choice;
|
|
190
|
+
if (key.ctrl && key.name === "c") cancelled = true;
|
|
191
|
+
else if (key.name === "return" || key.name === "enter") return true;
|
|
192
|
+
else if (key.name === "up" || key.name === "k") choice.at = (choice.at - 1 + options.length) % options.length;
|
|
193
|
+
else if (key.name === "down" || key.name === "j" || key.name === "tab") choice.at = (choice.at + 1) % options.length;
|
|
194
|
+
if (cancelled) return true;
|
|
195
|
+
this.#draw();
|
|
196
|
+
return false;
|
|
197
|
+
});
|
|
198
|
+
this.#draw();
|
|
199
|
+
await chosen;
|
|
200
|
+
if (cancelled) throw new Cancelled();
|
|
201
|
+
return state.choice.at;
|
|
202
|
+
} finally {
|
|
203
|
+
Object.assign(state, {
|
|
204
|
+
text: before,
|
|
205
|
+
status: "running",
|
|
206
|
+
choice: null
|
|
207
|
+
});
|
|
208
|
+
this.#tick();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async #paste(state, prompt, accept, signal) {
|
|
212
|
+
const keys = this.#keys();
|
|
213
|
+
if (keys === null) {
|
|
214
|
+
await new Promise((resolve) => signal.aborted ? resolve() : signal.addEventListener("abort", () => resolve()));
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
state.input = {
|
|
218
|
+
prompt,
|
|
219
|
+
typed: "",
|
|
220
|
+
error: null
|
|
221
|
+
};
|
|
222
|
+
let taken = null;
|
|
223
|
+
let cancelled = false;
|
|
224
|
+
try {
|
|
225
|
+
const pasted = readKeys(keys, async (key, sequence) => {
|
|
226
|
+
const input = state.input;
|
|
227
|
+
const next = edit(input.typed, key, sequence);
|
|
228
|
+
if (next === "cancel") cancelled = true;
|
|
229
|
+
else if (next === "submit" && input.typed.trim() !== "") {
|
|
230
|
+
const text = input.typed.trim();
|
|
231
|
+
input.typed = "";
|
|
232
|
+
input.error = await accept(text);
|
|
233
|
+
if (input.error === null) taken = text;
|
|
234
|
+
} else if (next !== "submit") {
|
|
235
|
+
input.typed = next;
|
|
236
|
+
input.error = null;
|
|
237
|
+
}
|
|
238
|
+
this.#draw();
|
|
239
|
+
return cancelled || taken !== null;
|
|
240
|
+
}, signal);
|
|
241
|
+
this.#draw();
|
|
242
|
+
await pasted;
|
|
243
|
+
} finally {
|
|
244
|
+
state.input = null;
|
|
245
|
+
this.#draw();
|
|
246
|
+
}
|
|
247
|
+
if (cancelled) throw new Cancelled();
|
|
248
|
+
return taken;
|
|
249
|
+
}
|
|
250
|
+
#tick() {
|
|
251
|
+
this.#draw();
|
|
252
|
+
if (this.#s.ansi && this.#timer === null) this.#timer = setInterval(() => {
|
|
253
|
+
this.#frame = (this.#frame + 1) % SPINNER.length;
|
|
254
|
+
this.#draw();
|
|
255
|
+
}, FRAME_MS).unref();
|
|
256
|
+
}
|
|
257
|
+
#stop() {
|
|
258
|
+
if (this.#timer !== null) clearInterval(this.#timer);
|
|
259
|
+
this.#timer = null;
|
|
260
|
+
this.#draw();
|
|
261
|
+
if (!this.#s.ansi) {
|
|
262
|
+
const failed = this.#steps.find((each) => each.status === "failed");
|
|
263
|
+
if (failed !== void 0 && !this.#reported) {
|
|
264
|
+
this.#reported = true;
|
|
265
|
+
this.#out.write(`✗ ${failed.text}\n${failed.details.map((line) => ` ${line}\n`).join("")}`);
|
|
266
|
+
}
|
|
267
|
+
} else if (this.#steps.some((each) => each.status === "failed")) this.#release();
|
|
268
|
+
}
|
|
269
|
+
#draw() {
|
|
270
|
+
if (!this.#s.ansi) return;
|
|
271
|
+
const lines = this.#lines(this.#from, this.#steps.length);
|
|
272
|
+
const up = this.#drawn > 0 ? `\x1b[${this.#drawn}F` : "";
|
|
273
|
+
this.#out.write(`${up}${lines.map((line) => `${line}\x1b[K\n`).join("")}\x1b[J`);
|
|
274
|
+
this.#drawn = lines.length;
|
|
275
|
+
}
|
|
276
|
+
/** Steps `from` to `to`, as lines cut to the terminal's width. */
|
|
277
|
+
#lines(from, to) {
|
|
278
|
+
const s = this.#s;
|
|
279
|
+
const columns = Math.max(20, (this.#out.columns ?? 80) - 1);
|
|
280
|
+
return this.#steps.slice(from, to).flatMap(({ text, status, details, under, input, choice }) => {
|
|
281
|
+
const icon = {
|
|
282
|
+
pending: s.dim("·"),
|
|
283
|
+
running: s.accent(SPINNER[this.#frame]),
|
|
284
|
+
asking: s.accent("?"),
|
|
285
|
+
done: s.green("✓"),
|
|
286
|
+
failed: s.red("✗")
|
|
287
|
+
}[status];
|
|
288
|
+
const line = status === "pending" ? s.dim(text) : status === "failed" ? s.red(text) : text;
|
|
289
|
+
const below = status === "failed" ? details.flatMap((detail) => wrap(detail, columns - 4)) : details.map(s.dim);
|
|
290
|
+
const live = [
|
|
291
|
+
...under.map((text) => ` ${s.dim(text)}`),
|
|
292
|
+
...choice === null ? [] : choice.options.map((option, i) => i === choice.at ? ` ${s.accent("›")} ${s.accent(option)}` : ` ${option}`),
|
|
293
|
+
...input === null ? [] : [
|
|
294
|
+
` ${input.prompt}`,
|
|
295
|
+
` ${s.accent("›")} ${"•".repeat(input.typed.length)}`,
|
|
296
|
+
...input.error === null ? [] : [` ${s.red(input.error)}`]
|
|
297
|
+
]
|
|
298
|
+
];
|
|
299
|
+
return [
|
|
300
|
+
` ${icon} ${line}`,
|
|
301
|
+
...below.map((detail) => ` ${detail}`),
|
|
302
|
+
...live
|
|
303
|
+
];
|
|
304
|
+
}).map((line) => truncate(line, columns));
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/version.ts
|
|
309
|
+
/**
|
|
310
|
+
* This CLI's version: the schema it migrates to, the version of coffre a
|
|
311
|
+
* deployment it makes runs, and the one `coffre migrate` expects to find.
|
|
312
|
+
*/
|
|
313
|
+
function cliVersion() {
|
|
314
|
+
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
315
|
+
}
|
|
316
|
+
//#endregion
|
|
317
|
+
export { StepFailed as n, Steps as r, cliVersion as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coffre/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "The coffre command line: secrets in your shell, and `coffre init` for a new deployment.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"type": "module",
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=20"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist"
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
"pg": "8.16.3",
|
|
29
29
|
"tsdown": "0.23.0",
|
|
30
30
|
"typescript": "5.9.3",
|
|
31
|
-
"@coffre/
|
|
32
|
-
"@coffre/
|
|
33
|
-
"@coffre/client": "0.1.
|
|
31
|
+
"@coffre/core": "0.1.15",
|
|
32
|
+
"@coffre/db": "0.1.15",
|
|
33
|
+
"@coffre/client": "0.1.15"
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"build": "tsdown && node scripts/copy-templates.ts && node scripts/copy-migrations.ts",
|