@coffre/cli 0.1.13 → 0.1.14

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.
@@ -62,8 +62,10 @@ With AWS KMS instead of a key of your own, the vault also needs a
62
62
  To do the same by hand, see
63
63
  [deploy.md](https://github.com/erwinkn/coffre/blob/main/docs/deploy.md#appendix-the-database-by-hand).
64
64
 
65
- Run `pnpm migrate`, with the administrator's URL in `DATABASE_URL`, after
66
- every package upgrade, before starting either process.
65
+ To upgrade: `coffre update` here, restart both processes, then `coffre migrate`
66
+ ([upgrading](https://github.com/erwinkn/coffre/blob/main/docs/deploy.md#upgrading)).
67
+ For automation, `pnpm migrate`, with the administrator's URL in
68
+ `DATABASE_URL`, does the last step.
67
69
 
68
70
  For tests and local development only, both URLs may instead name the same
69
71
  absolute SQLite file, e.g. `file:/tmp/coffre-local.db`; migrate that URL once
@@ -13,11 +13,11 @@
13
13
  "conformance": "coffre-conformance node"
14
14
  },
15
15
  "dependencies": {
16
- "@coffre/server": "0.1.13",
17
- "@coffre/vault": "0.1.13"
16
+ "@coffre/server": "0.1.14",
17
+ "@coffre/vault": "0.1.14"
18
18
  },
19
19
  "devDependencies": {
20
- "@coffre/conformance": "0.1.13",
20
+ "@coffre/conformance": "0.1.14",
21
21
  "@types/node": "26.1.1",
22
22
  "typescript": "5.9.3"
23
23
  }
@@ -76,8 +76,10 @@ pnpm exec wrangler secret put APP_KEY -c app/wrangler.jsonc
76
76
  pnpm exec wrangler secret put GITHUB_CLIENT_SECRET -c app/wrangler.jsonc
77
77
  ```
78
78
 
79
- Run `pnpm migrate`, with the administrator's URL in `DATABASE_URL`, after
80
- every upgrade of `@coffre/server`, before deploying it.
79
+ To upgrade: `coffre update` here, deploy, then `coffre migrate`
80
+ ([upgrading](https://github.com/erwinkn/coffre/blob/main/docs/deploy.md#upgrading)).
81
+ For automation, `pnpm migrate`, with the administrator's URL in
82
+ `DATABASE_URL`, does the last step.
81
83
 
82
84
  Keep older vault keys after a rotation, for good: what they wrapped still
83
85
  needs them, and so does what the vault signed under them before it. With AWS KMS
@@ -14,13 +14,13 @@
14
14
  "conformance": "coffre-conformance workers"
15
15
  },
16
16
  "dependencies": {
17
- "@coffre/server": "0.1.13",
18
- "@coffre/ui": "0.1.13",
19
- "@coffre/vault": "0.1.13"
17
+ "@coffre/server": "0.1.14",
18
+ "@coffre/ui": "0.1.14",
19
+ "@coffre/vault": "0.1.14"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@cloudflare/workers-types": "5.20260920.1",
23
- "@coffre/conformance": "0.1.13",
23
+ "@coffre/conformance": "0.1.14",
24
24
  "typescript": "5.9.3",
25
25
  "wrangler": "4.118.0"
26
26
  }
@@ -0,0 +1,283 @@
1
+ import { openSync } from "node:fs";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ import tty from "node:tty";
4
+ //#region src/tty.ts
5
+ const plain = (text) => text;
6
+ /**
7
+ * The web UI's accent, green and red, at the depth the terminal takes:
8
+ * 24-bit, 256 colours or the 16 named ones. NO_COLOR (and FORCE_COLOR) are
9
+ * the stream's own `hasColors`; bold and dim stay, being no colour.
10
+ */
11
+ function style(stream) {
12
+ if (stream.isTTY !== true) return {
13
+ ansi: false,
14
+ bold: plain,
15
+ dim: plain,
16
+ accent: plain,
17
+ green: plain,
18
+ red: plain
19
+ };
20
+ const terminal = stream;
21
+ const depth = typeof terminal.hasColors === "function" && terminal.hasColors() ? terminal.getColorDepth() : 1;
22
+ const sgr = (open, close) => (text) => `\x1b[${open}m${text}\x1b[${close}m`;
23
+ const colour = (rgb, index256, index16) => depth >= 24 ? sgr(`38;2;${rgb.join(";")}`, "39") : depth >= 8 ? sgr(`38;5;${index256}`, "39") : depth >= 4 ? sgr(String(index16), "39") : plain;
24
+ return {
25
+ ansi: true,
26
+ bold: sgr("1", "22"),
27
+ dim: sgr("2", "22"),
28
+ accent: colour([
29
+ 91,
30
+ 141,
31
+ 239
32
+ ], 69, 94),
33
+ green: colour([
34
+ 63,
35
+ 174,
36
+ 111
37
+ ], 71, 32),
38
+ red: colour([
39
+ 229,
40
+ 83,
41
+ 75
42
+ ], 167, 31)
43
+ };
44
+ }
45
+ const ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
46
+ /** Columns `text` takes on screen: its characters, less its escape codes. */
47
+ function width(text) {
48
+ return [...text.replace(ESCAPE, "")].length;
49
+ }
50
+ /** `text` cut to `columns` on screen, escape codes kept, an ellipsis where it was cut. */
51
+ function truncate(text, columns) {
52
+ if (width(text) <= columns) return text;
53
+ let out = "";
54
+ let used = 0;
55
+ for (let i = 0; i < text.length;) {
56
+ ESCAPE.lastIndex = i;
57
+ const code = text[i] === "\x1B" ? ESCAPE.exec(text) : null;
58
+ if (code !== null && code.index === i) {
59
+ out += code[0];
60
+ i += code[0].length;
61
+ continue;
62
+ }
63
+ const char = String.fromCodePoint(text.codePointAt(i));
64
+ if (used === columns - 1) return `${out}…${text.includes("\x1B") ? "\x1B[0m" : ""}`;
65
+ out += char;
66
+ used += 1;
67
+ i += char.length;
68
+ }
69
+ return out;
70
+ }
71
+ /** Plain `text` in lines of at most `columns`, broken at spaces, or anywhere in a word longer than a line. */
72
+ function wrap(text, columns) {
73
+ const lines = [];
74
+ let line = "";
75
+ for (const word of text.split(" ")) {
76
+ let rest = word;
77
+ while (width(rest) > columns) {
78
+ if (line !== "") lines.push(line);
79
+ line = "";
80
+ lines.push([...rest].slice(0, columns).join(""));
81
+ rest = [...rest].slice(columns).join("");
82
+ }
83
+ if (line === "") line = rest;
84
+ else if (width(line) + 1 + width(rest) <= columns) line += ` ${rest}`;
85
+ else {
86
+ lines.push(line);
87
+ line = rest;
88
+ }
89
+ }
90
+ return [...lines, line];
91
+ }
92
+ /** `text` wrapped to the stream's width, each line indented by `indent` spaces. */
93
+ function paragraph(out, text, indent = 2) {
94
+ const pad = " ".repeat(indent);
95
+ return wrap(text, Math.max(20, (out.columns || 80) - indent - 1)).map((line) => `${pad}${line}`).join("\n");
96
+ }
97
+ /** "a, b or c". */
98
+ function listed(items, last) {
99
+ return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} ${last} ${items.at(-1)}`;
100
+ }
101
+ /** A labelled row: `label` in a column of `column`, `text` wrapped beside it with a hanging indent. */
102
+ function row(out, s, label, text, indent = 4, column = 10) {
103
+ const lines = wrap(text, Math.max(20, (out.columns || 80) - indent - column - 1));
104
+ const pad = " ".repeat(indent);
105
+ return lines.map((line, i) => `${pad}${i === 0 ? s.dim(label.padEnd(column)) : " ".repeat(column)}${line}`).join("\n");
106
+ }
107
+ /**
108
+ * The keyboard: stdin when it is a terminal, otherwise the terminal itself,
109
+ * as `less` reads it when its input is piped. Null without one.
110
+ */
111
+ function keyboard() {
112
+ if (process.stdin.isTTY) return process.stdin;
113
+ try {
114
+ return new tty.ReadStream(openSync("/dev/tty", "r"));
115
+ } catch {
116
+ return null;
117
+ }
118
+ }
119
+ /** Where a screen is drawn and read from: stdout, and the keyboard. Null unless both are a terminal. */
120
+ function openTerminal() {
121
+ if (!process.stdout.isTTY) return null;
122
+ const keys = keyboard();
123
+ return keys === null ? null : {
124
+ keys,
125
+ out: process.stdout
126
+ };
127
+ }
128
+ /** Release a keyboard `keyboard()` opened: stdin stays, /dev/tty closes. */
129
+ function release(keys) {
130
+ keys.setRawMode?.(false);
131
+ keys.pause();
132
+ if (keys !== process.stdin) keys.destroy?.();
133
+ }
134
+ /**
135
+ * Every key pressed, in raw mode, until `until` returns true, or `signal`
136
+ * aborts the reading. Raw mode is on when this returns: a prompt shown after
137
+ * it gets every key, none echoed or edited by the terminal first.
138
+ */
139
+ function readKeys(keys, until, signal) {
140
+ emitKeypressEvents(keys);
141
+ keys.setRawMode?.(true);
142
+ keys.resume();
143
+ return new Promise((resolve, reject) => {
144
+ let busy = Promise.resolve();
145
+ const stop = () => {
146
+ keys.off("keypress", onKey);
147
+ signal?.removeEventListener("abort", stop);
148
+ keys.setRawMode?.(false);
149
+ keys.pause();
150
+ resolve();
151
+ };
152
+ const onKey = (sequence, key) => {
153
+ busy = busy.then(async () => {
154
+ if (await until(key ?? { sequence }, sequence ?? key?.sequence ?? "")) stop();
155
+ }).catch(reject);
156
+ };
157
+ keys.on("keypress", onKey);
158
+ if (signal?.aborted) stop();
159
+ else signal?.addEventListener("abort", stop);
160
+ });
161
+ }
162
+ /** What a typed key does to a line: its new text, or `submit`, or `cancel`. */
163
+ function edit(text, key, sequence) {
164
+ if (key.ctrl && key.name === "c") return "cancel";
165
+ if (key.name === "return" || key.name === "enter") return "submit";
166
+ if (key.name === "backspace") return text.slice(0, -1);
167
+ if (key.ctrl && key.name === "u") return "";
168
+ if (!key.ctrl && !key.meta && sequence.length > 0 && !sequence.startsWith("\x1B") && sequence >= " ") return text + sequence;
169
+ return text;
170
+ }
171
+ /**
172
+ * A question answered by typing, shown as typed, `initial` to start from;
173
+ * `note`, dim under it while it is asked; `check` says why an answer will
174
+ * not do, under it. Once given, the question becomes one line: what was
175
+ * asked, and the answer. Ctrl-C cancels.
176
+ */
177
+ async function textLine(keys, out, s, question, hint, options = {}) {
178
+ const columns = Math.max(20, (out.columns || 80) - 1);
179
+ let typed = options.initial ?? "";
180
+ let error = null;
181
+ const draw = () => {
182
+ const below = [...error === null ? [] : [s.red(error)], ...options.note === void 0 ? [] : wrap(options.note, columns - 4).map(s.dim)].map((line) => `\n${truncate(` ${line}`, columns)}`);
183
+ out.write(`\r\x1b[J${below.join("")}${below.length > 0 ? `\x1b[${below.length}A` : ""}\r${truncate(` ${s.accent("›")} ${typed}`, columns)}`);
184
+ };
185
+ let cancelled = false;
186
+ const answered = readKeys(keys, async (key, sequence) => {
187
+ const next = edit(typed, key, sequence);
188
+ if (next === "cancel") cancelled = true;
189
+ else if (next === "submit") {
190
+ if (typed.trim() === "") return false;
191
+ error = await options.check?.(typed.trim()) ?? null;
192
+ if (error === null) return true;
193
+ } else {
194
+ typed = next;
195
+ error = null;
196
+ }
197
+ if (cancelled) return true;
198
+ draw();
199
+ return false;
200
+ });
201
+ out.write(`${truncate(` ${s.accent("?")} ${s.bold(question)} ${s.dim(hint)}`, columns)}\n`);
202
+ draw();
203
+ await answered;
204
+ out.write("\r\x1B[J\x1B[1A\x1B[2K");
205
+ if (cancelled) throw new Cancelled();
206
+ out.write(`${truncate(` ${s.green("✓")} ${s.dim(question)} ${typed.trim()}`, columns)}\n`);
207
+ return typed.trim();
208
+ }
209
+ /** One of `options`, chosen with the arrows and Enter; then one line, as `textLine` leaves. Ctrl-C cancels. */
210
+ async function select(keys, out, s, question, options) {
211
+ const columns = Math.max(20, (out.columns || 80) - 1);
212
+ let at = 0;
213
+ let drawn = 0;
214
+ const draw = () => {
215
+ const lines = [` ${s.accent("?")} ${s.bold(question)} ${s.dim("↑↓ then Enter")}`, ...options.map((option, i) => i === at ? ` ${s.accent("›")} ${s.accent(option)}` : ` ${option}`)];
216
+ out.write(`${drawn > 0 ? `\x1b[${drawn}F` : ""}${lines.map((line) => `${truncate(line, columns)}\x1b[K\n`).join("")}`);
217
+ drawn = lines.length;
218
+ };
219
+ let cancelled = false;
220
+ const chosen = readKeys(keys, (key) => {
221
+ if (key.ctrl && key.name === "c") cancelled = true;
222
+ else if (key.name === "return" || key.name === "enter") return true;
223
+ else if (key.name === "up" || key.name === "k") at = (at - 1 + options.length) % options.length;
224
+ else if (key.name === "down" || key.name === "j" || key.name === "tab") at = (at + 1) % options.length;
225
+ if (cancelled) return true;
226
+ draw();
227
+ return false;
228
+ });
229
+ draw();
230
+ await chosen;
231
+ out.write(`\x1b[${drawn}F\x1b[J`);
232
+ if (cancelled) throw new Cancelled();
233
+ out.write(`${truncate(` ${s.green("✓")} ${s.dim(question)} ${options[at]}`, columns)}\n`);
234
+ return at;
235
+ }
236
+ var Cancelled = class extends Error {
237
+ constructor() {
238
+ super("cancelled");
239
+ }
240
+ };
241
+ /**
242
+ * A line typed without echo: a bullet for each character, so that a paste
243
+ * shows it arrived. The question, its hint and the bullets go once it is
244
+ * given. Ctrl-C cancels.
245
+ */
246
+ async function hiddenLine(keys, out, s, question, hint) {
247
+ const columns = Math.max(20, (out.columns || 80) - 1);
248
+ let typed = "";
249
+ const draw = () => {
250
+ const count = typed.length > 0 ? s.dim(` ${typed.length} characters`) : "";
251
+ out.write(`\r\x1b[2K${truncate(` ${s.accent("›")} ${"•".repeat(typed.length)}`, columns - width(count))}${count}`);
252
+ };
253
+ let cancelled = false;
254
+ const typedIn = readKeys(keys, (key, sequence) => {
255
+ const next = edit(typed, key, sequence);
256
+ if (next === "cancel") cancelled = true;
257
+ else if (next === "submit") return true;
258
+ else typed = next;
259
+ if (cancelled) return true;
260
+ draw();
261
+ return false;
262
+ });
263
+ out.write(`${truncate(` ${s.accent("?")} ${s.bold(question)}`, columns)}\n${truncate(` ${s.dim(hint)}`, columns)}\n`);
264
+ draw();
265
+ await typedIn;
266
+ out.write("\r\x1B[2K\x1B[1A\x1B[2K\x1B[1A\x1B[2K");
267
+ if (cancelled) throw new Cancelled();
268
+ return typed.trim();
269
+ }
270
+ /** One key: y for yes, anything else for no. Ctrl-C cancels. */
271
+ async function yes(keys) {
272
+ let answer = false;
273
+ let cancelled = false;
274
+ await readKeys(keys, (key) => {
275
+ if (key.ctrl && key.name === "c") cancelled = true;
276
+ answer = key.name === "y";
277
+ return true;
278
+ });
279
+ if (cancelled) throw new Cancelled();
280
+ return answer;
281
+ }
282
+ //#endregion
283
+ export { openTerminal as a, release as c, style as d, textLine as f, yes as g, wrap as h, listed as i, row as l, width as m, edit as n, paragraph as o, truncate as p, hiddenLine as r, readKeys as s, Cancelled as t, select as u };
@@ -0,0 +1,263 @@
1
+ import { a as openTerminal, c as release, d as style, i as listed, t as Cancelled } from "./tty-BDa14JX2.js";
2
+ import { n as StepFailed, r as Steps, t as cliVersion } from "./version-CPk6zTTK.js";
3
+ import { a as install, n as coffrePins, r as deploymentKind, t as bumpPins } from "./deployment-S8za5nUu.js";
4
+ import { createRequire } from "node:module";
5
+ import { parseArgs } from "node:util";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import { readFileSync, realpathSync } from "node:fs";
8
+ import { dirname, join, sep } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ /**
11
+ * Every migration this version ships, oldest first, per engine: read from
12
+ * the journals at build time, so a Worker, which has no files, knows them
13
+ * too. A database that has applied fewer is behind this code, whatever
14
+ * `REQUIRED_MIGRATIONS` lets it run on.
15
+ */
16
+ const KNOWN_MIGRATIONS = {
17
+ postgres: [{
18
+ "idx": 0,
19
+ "version": "7",
20
+ "when": 1790930587507,
21
+ "tag": "0000_baseline",
22
+ "breakpoints": true
23
+ }, {
24
+ "idx": 1,
25
+ "version": "7",
26
+ "when": 1791062292678,
27
+ "tag": "0001_remove_syncs",
28
+ "breakpoints": true
29
+ }].map((entry) => entry.tag),
30
+ sqlite: [{
31
+ "idx": 0,
32
+ "version": "6",
33
+ "when": 1790874462505,
34
+ "tag": "0000_baseline",
35
+ "breakpoints": true
36
+ }, {
37
+ "idx": 1,
38
+ "version": "6",
39
+ "when": 1791062293531,
40
+ "tag": "0001_remove_syncs",
41
+ "breakpoints": true
42
+ }].map((entry) => entry.tag)
43
+ };
44
+ //#endregion
45
+ //#region src/update.ts
46
+ const PACKAGE = "@coffre/cli";
47
+ /**
48
+ * How the CLI at `path` was installed: under npx's cache, a global root
49
+ * (`npm root -g`, `pnpm root -g`), a project's node_modules, or a checkout.
50
+ */
51
+ function installOf(path, roots) {
52
+ if (path.includes(`${sep}_npx${sep}`)) return { kind: "npx" };
53
+ if (roots.pnpm !== null && path.startsWith(dirname(roots.pnpm) + sep)) return { kind: "pnpm" };
54
+ if (roots.npm !== null && path.startsWith(roots.npm + sep)) return { kind: "npm" };
55
+ const modules = path.lastIndexOf(`${sep}node_modules${sep}`);
56
+ if (modules !== -1) return {
57
+ kind: "project",
58
+ dir: path.slice(0, modules)
59
+ };
60
+ if (path.includes(`${sep}packages${sep}cli${sep}`)) return { kind: "checkout" };
61
+ return { kind: "unknown" };
62
+ }
63
+ /** The migrations a deployment's installed coffre ships, through its server's own @coffre/db; null before an install. */
64
+ function deploymentMigrations(dir) {
65
+ try {
66
+ const server = createRequire(realpathSync(join(dir, "node_modules", "@coffre", "server", "package.json")));
67
+ return journalTags(join(dirname(server.resolve("@coffre/db/package.json")), "dist", "migrations", "postgres"));
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+ function journalTags(folder) {
73
+ return JSON.parse(readFileSync(join(folder, "meta", "_journal.json"), "utf8")).entries.map((entry) => entry.tag);
74
+ }
75
+ /**
76
+ * What moving from version `from` to `to` asks of the database, said once it
77
+ * is installed: the migrations `after` has and `before` did not.
78
+ */
79
+ function migrationsAdded(from, to, before, after) {
80
+ const added = after.filter((tag) => !before.includes(tag));
81
+ if (added.length === 0) return `coffre ${to} adds no migration to ${from}'s: deploying it is all.`;
82
+ return `coffre ${to} adds ${added.length === 1 ? "1 migration" : `${added.length} migrations`} to ${from}'s (${listed(added, "and")}): after deploying, run \`coffre migrate\`.`;
83
+ }
84
+ /** coffre's latest release, as the registry has it. */
85
+ async function latestVersion() {
86
+ const registry = (process.env.npm_config_registry ?? "https://registry.npmjs.org/").replace(/\/?$/, "/");
87
+ const response = await fetch(`${registry}${PACKAGE}/latest`, { headers: { accept: "application/json" } });
88
+ if (!response.ok) throw new Error(`${registry} answered ${response.status} for ${PACKAGE}`);
89
+ return (await response.json()).version;
90
+ }
91
+ function globalRoot(manager) {
92
+ const ran = spawnSync(manager, ["root", "-g"], {
93
+ encoding: "utf8",
94
+ timeout: 1e4
95
+ });
96
+ const root = ran.status === 0 ? ran.stdout.trim() : "";
97
+ if (root === "") return null;
98
+ try {
99
+ return realpathSync(root);
100
+ } catch {
101
+ return root;
102
+ }
103
+ }
104
+ /** A command, its output kept to explain a failure. */
105
+ function command(name, args, cwd) {
106
+ return new Promise((resolve, reject) => {
107
+ const child = spawn(name, args, {
108
+ cwd,
109
+ stdio: [
110
+ "ignore",
111
+ "pipe",
112
+ "pipe"
113
+ ],
114
+ env: {
115
+ ...process.env,
116
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
117
+ }
118
+ });
119
+ let output = "";
120
+ child.stdout.setEncoding("utf8").on("data", (chunk) => output += chunk);
121
+ child.stderr.setEncoding("utf8").on("data", (chunk) => output += chunk);
122
+ child.on("error", () => reject(/* @__PURE__ */ new Error(`${name} is not installed`)));
123
+ child.on("close", (code) => {
124
+ if (code === 0) return resolve();
125
+ 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`));
126
+ reject(/* @__PURE__ */ new Error(`${name} ${args.join(" ")} failed: ${output.trim().split("\n").slice(-3).join(" ")}`));
127
+ });
128
+ });
129
+ }
130
+ async function update(args) {
131
+ let yes;
132
+ try {
133
+ ({yes} = parseArgs({
134
+ args,
135
+ options: { yes: {
136
+ type: "boolean",
137
+ default: false
138
+ } },
139
+ strict: true
140
+ }).values);
141
+ } catch {
142
+ process.stderr.write("usage: coffre update [--yes]\n");
143
+ process.exit(2);
144
+ }
145
+ const terminal = openTerminal();
146
+ const out = terminal?.out ?? process.stderr;
147
+ const s = style(out);
148
+ const keys = () => terminal?.keys ?? null;
149
+ const ask = async (question, step) => {
150
+ if (yes) return true;
151
+ if (keys() === null) throw new Error("nothing here to confirm on: run coffre update on a terminal, or pass --yes");
152
+ return step.ask(question);
153
+ };
154
+ const current = cliVersion();
155
+ const self = realpathSync(fileURLToPath(import.meta.url));
156
+ const how = installOf(self, {
157
+ npm: globalRoot("npm"),
158
+ pnpm: globalRoot("pnpm")
159
+ });
160
+ const dir = process.cwd();
161
+ const kind = deploymentKind(dir);
162
+ const deployment = kind === "workers" || kind === "node" ? dir : null;
163
+ const pins = deployment === null ? {} : coffrePins(deployment);
164
+ const pinned = [...new Set(Object.values(pins))];
165
+ if (s.ansi) out.write(`\n ${s.bold("coffre update")} ${s.dim(`this CLI${deployment === null ? "" : ", and this deployment"}`)}\n\n`);
166
+ const steps = new Steps(out, [
167
+ "Look up coffre's latest release",
168
+ "Update this CLI",
169
+ ...deployment === null ? [] : ["Update this deployment's coffre packages"]
170
+ ], keys, (error) => error instanceof Error ? error.message : String(error));
171
+ let before = (deployment === null ? null : deploymentMigrations(deployment)) ?? KNOWN_MIGRATIONS.postgres;
172
+ const from = deployment !== null && pinned.length === 1 ? pinned[0] : current;
173
+ let after = null;
174
+ let latest = current;
175
+ try {
176
+ await steps.run(0, async () => {
177
+ latest = await latestVersion();
178
+ return `coffre ${latest} is the latest release; this CLI is ${current}`;
179
+ });
180
+ await steps.run(1, async (step) => {
181
+ if (current === latest) return `This CLI is up to date, at ${current}`;
182
+ switch (how.kind) {
183
+ case "npx": return {
184
+ text: "Nothing to update: npx runs the version it is given",
185
+ details: [`npx ${PACKAGE}@${latest} … runs ${latest}`]
186
+ };
187
+ case "checkout": return {
188
+ text: "Nothing to update here: this CLI runs from a checkout of coffre",
189
+ details: ["git pull, then pnpm install and pnpm build"]
190
+ };
191
+ case "project": return how.dir === deployment ? {
192
+ text: `This CLI is one of the deployment's packages, updated with them below`,
193
+ details: []
194
+ } : {
195
+ text: `Nothing updated: this CLI is a dependency of ${how.dir}`,
196
+ details: [`update it there: its @coffre/cli pin, to ${latest}`]
197
+ };
198
+ case "unknown": return {
199
+ text: "Nothing updated: where this CLI was installed from is unclear",
200
+ details: [`npm install -g ${PACKAGE}@${latest}, or pnpm add -g ${PACKAGE}@${latest}`]
201
+ };
202
+ default: {
203
+ const run = how.kind === "npm" ? ["npm", [
204
+ "install",
205
+ "-g",
206
+ `${PACKAGE}@${latest}`
207
+ ]] : ["pnpm", [
208
+ "add",
209
+ "-g",
210
+ `${PACKAGE}@${latest}`
211
+ ]];
212
+ if (!await ask(`Update this CLI from ${current} to ${latest}, with ${run[0]} ${run[1].join(" ")}?`, step)) return {
213
+ text: `This CLI stays at ${current}`,
214
+ details: []
215
+ };
216
+ step.note(`Updating this CLI, with ${run[0]}`);
217
+ await command(run[0], [...run[1]], dir);
218
+ after ??= journalTags(join(dirname(self), "migrations", "postgres"));
219
+ return `Updated this CLI from ${current} to ${latest}, with ${run[0]}`;
220
+ }
221
+ }
222
+ });
223
+ if (deployment !== null) await steps.run(2, async (step) => {
224
+ if (pinned.length === 1 && pinned[0] === latest) return `This deployment's coffre packages are at ${latest} already`;
225
+ const was = pinned.length === 1 ? pinned[0] : listed(pinned, "and");
226
+ if (!await ask(`Move this deployment's coffre packages from ${was} to ${latest}, and install them?`, step)) return {
227
+ text: `This deployment stays at ${was}`,
228
+ details: []
229
+ };
230
+ before = deploymentMigrations(deployment) ?? before;
231
+ bumpPins(deployment, latest);
232
+ step.note(`Installing coffre ${latest}'s packages, with pnpm`);
233
+ await install(deployment);
234
+ after = deploymentMigrations(deployment) ?? after;
235
+ return {
236
+ text: `Moved this deployment from ${was} to ${latest}, and installed it`,
237
+ details: [`${Object.keys(pins).length} coffre packages, which minimumReleaseAge lets through at once`]
238
+ };
239
+ });
240
+ } catch (error) {
241
+ if (error instanceof Cancelled) finish(out, `${s.red("✗")} cancelled; nothing after the last step done was changed`, 130);
242
+ if (error instanceof StepFailed) finish(out, "", 1);
243
+ finish(out, `${s.red("✗")} ${error instanceof Error ? error.message : String(error)}`, 1);
244
+ } finally {
245
+ steps.end();
246
+ if (terminal !== null) release(terminal.keys);
247
+ }
248
+ out.write("\n");
249
+ if (after !== null) {
250
+ out.write(` ${migrationsAdded(from, latest, before, after)}\n`);
251
+ if (deployment !== null) {
252
+ const deploy = kind === "workers" ? "`pnpm run deploy`, or a push for Workers Builds" : "restarting the server";
253
+ out.write(` ${s.dim(`Deploy it as you do: ${deploy}.`)}\n`);
254
+ }
255
+ out.write("\n");
256
+ }
257
+ }
258
+ function finish(out, message, code) {
259
+ if (message !== "") out.write(`${message}\n`);
260
+ process.exit(code);
261
+ }
262
+ //#endregion
263
+ export { update };