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 +4 -1
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands.js +57 -3
- package/src/completion.js +1 -0
- package/src/link.js +59 -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();
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
|
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) {
|