alexandr 0.2.2 → 0.3.1
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 +74 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +8 -1
- package/src/commands.js +97 -12
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/instance.js +34 -1
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +33 -0
- package/templates/env.example +10 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `alexandr app update` — move the linked workspace onto the newest release.
|
|
2
|
+
//
|
|
3
|
+
// The terminal door to `POST /_kernel/store/update` (app-system-stage-3.md
|
|
4
|
+
// WP-N), which re-reads the feed, refuses anything that is not strictly newer,
|
|
5
|
+
// then stages and switches. One request, one answer: `{ ok, version, previous }`.
|
|
6
|
+
//
|
|
7
|
+
// ⚠ THIS VERB DOES NOT PUBLISH AND DOES NOT BUILD. It moves a box between
|
|
8
|
+
// releases that already exist — which is why the box gates it on
|
|
9
|
+
// `os.apps.manage` (install/uninstall/switch) rather than `os.apps.create` (new
|
|
10
|
+
// code arriving from a laptop, the `deploy` gate).
|
|
11
|
+
//
|
|
12
|
+
// ⚠ A 409 HERE IS AN ANSWER, NOT A FAILURE. "notes is already at 1.2.0" is the
|
|
13
|
+
// truthful reply to "update notes", and a CLI that exited non-zero on it would
|
|
14
|
+
// make `alexandr app update` unusable in any script that runs it more than once.
|
|
15
|
+
// Only a refusal that leaves the box somewhere it should not be is an error.
|
|
16
|
+
//
|
|
17
|
+
// This module also holds the two helpers the three release verbs share
|
|
18
|
+
// (`postKernelJson`, `resolveAppId`) — `rollback` and `entitle` import them from
|
|
19
|
+
// here rather than each transcribing the same eight lines, and deliberately NOT
|
|
20
|
+
// from `reach.js`, which is the "how do I talk to the box" module and has no
|
|
21
|
+
// business knowing what an app id is.
|
|
22
|
+
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import { bold, cyan, dim, fail, log, ok, step, warn } from "../util.js";
|
|
25
|
+
import { EXIT } from "../exit.js";
|
|
26
|
+
import { appIdOf } from "./deploy.js";
|
|
27
|
+
import { APP_ID_RE, headersFor, reachWorkspace } from "./reach.js";
|
|
28
|
+
|
|
29
|
+
export const UPDATE_HELP = `${bold("alexandr app update")} — move the linked workspace onto the newest release
|
|
30
|
+
|
|
31
|
+
${bold("USAGE")}
|
|
32
|
+
alexandr app update [--app <id>] [--dir <path>]
|
|
33
|
+
|
|
34
|
+
${bold("OPTIONS")}
|
|
35
|
+
--app <id> The app to update, when it isn't the one in this folder
|
|
36
|
+
--dir <path> The app folder, when it isn't the current one
|
|
37
|
+
|
|
38
|
+
${dim("Re-reads the catalog and switches the workspace onto the newest release. Refuses anything that isn't strictly newer — that is `alexandr app rollback`'s job. Needs the os.apps.manage permission in that workspace.")}`;
|
|
39
|
+
|
|
40
|
+
export async function appUpdate(flags, project) {
|
|
41
|
+
// ⚠ `--help` before anything that can exit — see the note in publish.js.
|
|
42
|
+
if (flags.help || flags.h) return void log(UPDATE_HELP);
|
|
43
|
+
const dir = project ?? projectFor(flags);
|
|
44
|
+
const chosen = resolveAppId(flags, dir);
|
|
45
|
+
if (chosen.error) fail(chosen.error, EXIT.USAGE);
|
|
46
|
+
const id = chosen.id;
|
|
47
|
+
|
|
48
|
+
const { url, authorization } = await reachWorkspace(dir);
|
|
49
|
+
step(`Updating ${bold(id)} in ${cyan(url)}…`);
|
|
50
|
+
const res = await postKernelJson(`${url}/_kernel/store/update`, { id }, headersFor(authorization, true));
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
if (res.status === 403) {
|
|
54
|
+
fail(
|
|
55
|
+
`The workspace refused it (403) — changing which version an app runs needs the 'os.apps.manage' permission in that workspace. ${res.error ?? ""}`.trim(),
|
|
56
|
+
EXIT.GENERAL,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 409) {
|
|
60
|
+
// The door's own sentence, verbatim: it already names the installed version
|
|
61
|
+
// and what the catalog offers, which is more than this side knows.
|
|
62
|
+
const conflict = updateConflict(res.error, id);
|
|
63
|
+
return void (conflict.kind === "ok" ? ok(conflict.message) : warn(conflict.message));
|
|
64
|
+
}
|
|
65
|
+
// A build that failed AFTER the switch has already put the previous release
|
|
66
|
+
// back by the time this answers — say so, or the developer goes looking for a
|
|
67
|
+
// half-updated box that does not exist.
|
|
68
|
+
if (res.rolledBack) warn(`${id} is back on its previous release.`);
|
|
69
|
+
fail(`Couldn't update ${id}: ${res.error}`, EXIT.GENERAL);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { version, previous } = res.data ?? {};
|
|
73
|
+
ok(`${bold(id)} is on ${bold(version ?? "its newest release")}${previous ? dim(` (was ${previous})`) : ""}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A 409 from the update door, classified.
|
|
78
|
+
*
|
|
79
|
+
* "already at X" is the happy answer to a re-run and reads as a tick; anything
|
|
80
|
+
* else the door refuses with (a catalog that does not name a version for this
|
|
81
|
+
* app) is a real gap the developer should see as a warning. Neither is an exit
|
|
82
|
+
* code — see the header.
|
|
83
|
+
*/
|
|
84
|
+
export function updateConflict(error, id) {
|
|
85
|
+
const message = typeof error === "string" && error.trim() ? error.trim() : `${id} has nothing newer to move to.`;
|
|
86
|
+
return { kind: /already at/i.test(message) ? "ok" : "warn", message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The folder these verbs act in: `--dir`, else the cwd.
|
|
91
|
+
*
|
|
92
|
+
* ⚠ NOT `appDirOf` — and the difference is the point. `update`, `rollback` and
|
|
93
|
+
* `entitle` can name their app with `--app`, so a folder with no manifest.json
|
|
94
|
+
* is a perfectly legitimate place to run them from (what they need is the
|
|
95
|
+
* project's `.alexandr/link.json`, not its manifest). `appDirOf` would refuse
|
|
96
|
+
* that folder, and refuse it with the wrong reason.
|
|
97
|
+
*/
|
|
98
|
+
export function projectFor(flags, cwd = process.cwd()) {
|
|
99
|
+
return typeof flags.dir === "string" && flags.dir.trim() ? resolve(cwd, flags.dir) : resolve(cwd);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which app: `--app` when given, else the one this folder declares.
|
|
104
|
+
*
|
|
105
|
+
* Pure — it answers `{ id }` or `{ error }` rather than exiting, so the choice
|
|
106
|
+
* is testable without a process. `--app` is validated against the same grammar
|
|
107
|
+
* the kernel mints ids with, so a typo is refused here with the rule spelled
|
|
108
|
+
* out instead of arriving as a 404 from the box.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveAppId(flags, project) {
|
|
111
|
+
const named = typeof flags.app === "string" ? flags.app.trim() : "";
|
|
112
|
+
if (named) {
|
|
113
|
+
if (!APP_ID_RE.test(named)) {
|
|
114
|
+
return { error: `'${named}' is not an app id — lowercase letters, digits and dashes.` };
|
|
115
|
+
}
|
|
116
|
+
return { id: named };
|
|
117
|
+
}
|
|
118
|
+
const declared = appIdOf(project);
|
|
119
|
+
if (!declared) {
|
|
120
|
+
return {
|
|
121
|
+
error: `No manifest.json in ${project} to read an app id from — name the app with \`--app <id>\`, or run this inside its folder.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { id: declared };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* POST json to a kernel door → `{ ok, status, data, error, rolledBack }`.
|
|
129
|
+
*
|
|
130
|
+
* The kernel's store routes answer with a BODY on failure too (`{ error }`, and
|
|
131
|
+
* `rolledBack` when an update undid itself), so the status alone is never the
|
|
132
|
+
* whole answer — a helper that threw away the body would lose the only sentence
|
|
133
|
+
* worth printing. Shaped like `postMultipart` in deploy.js for the same reason.
|
|
134
|
+
*/
|
|
135
|
+
export async function postKernelJson(url, body, headers = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(url, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json", ...headers },
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
});
|
|
142
|
+
const data = await res.json().catch(() => null);
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
status: res.status,
|
|
147
|
+
error: typeof data?.error === "string" && data.error ? data.error : `HTTP ${res.status}`,
|
|
148
|
+
rolledBack: data?.rolledBack === true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, status: res.status, data };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { ok: false, status: 0, error: `couldn't reach ${safeOrigin(url)}: ${e?.message ?? e}` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeOrigin(url) {
|
|
158
|
+
try {
|
|
159
|
+
return new URL(url).origin;
|
|
160
|
+
} catch {
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as cmd from "./commands.js";
|
|
|
10
10
|
import { resolveInstance, isMaterialized, kernelPort } from "./instance.js";
|
|
11
11
|
import { kernelUrl, version as kVersion } from "./probe.js";
|
|
12
12
|
import { COMMANDS, completion } from "./completion.js";
|
|
13
|
+
import { app } from "./app/index.js";
|
|
13
14
|
import { EXIT } from "./exit.js";
|
|
14
15
|
|
|
15
16
|
const pkg = JSON.parse(
|
|
@@ -51,17 +52,23 @@ async function version(flags) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const TABLE = {
|
|
55
|
+
app,
|
|
54
56
|
up: cmd.up, down: cmd.down, destroy: cmd.destroy, status: cmd.status,
|
|
55
57
|
ls: cmd.ls, logs: cmd.logs, config: cmd.config, init: cmd.init,
|
|
56
58
|
update: cmd.update, connect: cmd.connect, link: cmd.link, login: cmd.login, backup: cmd.backup,
|
|
57
59
|
restore: cmd.restore, completion, doctor: cmd.doctor, version,
|
|
60
|
+
// The sidecar's entrypoint (`alexandr updater serve`) — the same package, in a container.
|
|
61
|
+
updater: cmd.updater,
|
|
58
62
|
};
|
|
59
63
|
|
|
60
64
|
export async function run(argv = process.argv.slice(2)) {
|
|
61
65
|
const flags = parseArgs(argv);
|
|
62
66
|
const sub = flags._.shift();
|
|
63
67
|
|
|
64
|
-
|
|
68
|
+
// ⚠ `--help` is handled HERE for every verb except `app`, whose subcommands
|
|
69
|
+
// each have their own help (`alexandr app deploy --help`). Swallowing it at
|
|
70
|
+
// this level would make those unreachable.
|
|
71
|
+
if (!sub || ((flags.help || flags.h) && sub !== "app")) {
|
|
65
72
|
if (!sub && (flags.version || flags.v)) return version(flags);
|
|
66
73
|
log(HELP);
|
|
67
74
|
return;
|
package/src/commands.js
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
} from "./docker.js";
|
|
16
16
|
import {
|
|
17
17
|
resolveInstance, isMaterialized, materialize, readEnv, envHas, setEnv,
|
|
18
|
-
unsetEnv, kernelPort,
|
|
18
|
+
unsetEnv, kernelPort, pruneSnapshots,
|
|
19
19
|
} from "./instance.js";
|
|
20
|
+
export { updater } from "./updater.js";
|
|
20
21
|
import { kernelUrl, health, version as kVersion, waitHealthy, waitPosture } from "./probe.js";
|
|
21
22
|
import { buildConnect } from "./connect.js";
|
|
22
23
|
import {
|
|
@@ -79,6 +80,71 @@ function archiveVolume(vol, outPath) {
|
|
|
79
80
|
return r.status === 0;
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/** Snapshots kept per box (owner, 2026-09-08): the newest three `pre-update-*.tgz`. */
|
|
84
|
+
const SNAPSHOT_KEEP = 3;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The compose PROFILES this instance runs with — every `up`/`down` must pass the same set, or
|
|
88
|
+
* a profiled service is left behind (a `down` without the profile leaves the updater running).
|
|
89
|
+
*
|
|
90
|
+
* · `public` — Caddy, when a domain is configured.
|
|
91
|
+
* · `updater` — the updater sidecar (self-host-update-sidecar.md), unless the operator opted
|
|
92
|
+
* out (`ALEXANDR_UPDATER=off`, written by `--no-updater`) — or unless THIS process is the
|
|
93
|
+
* sidecar's own child (`ALEXANDR_UPDATER_INSIDE=1`): a container cannot recreate itself
|
|
94
|
+
* mid-run, so the recipe run from inside leaves the `updater` service alone.
|
|
95
|
+
*/
|
|
96
|
+
function profileArgs(dir) {
|
|
97
|
+
const env = readEnv(dir);
|
|
98
|
+
const args = [];
|
|
99
|
+
if (envHas(dir, "ALEXANDR_DOMAIN")) args.push("--profile", "public");
|
|
100
|
+
const updaterOff = (env.ALEXANDR_UPDATER || "").trim().toLowerCase() === "off";
|
|
101
|
+
if (!updaterOff && process.env.ALEXANDR_UPDATER_INSIDE !== "1") args.push("--profile", "updater");
|
|
102
|
+
return args;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* What the updater sidecar needs written into `.env` before compose can start it: the instance
|
|
107
|
+
* dir at its host path (mounted at the same path inside), the compose project name (so the
|
|
108
|
+
* recipe run from inside drives THIS project, not a re-derived one), and the sidecar's image
|
|
109
|
+
* tag — pinned to the CLI that installed it, so the sidecar's version is the CLI's version.
|
|
110
|
+
*/
|
|
111
|
+
function stampUpdaterEnv(inst) {
|
|
112
|
+
setEnv(inst.dir, "ALEXANDR_INSTANCE_DIR", hostPath(inst.dir));
|
|
113
|
+
setEnv(inst.dir, "ALEXANDR_COMPOSE_PROJECT", inst.projectName);
|
|
114
|
+
if (process.env.ALEXANDR_UPDATER_INSIDE !== "1") setEnv(inst.dir, "ALEXANDR_UPDATER_IMAGE_TAG", pkgVersion());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ⚠ THE SIDECAR MUST NEVER COST A BOOT. With the `updater` profile on, a `compose up` that
|
|
119
|
+
* cannot pull the updater image (the tag not yet published for this CLI version, an
|
|
120
|
+
* air-gapped host, a registry that serves the kernel but not this package) fails the WHOLE
|
|
121
|
+
* `up` — kernel included. So when `up` fails and the profile was on, probe the updater's
|
|
122
|
+
* pull alone; if THAT is what failed, say so once and retry without the sidecar. Nothing is
|
|
123
|
+
* persisted: the next `up` tries the sidecar again. Returns the compose status.
|
|
124
|
+
*/
|
|
125
|
+
function composeUpWithoutSidecarIfNeeded(inst, args, offline) {
|
|
126
|
+
if (!args.includes("updater")) return 1;
|
|
127
|
+
if (!offline) {
|
|
128
|
+
const probe = composeCapture(inst.dir, inst.projectName, ["--profile", "updater", "pull", "updater"]);
|
|
129
|
+
if (probe.status === 0) return 1; // the sidecar image is fine — the failure is elsewhere
|
|
130
|
+
}
|
|
131
|
+
warn("The updater sidecar's image isn't available yet — starting without it (the terminal `alexandr update` still works; the sidecar comes back on the next `alexandr up`).");
|
|
132
|
+
const without = [];
|
|
133
|
+
for (let i = 0; i < args.length; i++) {
|
|
134
|
+
if (args[i] === "--profile" && args[i + 1] === "updater") {
|
|
135
|
+
i++;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
without.push(args[i]);
|
|
139
|
+
}
|
|
140
|
+
return compose(inst.dir, inst.projectName, without).status;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pkgVersion() {
|
|
144
|
+
const p = path.resolve(fileURLToPath(import.meta.url), "..", "..", "package.json");
|
|
145
|
+
return JSON.parse(fs.readFileSync(p, "utf8")).version;
|
|
146
|
+
}
|
|
147
|
+
|
|
82
148
|
function isRunning(dir, projectName) {
|
|
83
149
|
const r = composeCapture(dir, projectName, ["ps", "--status", "running", "-q", "kernel"]);
|
|
84
150
|
return r.status === 0 && r.stdout !== "";
|
|
@@ -182,6 +248,11 @@ export async function up(flags) {
|
|
|
182
248
|
if (typeof flags.name === "string" && flags.name.trim()) {
|
|
183
249
|
setEnv(inst.dir, "ALEXANDR_WORKSPACE_NAME", flags.name.trim());
|
|
184
250
|
}
|
|
251
|
+
// The updater sidecar (self-host-update-sidecar.md): on by default, `--no-updater` records
|
|
252
|
+
// the opt-out in .env so every later compose call agrees; `--updater` turns it back on.
|
|
253
|
+
if (flags["no-updater"]) setEnv(inst.dir, "ALEXANDR_UPDATER", "off");
|
|
254
|
+
else if (flags.updater === true) unsetEnv(inst.dir, "ALEXANDR_UPDATER");
|
|
255
|
+
stampUpdaterEnv(inst);
|
|
185
256
|
|
|
186
257
|
// Sign-in comes FIRST (account-required-runtimes D3) — now literally: the entitlement
|
|
187
258
|
// gate fires before anything system-mutating, so a refused account walks away from a
|
|
@@ -209,9 +280,7 @@ export async function up(flags) {
|
|
|
209
280
|
}
|
|
210
281
|
|
|
211
282
|
const offline = Boolean(flags.offline || flags["no-pull"]);
|
|
212
|
-
const args = [];
|
|
213
|
-
if (hasDomain) args.push("--profile", "public");
|
|
214
|
-
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
283
|
+
const args = [...profileArgs(inst.dir), "up", "-d", "--pull", offline ? "never" : "missing"];
|
|
215
284
|
|
|
216
285
|
step(`Starting alexandr (${inst.mode} · ${inst.dir})…`);
|
|
217
286
|
let r = compose(inst.dir, inst.projectName, args);
|
|
@@ -219,7 +288,9 @@ export async function up(flags) {
|
|
|
219
288
|
step("Retrying the start…");
|
|
220
289
|
r = compose(inst.dir, inst.projectName, args);
|
|
221
290
|
}
|
|
222
|
-
if (r.status !== 0
|
|
291
|
+
if (r.status !== 0 && composeUpWithoutSidecarIfNeeded(inst, args, offline) !== 0) {
|
|
292
|
+
fail("`docker compose up` failed — see the output above. Try `alexandr doctor`.", EXIT.RUNTIME);
|
|
293
|
+
}
|
|
223
294
|
|
|
224
295
|
const base = kernelUrl(port);
|
|
225
296
|
step("Waiting for the kernel to come up…");
|
|
@@ -236,7 +307,7 @@ export async function up(flags) {
|
|
|
236
307
|
);
|
|
237
308
|
await runLinkCeremony(inst, flags);
|
|
238
309
|
step("Restarting the runtime with the fresh credentials…");
|
|
239
|
-
const restartArgs =
|
|
310
|
+
const restartArgs = [...profileArgs(inst.dir), "up", "-d"];
|
|
240
311
|
if (compose(inst.dir, inst.projectName, restartArgs).status !== 0) {
|
|
241
312
|
fail("`docker compose up` failed after re-linking — see the output above.", EXIT.RUNTIME);
|
|
242
313
|
}
|
|
@@ -298,7 +369,7 @@ export async function down(flags) {
|
|
|
298
369
|
const inst = resolveInstance(flags);
|
|
299
370
|
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
300
371
|
step("Stopping alexandr (data preserved)…");
|
|
301
|
-
const r = compose(inst.dir, inst.projectName, ["down"]);
|
|
372
|
+
const r = compose(inst.dir, inst.projectName, [...profileArgs(inst.dir), "down"]);
|
|
302
373
|
if (r.status === 0) ok("Stopped. Your data volume is untouched — `alexandr up` to resume.");
|
|
303
374
|
}
|
|
304
375
|
|
|
@@ -351,7 +422,7 @@ export async function destroy(flags) {
|
|
|
351
422
|
else warn("Couldn't remove it from your account — remove it from your account page instead.");
|
|
352
423
|
}
|
|
353
424
|
step(wipe ? "Removing alexandr AND its data volume…" : "Removing alexandr containers (data preserved)…");
|
|
354
|
-
const r = compose(inst.dir, inst.projectName,
|
|
425
|
+
const r = compose(inst.dir, inst.projectName, [...profileArgs(inst.dir), "down", ...(wipe ? ["-v"] : [])]);
|
|
355
426
|
if (r.status === 0) ok(wipe ? "Destroyed, including /data." : "Containers removed; /data volume kept.");
|
|
356
427
|
}
|
|
357
428
|
|
|
@@ -530,6 +601,15 @@ export async function update(flags) {
|
|
|
530
601
|
await ensureLinked(inst, flags);
|
|
531
602
|
}
|
|
532
603
|
|
|
604
|
+
// The compose file is the CLI's template, re-staged on every `up`; an update re-stages it
|
|
605
|
+
// too, so the FIRST update after the updater sidecar shipped installs the sidecar
|
|
606
|
+
// (self-host-update-sidecar.md §6) — the image pin lives in .env, never in the file.
|
|
607
|
+
// ⚠ Not from inside the sidecar: its own compose run must not rewrite the file it is
|
|
608
|
+
// executing against, and it never carries the newer template anyway.
|
|
609
|
+
if (process.env.ALEXANDR_UPDATER_INSIDE !== "1") {
|
|
610
|
+
materialize(inst.dir);
|
|
611
|
+
stampUpdaterEnv(inst);
|
|
612
|
+
}
|
|
533
613
|
const prevImage = readEnv(inst.dir).ALEXANDR_IMAGE || `${IMAGE_BASE}:latest`;
|
|
534
614
|
const base = kernelUrl(kernelPort(inst.dir));
|
|
535
615
|
const before = (await kVersion(base))?.version ?? "?";
|
|
@@ -580,14 +660,19 @@ export async function update(flags) {
|
|
|
580
660
|
fail("Pull failed — check your network / the image tag.", EXIT.RUNTIME);
|
|
581
661
|
}
|
|
582
662
|
}
|
|
583
|
-
const args = [];
|
|
584
|
-
if (envHas(inst.dir, "ALEXANDR_DOMAIN")) args.push("--profile", "public");
|
|
585
|
-
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
663
|
+
const args = [...profileArgs(inst.dir), "up", "-d", "--pull", offline ? "never" : "missing"];
|
|
586
664
|
step("Applying the update (data preserved)…");
|
|
587
|
-
if (compose(inst.dir, inst.projectName, args).status !== 0
|
|
665
|
+
if (compose(inst.dir, inst.projectName, args).status !== 0 && composeUpWithoutSidecarIfNeeded(inst, args, offline) !== 0) {
|
|
666
|
+
fail("Update failed — see output above.", EXIT.RUNTIME);
|
|
667
|
+
}
|
|
588
668
|
|
|
589
669
|
const after = (await waitHealthy(base))?.version ?? "?";
|
|
590
670
|
ok(`Updated: ${before} → ${after}`);
|
|
671
|
+
// The newest three snapshots stay (owner, 2026-09-08); a box updated from the app every
|
|
672
|
+
// train would otherwise fill its disk with tarballs. Pruned AFTER the update is verified, so
|
|
673
|
+
// a failed update never deletes the snapshot it may need.
|
|
674
|
+
const pruned = pruneSnapshots(inst.dir, SNAPSHOT_KEEP);
|
|
675
|
+
if (pruned.length) log(dim(` Pruned ${pruned.length} older snapshot(s); the newest ${SNAPSHOT_KEEP} are kept in backups/.`));
|
|
591
676
|
}
|
|
592
677
|
|
|
593
678
|
// ---------------------------------------------------------------- connect
|
package/src/completion.js
CHANGED
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { fail } from "./util.js";
|
|
8
8
|
import { EXIT } from "./exit.js";
|
|
9
|
+
import { APP_SUBCOMMANDS } from "./app/index.js";
|
|
9
10
|
|
|
10
11
|
// One row per command: { name, usage, summary }. `usage` is the display form for
|
|
11
12
|
// help (may carry an arg hint like "logs [-f]"); `name` is the bare verb used for
|
|
12
13
|
// completion. Order is the help display order.
|
|
13
14
|
export const COMMANDS = [
|
|
14
15
|
{ name: "up", usage: "up", summary: "Sign in (first run), pull + boot the runtime; print the URL" },
|
|
16
|
+
{
|
|
17
|
+
name: "app",
|
|
18
|
+
usage: "app <cmd>",
|
|
19
|
+
// ⚠ The CANDIDATES come from `APP_SUBCOMMANDS` (src/app/index.js), so tab
|
|
20
|
+
// completion is right on its own — this line is only the one-line summary a
|
|
21
|
+
// person reads, and it has to be kept in step by hand.
|
|
22
|
+
summary: "Develop an app: link | dev | build | deploy | secrets | config | publish | update | rollback | entitle",
|
|
23
|
+
},
|
|
15
24
|
{ name: "down", usage: "down", summary: "Stop the runtime (data preserved)" },
|
|
16
25
|
{ name: "status", usage: "status", summary: "Show state, version, URL, and data size" },
|
|
17
26
|
{ name: "ls", usage: "ls", summary: "List all alexandr instances" },
|
|
@@ -20,6 +29,7 @@ export const COMMANDS = [
|
|
|
20
29
|
{ name: "link", usage: "link", summary: "Re-link this runtime to your alexandr account (--force re-registers)" },
|
|
21
30
|
{ name: "login", usage: "login", summary: "Refresh the runtime-image pull credential (sign in, no re-register)" },
|
|
22
31
|
{ name: "update", usage: "update", summary: "Update the runtime image (--to <tag>, --rollback)" },
|
|
32
|
+
{ name: "updater", usage: "updater serve", summary: "Run the updater sidecar (inside its container; `up` starts it)" },
|
|
23
33
|
{ name: "backup", usage: "backup", summary: "Archive the data volume (--out <file>)" },
|
|
24
34
|
{ name: "restore", usage: "restore <f>", summary: "Restore a data-volume archive (--yes)" },
|
|
25
35
|
{ name: "config", usage: "config", summary: "get | set | list | unset (e.g. alexandr config set ai.url …)" },
|
|
@@ -37,6 +47,8 @@ const NAMES = COMMANDS.map((c) => c.name);
|
|
|
37
47
|
|
|
38
48
|
// Sub-surfaces worth completing one level deep.
|
|
39
49
|
const CONFIG_SUBS = ["get", "set", "list", "unset"];
|
|
50
|
+
// The `app` verb family (app-system-stage-1.md §2 WP-E) — the laptop door.
|
|
51
|
+
const APP_SUBS = APP_SUBCOMMANDS.map((c) => c.name);
|
|
40
52
|
|
|
41
53
|
// ---- generators -------------------------------------------------------------
|
|
42
54
|
|
|
@@ -55,6 +67,7 @@ _alexandr() {
|
|
|
55
67
|
case "\$cmd" in
|
|
56
68
|
completion) COMPREPLY=( \$(compgen -W "${SHELLS.join(" ")}" -- "\$cur") ); return ;;
|
|
57
69
|
config) if [ "\$COMP_CWORD" -eq 2 ]; then COMPREPLY=( \$(compgen -W "${CONFIG_SUBS.join(" ")}" -- "\$cur") ); return; fi ;;
|
|
70
|
+
app) if [ "\$COMP_CWORD" -eq 2 ]; then COMPREPLY=( \$(compgen -W "${APP_SUBS.join(" ")}" -- "\$cur") ); return; fi ;;
|
|
58
71
|
esac
|
|
59
72
|
if [[ "\$cur" == -* ]]; then
|
|
60
73
|
COMPREPLY=( \$(compgen -W "${GLOBAL_FLAGS.join(" ")}" -- "\$cur") )
|
|
@@ -88,6 +101,7 @@ fi
|
|
|
88
101
|
case "\${words[2]}" in
|
|
89
102
|
completion) _values 'shell' ${SHELLS.join(" ")}; return ;;
|
|
90
103
|
config) (( CURRENT == 3 )) && { _values 'subcommand' ${CONFIG_SUBS.join(" ")}; return } ;;
|
|
104
|
+
app) (( CURRENT == 3 )) && { _values 'subcommand' ${APP_SUBS.join(" ")}; return } ;;
|
|
91
105
|
esac
|
|
92
106
|
_describe -t options 'option' _alexandr_flags
|
|
93
107
|
`;
|
|
@@ -104,6 +118,7 @@ function fishScript() {
|
|
|
104
118
|
}
|
|
105
119
|
lines.push("complete -c alexandr -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'");
|
|
106
120
|
lines.push(`complete -c alexandr -n '__fish_seen_subcommand_from config' -a '${CONFIG_SUBS.join(" ")}'`);
|
|
121
|
+
lines.push(`complete -c alexandr -n '__fish_seen_subcommand_from app' -a '${APP_SUBS.join(" ")}'`);
|
|
107
122
|
for (const f of GLOBAL_FLAGS) {
|
|
108
123
|
if (f.startsWith("--")) lines.push(`complete -c alexandr -l ${f.slice(2)}`);
|
|
109
124
|
}
|