alexandr 0.2.0 → 0.2.2
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 +4 -1
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands.js +93 -6
- package/src/completion.js +1 -0
- package/src/link.js +66 -17
package/README.md
CHANGED
|
@@ -37,7 +37,9 @@ when Docker or Compose v2 is missing it offers to install them right there
|
|
|
37
37
|
always asked first). macOS/Windows get instructions instead. The runtime image
|
|
38
38
|
itself is a private package — it pulls with a credential the control plane mints
|
|
39
39
|
for your signed-in account, so the download is gated by the same entitlement as
|
|
40
|
-
registration.
|
|
40
|
+
registration. If the registry ever rejects a pull (say, after a credential
|
|
41
|
+
rotation), `up` and `update` offer a quick re-sign-in on the spot — or run
|
|
42
|
+
`alexandr login` any time.
|
|
41
43
|
|
|
42
44
|
## Commands
|
|
43
45
|
|
|
@@ -50,6 +52,7 @@ registration.
|
|
|
50
52
|
| `alexandr logs -f` | Tail kernel logs |
|
|
51
53
|
| `alexandr connect` | Print the `alexandr://connect` link / paste-string |
|
|
52
54
|
| `alexandr link` | Re-link this runtime to your account (`--force` re-registers) |
|
|
55
|
+
| `alexandr login` | Refresh the runtime-image pull credential (sign in, no re-register) |
|
|
53
56
|
| `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip) |
|
|
54
57
|
| `alexandr backup` / `restore <f>` | Archive / restore the data volume |
|
|
55
58
|
| `alexandr config set <k> <v>` | Edit config (`ai.url`, `model`, `port`, `domain`, …) |
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -53,7 +53,7 @@ async function version(flags) {
|
|
|
53
53
|
const TABLE = {
|
|
54
54
|
up: cmd.up, down: cmd.down, destroy: cmd.destroy, status: cmd.status,
|
|
55
55
|
ls: cmd.ls, logs: cmd.logs, config: cmd.config, init: cmd.init,
|
|
56
|
-
update: cmd.update, connect: cmd.connect, link: cmd.link, backup: cmd.backup,
|
|
56
|
+
update: cmd.update, connect: cmd.connect, link: cmd.link, login: cmd.login, backup: cmd.backup,
|
|
57
57
|
restore: cmd.restore, completion, doctor: cmd.doctor, version,
|
|
58
58
|
};
|
|
59
59
|
|
package/src/commands.js
CHANGED
|
@@ -19,7 +19,10 @@ import {
|
|
|
19
19
|
} from "./instance.js";
|
|
20
20
|
import { kernelUrl, health, version as kVersion, waitHealthy, waitPosture } from "./probe.js";
|
|
21
21
|
import { buildConnect } from "./connect.js";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
ensureLinked, isLinked, runLinkCeremony, unlinkFromAccount, applyRegistryLogin,
|
|
24
|
+
refreshRegistryLogin,
|
|
25
|
+
} from "./link.js";
|
|
23
26
|
import { select, ask } from "./prompt.js";
|
|
24
27
|
import { offerDependencyInstall } from "./deps.js";
|
|
25
28
|
import { EXIT } from "./exit.js";
|
|
@@ -211,7 +214,11 @@ export async function up(flags) {
|
|
|
211
214
|
args.push("up", "-d", "--pull", offline ? "never" : "missing");
|
|
212
215
|
|
|
213
216
|
step(`Starting alexandr (${inst.mode} · ${inst.dir})…`);
|
|
214
|
-
|
|
217
|
+
let r = compose(inst.dir, inst.projectName, args);
|
|
218
|
+
if (r.status !== 0 && !offline && (await recoverUnauthorizedPull(inst, flags))) {
|
|
219
|
+
step("Retrying the start…");
|
|
220
|
+
r = compose(inst.dir, inst.projectName, args);
|
|
221
|
+
}
|
|
215
222
|
if (r.status !== 0) fail("`docker compose up` failed — see the output above. Try `alexandr doctor`.", EXIT.RUNTIME);
|
|
216
223
|
|
|
217
224
|
const base = kernelUrl(port);
|
|
@@ -243,6 +250,48 @@ export async function up(flags) {
|
|
|
243
250
|
if (flags.open) openURL(clientUrl(inst.dir));
|
|
244
251
|
}
|
|
245
252
|
|
|
253
|
+
/**
|
|
254
|
+
* A failed compose start/pull might be the registry rejecting us — the box's `docker login`
|
|
255
|
+
* is missing or expired (fleet-wide whenever the CP-held pull PAT rotates). Probe with a
|
|
256
|
+
* captured pull; on an auth rejection offer the credential-refresh ceremony (interactive)
|
|
257
|
+
* or point at `alexandr login` (scripts). Returns true when a refreshed login landed and
|
|
258
|
+
* the caller should retry ONCE.
|
|
259
|
+
*/
|
|
260
|
+
async function recoverUnauthorizedPull(inst, flags) {
|
|
261
|
+
const probe = composeCapture(inst.dir, inst.projectName, ["pull", "kernel"]);
|
|
262
|
+
if (probe.status === 0) return true; // the pull works now (transient failure) — retry
|
|
263
|
+
if (!/unauthorized|denied|authentication required/i.test(`${probe.stderr}\n${probe.stdout}`)) return false;
|
|
264
|
+
warn("The registry rejected the image pull — this box's image credential is missing or expired.");
|
|
265
|
+
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
266
|
+
warn("Run `alexandr login` to refresh it, then re-run this command.");
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
return await refreshRegistryLogin(inst, flags);
|
|
271
|
+
} catch (e) {
|
|
272
|
+
warn(`Couldn't refresh the credential: ${e.message}`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------- login
|
|
278
|
+
/** Refresh the runtime-image pull credential on demand — sign in, mint the pull
|
|
279
|
+
* credential, `docker login`. The standalone repair verb for `unauthorized` pulls. */
|
|
280
|
+
export async function login(flags) {
|
|
281
|
+
ensureDocker();
|
|
282
|
+
const inst = resolveInstance(flags);
|
|
283
|
+
if (!isMaterialized(inst.dir)) fail("No alexandr instance here. Run `alexandr up` first.", EXIT.NO_INSTANCE);
|
|
284
|
+
try {
|
|
285
|
+
if (await refreshRegistryLogin(inst, flags)) {
|
|
286
|
+
ok("Image credential refreshed — pulls use your account again.");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
fail(`Login aborted: ${e.message}`);
|
|
291
|
+
}
|
|
292
|
+
fail("The sign-in completed but the registry credential didn't apply — see the warnings above.");
|
|
293
|
+
}
|
|
294
|
+
|
|
246
295
|
// ---------------------------------------------------------------- down
|
|
247
296
|
export async function down(flags) {
|
|
248
297
|
ensureDocker();
|
|
@@ -258,13 +307,46 @@ export async function destroy(flags) {
|
|
|
258
307
|
ensureDocker();
|
|
259
308
|
const inst = resolveInstance(flags);
|
|
260
309
|
if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
|
|
261
|
-
|
|
262
|
-
|
|
310
|
+
let wipe = Boolean(flags.volumes || flags.data);
|
|
311
|
+
let unlink = Boolean(flags.unlink);
|
|
312
|
+
|
|
313
|
+
// Interactive teardown (owner feedback, 2026-08-14): a destructive verb should ASK about
|
|
314
|
+
// everything it could take, not hide the data behind a flag you learn about afterwards.
|
|
315
|
+
// On a TTY without --yes, walk the choices; flags pre-answer their question. Non-TTY
|
|
316
|
+
// keeps the strict behavior: destructive combinations demand --yes (exit 8), scripts
|
|
317
|
+
// stay explicit.
|
|
318
|
+
const interactive = !confirmed(flags) && Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
319
|
+
if (interactive) {
|
|
320
|
+
if (!wipe) {
|
|
321
|
+
wipe = await select("Also delete the workspace data (the /data volume)?", [
|
|
322
|
+
{ label: "Keep the data", hint: "containers go, /data stays — `alexandr up` resumes it", value: false },
|
|
323
|
+
{ label: "Delete everything", hint: "workspace DB, apps, files — irreversible", value: true },
|
|
324
|
+
]);
|
|
325
|
+
}
|
|
326
|
+
if (!unlink && isLinked(inst.dir)) {
|
|
327
|
+
unlink = await select("Also remove this runtime from your alexandr account?", [
|
|
328
|
+
{ label: "Keep the account link", hint: "the workspace stays listed in your hub", value: false },
|
|
329
|
+
{ label: "Remove it", hint: "sign-in required — the hub entry disappears", value: true },
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
const go = await select(
|
|
333
|
+
wipe ? "Destroy this runtime AND its data?" : "Destroy this runtime (data preserved)?",
|
|
334
|
+
[
|
|
335
|
+
{ label: "Cancel", value: false },
|
|
336
|
+
{ label: wipe ? "Yes, destroy everything" : "Yes, destroy it", value: true },
|
|
337
|
+
],
|
|
338
|
+
);
|
|
339
|
+
if (!go) {
|
|
340
|
+
log(dim("Nothing touched."));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
} else if (wipe && !confirmed(flags)) {
|
|
263
344
|
fail("`destroy --volumes` deletes /data (workspace DB, apps, blobs) irreversibly. Re-run with --yes to confirm.", EXIT.CONFIRMATION);
|
|
264
345
|
}
|
|
346
|
+
|
|
265
347
|
// Optional account-side cleanup: sign in and remove the workspace record too, so the hub
|
|
266
348
|
// doesn't keep a phantom entry. Off by default — destroying the containers never needs it.
|
|
267
|
-
if (
|
|
349
|
+
if (unlink && isLinked(inst.dir)) {
|
|
268
350
|
if (await unlinkFromAccount(inst, flags)) ok("Removed from your account.");
|
|
269
351
|
else warn("Couldn't remove it from your account — remove it from your account page instead.");
|
|
270
352
|
}
|
|
@@ -489,7 +571,12 @@ export async function update(flags) {
|
|
|
489
571
|
step("Offline update — using the locally-present image (no pull).");
|
|
490
572
|
} else {
|
|
491
573
|
step("Pulling the runtime image…");
|
|
492
|
-
|
|
574
|
+
let pull = compose(inst.dir, inst.projectName, ["pull", "kernel"]);
|
|
575
|
+
if (pull.status !== 0 && (await recoverUnauthorizedPull(inst, flags))) {
|
|
576
|
+
step("Retrying the pull…");
|
|
577
|
+
pull = compose(inst.dir, inst.projectName, ["pull", "kernel"]);
|
|
578
|
+
}
|
|
579
|
+
if (pull.status !== 0) {
|
|
493
580
|
fail("Pull failed — check your network / the image tag.", EXIT.RUNTIME);
|
|
494
581
|
}
|
|
495
582
|
}
|
package/src/completion.js
CHANGED
|
@@ -18,6 +18,7 @@ export const COMMANDS = [
|
|
|
18
18
|
{ name: "logs", usage: "logs [-f]", summary: "Show kernel logs (-f to follow)" },
|
|
19
19
|
{ name: "connect", usage: "connect", summary: "Print the desktop-app connect link / paste-string" },
|
|
20
20
|
{ name: "link", usage: "link", summary: "Re-link this runtime to your alexandr account (--force re-registers)" },
|
|
21
|
+
{ name: "login", usage: "login", summary: "Refresh the runtime-image pull credential (sign in, no re-register)" },
|
|
21
22
|
{ name: "update", usage: "update", summary: "Update the runtime image (--to <tag>, --rollback)" },
|
|
22
23
|
{ name: "backup", usage: "backup", summary: "Archive the data volume (--out <file>)" },
|
|
23
24
|
{ name: "restore", usage: "restore <f>", summary: "Restore a data-volume archive (--yes)" },
|
package/src/link.js
CHANGED
|
@@ -15,7 +15,7 @@ import os from "node:os";
|
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import readline from "node:readline";
|
|
17
17
|
import { log, dim, bold, cyan, fail, step, ok, warn, openURL, sleep } from "./util.js";
|
|
18
|
-
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv } from "./instance.js";
|
|
18
|
+
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv, unsetEnv } from "./instance.js";
|
|
19
19
|
import { kernelUrl, health, waitPosture } from "./probe.js";
|
|
20
20
|
import { compose, exec } from "./docker.js";
|
|
21
21
|
|
|
@@ -95,13 +95,7 @@ export async function link(flags) {
|
|
|
95
95
|
* Two grant shapes, one registration: the DEVICE flow on headless machines (a short code
|
|
96
96
|
* confirmed from any browser — no tunnel), the instant loopback PKCE redirect on desktops. */
|
|
97
97
|
export async function runLinkCeremony(inst, flags) {
|
|
98
|
-
|
|
99
|
-
// .env) for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
100
|
-
// locally — and the runtime self-reports its origin on heartbeat either way).
|
|
101
|
-
const domain = flags.domain || readEnv(inst.dir).ALEXANDR_DOMAIN;
|
|
102
|
-
const boxUrl = domain
|
|
103
|
-
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
104
|
-
: kernelUrl(kernelPort(inst.dir));
|
|
98
|
+
const { domain, boxUrl } = instanceCoords(inst, flags);
|
|
105
99
|
const name =
|
|
106
100
|
(typeof flags.name === "string" && flags.name.trim()) ||
|
|
107
101
|
(readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() ||
|
|
@@ -121,9 +115,20 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
121
115
|
await registerAndPersist(inst, { boxUrl, name, sessionToken });
|
|
122
116
|
}
|
|
123
117
|
|
|
118
|
+
/** The URL the CP records + the consent card names. --domain (or a domain already in .env)
|
|
119
|
+
* for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
120
|
+
* locally — and the runtime self-reports its origin on heartbeat either way). */
|
|
121
|
+
function instanceCoords(inst, flags) {
|
|
122
|
+
const domain = flags.domain || readEnv(inst.dir).ALEXANDR_DOMAIN;
|
|
123
|
+
const boxUrl = domain
|
|
124
|
+
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
125
|
+
: kernelUrl(kernelPort(inst.dir));
|
|
126
|
+
return { domain, boxUrl };
|
|
127
|
+
}
|
|
128
|
+
|
|
124
129
|
/** The DESKTOP grant — OAuth authorization-code + PKCE against a loopback redirect the
|
|
125
130
|
* browser on THIS machine can reach. Returns the short-lived session token. */
|
|
126
|
-
async function loopbackGrantToken({ boxUrl, name, domain }) {
|
|
131
|
+
async function loopbackGrantToken({ boxUrl, name, domain, intent }) {
|
|
127
132
|
// PKCE (S256) + a CSRF state for the loopback redirect.
|
|
128
133
|
const verifier = b64url(crypto.randomBytes(32));
|
|
129
134
|
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
@@ -140,6 +145,9 @@ async function loopbackGrantToken({ boxUrl, name, domain }) {
|
|
|
140
145
|
code_challenge_method: "S256",
|
|
141
146
|
host: boxUrl,
|
|
142
147
|
name,
|
|
148
|
+
// Copy-only hint for the consent card ("refresh" renews the image credential and
|
|
149
|
+
// registers nothing) — the grant's power is identical either way.
|
|
150
|
+
...(intent && intent !== "link" ? { intent } : {}),
|
|
143
151
|
}).toString();
|
|
144
152
|
|
|
145
153
|
await presentAuthUrl(authUrl, { port, domain });
|
|
@@ -258,24 +266,54 @@ async function registryLogin(sessionToken) {
|
|
|
258
266
|
{},
|
|
259
267
|
{ authorization: `Bearer ${sessionToken}` },
|
|
260
268
|
);
|
|
261
|
-
if (!r.data?.token || !r.data?.username)
|
|
269
|
+
if (!r.data?.token || !r.data?.username) {
|
|
270
|
+
// LOUD on purpose (the silent skip hid a dead prod credential on the first real
|
|
271
|
+
// install, 2026-08-14): the image is private, so no credential = the pull WILL fail.
|
|
272
|
+
warn(
|
|
273
|
+
`The control plane couldn't provide the runtime-image credential (${r.error ?? "malformed response"}) — the image pull will fail. Retry later, or contact your operator.`,
|
|
274
|
+
);
|
|
275
|
+
return "none";
|
|
276
|
+
}
|
|
262
277
|
pendingPullCred = r.data;
|
|
263
|
-
applyRegistryLogin();
|
|
278
|
+
return applyRegistryLogin();
|
|
264
279
|
}
|
|
265
280
|
|
|
266
|
-
/** Run the held `docker login` if the docker CLI is available;
|
|
267
|
-
* re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering.
|
|
281
|
+
/** Run the held `docker login` if the docker CLI is available; "held" otherwise (the caller
|
|
282
|
+
* re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering.
|
|
283
|
+
* Returns "done" | "failed" | "held" | "none" so recovery flows can branch on the outcome. */
|
|
268
284
|
export function applyRegistryLogin() {
|
|
269
|
-
if (!pendingPullCred) return;
|
|
270
|
-
if (exec("docker", ["--version"]).status !== 0) return; // not installed yet — hold on
|
|
285
|
+
if (!pendingPullCred) return "none";
|
|
286
|
+
if (exec("docker", ["--version"]).status !== 0) return "held"; // not installed yet — hold on
|
|
271
287
|
const { username, token } = pendingPullCred;
|
|
272
288
|
const registry = pendingPullCred.registry || "ghcr.io";
|
|
273
289
|
const login = exec("docker", ["login", registry, "-u", username, "--password-stdin"], {
|
|
274
290
|
input: token,
|
|
275
291
|
});
|
|
276
|
-
if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
|
|
277
|
-
else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
|
|
278
292
|
pendingPullCred = null;
|
|
293
|
+
if (login.status === 0) {
|
|
294
|
+
log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
|
|
295
|
+
return "done";
|
|
296
|
+
}
|
|
297
|
+
warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
|
|
298
|
+
return "failed";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Refresh THIS box's image-pull credential without touching its registration — the recovery
|
|
303
|
+
* for an expired/missing `docker login` (fleet-wide whenever the CP-held PAT rotates). A
|
|
304
|
+
* quick sign-in (device flow on servers, loopback on desktops, intent "refresh" so the
|
|
305
|
+
* consent card says what's actually happening) mints the short-lived session, and the
|
|
306
|
+
* pull credential rides it. Returns true when the docker login landed. Throws on ceremony
|
|
307
|
+
* failure — callers decide between fail() and a soft fallback.
|
|
308
|
+
*/
|
|
309
|
+
export async function refreshRegistryLogin(inst, flags) {
|
|
310
|
+
const { domain, boxUrl } = instanceCoords(inst, flags);
|
|
311
|
+
const name = (readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() || "Self-hosted runtime";
|
|
312
|
+
step("Sign in to refresh this runtime's image credential…");
|
|
313
|
+
const token = useDeviceFlow()
|
|
314
|
+
? await deviceGrantToken(boxUrl, name, "refresh")
|
|
315
|
+
: await loopbackGrantToken({ boxUrl, name, domain, intent: "refresh" });
|
|
316
|
+
return (await registryLogin(token)) === "done";
|
|
279
317
|
}
|
|
280
318
|
|
|
281
319
|
/**
|
|
@@ -304,6 +342,7 @@ export async function unlinkFromAccount(inst, flags) {
|
|
|
304
342
|
method: "DELETE",
|
|
305
343
|
headers: { authorization: `Bearer ${token}` },
|
|
306
344
|
});
|
|
345
|
+
if (res.ok) scrubCredentials(inst.dir);
|
|
307
346
|
return res.ok;
|
|
308
347
|
} catch {
|
|
309
348
|
return false;
|
|
@@ -347,12 +386,22 @@ export async function unlinkFromAccount(inst, flags) {
|
|
|
347
386
|
method: "DELETE",
|
|
348
387
|
headers: { authorization: `Bearer ${tok.data.token}` },
|
|
349
388
|
});
|
|
389
|
+
if (res.ok) scrubCredentials(inst.dir);
|
|
350
390
|
return res.ok;
|
|
351
391
|
} catch {
|
|
352
392
|
return false;
|
|
353
393
|
}
|
|
354
394
|
}
|
|
355
395
|
|
|
396
|
+
/** After a successful unlink the .env trio is DEAD — presence isn't validity, and leaving
|
|
397
|
+
* it made the next `up` skip the ceremony entirely and die at an unauthorized pull (hit
|
|
398
|
+
* live 2026-08-14). Scrub it so `isLinked` answers honestly and `up` signs in afresh. */
|
|
399
|
+
function scrubCredentials(dir) {
|
|
400
|
+
unsetEnv(dir, "ALEXANDR_INSTANCE_ID");
|
|
401
|
+
unsetEnv(dir, "ALEXANDR_RUNTIME_SECRET");
|
|
402
|
+
unsetEnv(dir, "ALEXANDR_WORKSPACE_ID");
|
|
403
|
+
}
|
|
404
|
+
|
|
356
405
|
/** No local browser to open — a Linux box with no display server. The PRIMARY self-host
|
|
357
406
|
* case, so it must be first-class, not a dim afterthought. Pure for tests. */
|
|
358
407
|
export function isHeadless(platform = process.platform, env = process.env) {
|