alexandr 0.2.1 → 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.1",
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();
@@ -522,7 +571,12 @@ export async function update(flags) {
522
571
  step("Offline update — using the locally-present image (no pull).");
523
572
  } else {
524
573
  step("Pulling the runtime image…");
525
- 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) {
526
580
  fail("Pull failed — check your network / the image tag.", EXIT.RUNTIME);
527
581
  }
528
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 });
@@ -264,25 +272,48 @@ async function registryLogin(sessionToken) {
264
272
  warn(
265
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.`,
266
274
  );
267
- return;
275
+ return "none";
268
276
  }
269
277
  pendingPullCred = r.data;
270
- applyRegistryLogin();
278
+ return applyRegistryLogin();
271
279
  }
272
280
 
273
- /** Run the held `docker login` if the docker CLI is available; no-op otherwise (the caller
274
- * 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. */
275
284
  export function applyRegistryLogin() {
276
- if (!pendingPullCred) return;
277
- 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
278
287
  const { username, token } = pendingPullCred;
279
288
  const registry = pendingPullCred.registry || "ghcr.io";
280
289
  const login = exec("docker", ["login", registry, "-u", username, "--password-stdin"], {
281
290
  input: token,
282
291
  });
283
- if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
284
- else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
285
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";
286
317
  }
287
318
 
288
319
  /**
@@ -311,6 +342,7 @@ export async function unlinkFromAccount(inst, flags) {
311
342
  method: "DELETE",
312
343
  headers: { authorization: `Bearer ${token}` },
313
344
  });
345
+ if (res.ok) scrubCredentials(inst.dir);
314
346
  return res.ok;
315
347
  } catch {
316
348
  return false;
@@ -354,12 +386,22 @@ export async function unlinkFromAccount(inst, flags) {
354
386
  method: "DELETE",
355
387
  headers: { authorization: `Bearer ${tok.data.token}` },
356
388
  });
389
+ if (res.ok) scrubCredentials(inst.dir);
357
390
  return res.ok;
358
391
  } catch {
359
392
  return false;
360
393
  }
361
394
  }
362
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
+
363
405
  /** No local browser to open — a Linux box with no display server. The PRIMARY self-host
364
406
  * case, so it must be first-class, not a dim afterthought. Pure for tests. */
365
407
  export function isHeadless(platform = process.platform, env = process.env) {