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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexandr",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Run the alexandr workspace runtime locally — a thin Docker front door (npx alexandr up). Pulls + boots the published kernel image.",
5
5
  "type": "module",
6
6
  "bin": {
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 { ensureLinked, isLinked, runLinkCeremony, unlinkFromAccount, applyRegistryLogin } from "./link.js";
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
- const r = compose(inst.dir, inst.projectName, args);
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
- const wipe = Boolean(flags.volumes || flags.data);
262
- if (wipe && !confirmed(flags)) {
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 (flags.unlink && isLinked(inst.dir)) {
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
- if (compose(inst.dir, inst.projectName, ["pull", "kernel"]).status !== 0) {
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
- // The URL the CP records + the SSO handoff redirects back to. --domain (or a domain already in
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) return;
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; no-op otherwise (the caller
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) {