alexandr 0.1.0 → 0.1.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
@@ -22,14 +22,20 @@ port or domain, and the workspace name. Flags (`--domain`, `--port`, `--name`),
22
22
  `--yes`, or a non-TTY skip the questions and keep `up` fully scriptable; re-runs
23
23
  never prompt (`up` stays an idempotent restart).
24
24
 
25
- On a fresh **Linux** server `up` also handles its own dependencies: when Docker
26
- or Compose v2 is missing it offers to install them right there (Docker's
27
- official `get.docker.com` script + a systemd start; root or sudo, always asked
28
- first). macOS/Windows get instructions instead Docker Desktop can't be
29
- installed silently.
30
-
31
- Sign-in comes next: every runtime is linked to an alexandr account before it
32
- serves anyone `up` opens the browser once, then boots the box already linked.
25
+ Sign-in comes right after the questions: every runtime is linked to an alexandr
26
+ account before it serves anyone, and the entitlement gate fires **before anything
27
+ touches the system** an account that may not register walks away from a box
28
+ holding three text files, not a Docker install. On a desktop the CLI asks before
29
+ opening your browser; on a headless server it prints the tunnel + link steps to
30
+ run from your computer.
31
+
32
+ Only after sign-in does `up` handle dependencies: on a fresh **Linux** server,
33
+ when Docker or Compose v2 is missing it offers to install them right there
34
+ (Docker's official `get.docker.com` script + a systemd start; root or sudo,
35
+ always asked first). macOS/Windows get instructions instead. The runtime image
36
+ itself is a private package — it pulls with a credential the control plane mints
37
+ for your signed-in account, so the download is gated by the same entitlement as
38
+ registration.
33
39
 
34
40
  ## Commands
35
41
 
package/bin.js CHANGED
@@ -1,7 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // Public entrypoint for the `alexandr` CLI. Keep this a thin shim; logic lives
3
- // in ./src. Plain Node (no Bun, no deps) so it runs anywhere Node 20+ is.
4
- import { run } from "./src/cli.js";
3
+ // in ./src. Plain Node (no Bun, no deps) so it runs anywhere Node 18+ is.
4
+
5
+ // Our own version guard, so nobody meets npm's EBADENGINE wall: package.json
6
+ // declares >=18 (what Ubuntu LTS's apt ships, and provably sufficient — the CLI
7
+ // is dependency-free), and anything older gets ONE styled line instead of a trace.
8
+ const major = Number(process.versions.node.split(".")[0]);
9
+ if (major < 18) {
10
+ process.stderr.write(
11
+ `\x1b[31m✗\x1b[0m alexandr needs Node 18 or newer — this is Node ${process.versions.node}.\n` +
12
+ ` On Ubuntu/Debian: apt install -y npm (or use nodesource for a current LTS)\n`,
13
+ );
14
+ process.exit(1);
15
+ }
16
+
17
+ const { run } = await import("./src/cli.js");
5
18
 
6
19
  run().catch((err) => {
7
20
  process.stderr.write(`${err?.stack || err}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexandr",
3
- "version": "0.1.0",
3
+ "version": "0.1.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": {
@@ -17,7 +17,7 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": ">=18"
21
21
  },
22
22
  "license": "Apache-2.0",
23
23
  "repository": {
package/src/commands.js CHANGED
@@ -19,7 +19,7 @@ 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 } from "./link.js";
22
+ import { ensureLinked, isLinked, runLinkCeremony, unlinkFromAccount, applyRegistryLogin } from "./link.js";
23
23
  import { select, ask } from "./prompt.js";
24
24
  import { offerDependencyInstall } from "./deps.js";
25
25
  import { EXIT } from "./exit.js";
@@ -164,12 +164,6 @@ async function firstRunWizard(flags) {
164
164
  }
165
165
 
166
166
  export async function up(flags) {
167
- // Install (the missing dependencies), don't just diagnose: on a fresh Linux box, `up`
168
- // offers to set up Docker + Compose itself (deps.js), then re-checks. Declined /
169
- // non-Linux / non-TTY → the normal per-cause failure below.
170
- let problems = dockerProblems();
171
- if (problems.length && (await offerDependencyInstall(problems))) problems = dockerProblems();
172
- ensureDocker(problems);
173
167
  const inst = resolveInstance(flags);
174
168
  const fresh = !isMaterialized(inst.dir);
175
169
  materialize(inst.dir);
@@ -179,12 +173,30 @@ export async function up(flags) {
179
173
 
180
174
  if (flags.port) setEnv(inst.dir, "ALEXANDR_KERNEL_PORT", String(flags.port));
181
175
  if (flags.domain) setEnv(inst.dir, "ALEXANDR_DOMAIN", String(flags.domain));
176
+ // Persist the display name so a RETRIED ceremony reuses the wizard's answer — the
177
+ // first attempt on the aos box lost "AOS" to the default because it lived only in
178
+ // that run's memory (the ceremony it fed timed out).
179
+ if (typeof flags.name === "string" && flags.name.trim()) {
180
+ setEnv(inst.dir, "ALEXANDR_WORKSPACE_NAME", flags.name.trim());
181
+ }
182
182
 
183
- // Sign-in comes FIRST (account-required-runtimes D3): every runtime must be linked to an
184
- // account before it serves anyone an unlinked box boots into a static refusal page. The
185
- // ceremony is a no-op when the .env already carries the credential trio, so re-running
186
- // `alexandr up` never re-prompts.
183
+ // Sign-in comes FIRST (account-required-runtimes D3) now literally: the entitlement
184
+ // gate fires before anything system-mutating, so a refused account walks away from a
185
+ // box holding three text files in ~/.alexandr, not a Docker install. Only the wizard
186
+ // precedes it, because its questions are free and the consent card should name the
187
+ // real domain. The ceremony is a no-op when .env already carries the credential trio,
188
+ // so re-running `alexandr up` never re-prompts.
187
189
  await ensureLinked(inst, flags);
190
+
191
+ // Dependencies AFTER auth — only an entitled account installs anything. On a fresh
192
+ // Linux box `up` offers to set up Docker + Compose itself (deps.js), then re-checks;
193
+ // declined / non-Linux / non-TTY → the normal per-cause failure.
194
+ let problems = dockerProblems();
195
+ if (problems.length && (await offerDependencyInstall(problems))) problems = dockerProblems();
196
+ ensureDocker(problems);
197
+ // The pull credential fetched at the ceremony was held until docker existed — apply it.
198
+ applyRegistryLogin();
199
+
188
200
  const hasDomain = envHas(inst.dir, "ALEXANDR_DOMAIN");
189
201
 
190
202
  // Port pre-flight (clear message instead of an opaque Docker bind error).
package/src/link.js CHANGED
@@ -11,8 +11,10 @@
11
11
  // re-link/repair verb.
12
12
 
13
13
  import http from "node:http";
14
+ import os from "node:os";
14
15
  import crypto from "node:crypto";
15
- import { log, dim, fail, step, ok, warn, openURL } from "./util.js";
16
+ import readline from "node:readline";
17
+ import { log, dim, bold, cyan, fail, step, ok, warn, openURL } from "./util.js";
16
18
  import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv } from "./instance.js";
17
19
  import { kernelUrl, health, waitPosture } from "./probe.js";
18
20
  import { compose, exec } from "./docker.js";
@@ -99,7 +101,9 @@ export async function runLinkCeremony(inst, flags) {
99
101
  ? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
100
102
  : kernelUrl(kernelPort(inst.dir));
101
103
  const name =
102
- typeof flags.name === "string" && flags.name.trim() ? flags.name.trim() : "My self-hosted workspace";
104
+ (typeof flags.name === "string" && flags.name.trim()) ||
105
+ (readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() ||
106
+ "My self-hosted workspace";
103
107
 
104
108
  // PKCE (S256) + a CSRF state for the loopback redirect.
105
109
  const verifier = b64url(crypto.randomBytes(32));
@@ -120,11 +124,7 @@ export async function runLinkCeremony(inst, flags) {
120
124
  }).toString();
121
125
 
122
126
  step(`Link ${boxUrl} to your alexandr account`);
123
- log(dim("Opening your browser to sign in and confirm…"));
124
- log(dim(authUrl));
125
- log(dim("Headless box? Forward the callback port and open the URL from your desktop:"));
126
- log(dim(` ssh -L ${port}:127.0.0.1:${port} <this-server> (keep this command waiting)`));
127
- openURL(authUrl);
127
+ await presentAuthUrl(authUrl, { port, domain });
128
128
 
129
129
  let cb;
130
130
  try {
@@ -177,14 +177,17 @@ export async function runLinkCeremony(inst, flags) {
177
177
  }
178
178
 
179
179
  /**
180
- * Best-effort `docker login` with the CP-minted pull credential
181
- * (docs/plans/private-runtime-image.md): once the kernel image is a private package, the
182
- * compose pull that follows the ceremony needs it. The CP gates the credential on the same
183
- * entitlement as registration, so a session that just registered can always fetch it.
184
- * Docker persists the login in its credential store that is what `alexandr update`'s
185
- * later pulls ride on. Older CP (404) / credential unconfigured (503) / any other failure
186
- * skip quietly: anonymous pulls keep working while the package is public.
180
+ * The CP-minted pull credential (docs/plans/private-runtime-image.md): the kernel image is
181
+ * a private package, and the CP gates the credential on the same entitlement as
182
+ * registration so a session that just registered can always fetch it. Fetched IMMEDIATELY
183
+ * after the ceremony (the login session is short-lived) but the `docker login` may run
184
+ * LATER: under sign-in-first ordering, auth precedes the Docker install, so the credential
185
+ * is held in memory until the docker CLI exists (`applyRegistryLogin`). Docker persists the
186
+ * login in its credential store that is what `alexandr update`'s later pulls ride on.
187
+ * Older CP (404) / credential unconfigured (503) / any other failure → skip quietly.
187
188
  */
189
+ let pendingPullCred = null;
190
+
188
191
  async function registryLogin(sessionToken) {
189
192
  const r = await postJson(
190
193
  `${CP_URL}/registry/pull-token`,
@@ -192,12 +195,23 @@ async function registryLogin(sessionToken) {
192
195
  { authorization: `Bearer ${sessionToken}` },
193
196
  );
194
197
  if (!r.data?.token || !r.data?.username) return;
195
- const registry = r.data.registry || "ghcr.io";
196
- const login = exec("docker", ["login", registry, "-u", r.data.username, "--password-stdin"], {
197
- input: r.data.token,
198
+ pendingPullCred = r.data;
199
+ applyRegistryLogin();
200
+ }
201
+
202
+ /** Run the held `docker login` if the docker CLI is available; no-op otherwise (the caller
203
+ * re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering. */
204
+ export function applyRegistryLogin() {
205
+ if (!pendingPullCred) return;
206
+ if (exec("docker", ["--version"]).status !== 0) return; // not installed yet — hold on
207
+ const { username, token } = pendingPullCred;
208
+ const registry = pendingPullCred.registry || "ghcr.io";
209
+ const login = exec("docker", ["login", registry, "-u", username, "--password-stdin"], {
210
+ input: token,
198
211
  });
199
212
  if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
200
213
  else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
214
+ pendingPullCred = null;
201
215
  }
202
216
 
203
217
  /**
@@ -228,7 +242,7 @@ export async function unlinkFromAccount(inst, flags) {
228
242
  name: "Unlink this runtime",
229
243
  }).toString();
230
244
  step("Sign in to remove this runtime from your account…");
231
- openURL(authUrl);
245
+ await presentAuthUrl(authUrl, { port, domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined });
232
246
  let cb;
233
247
  try {
234
248
  cb = await done;
@@ -257,8 +271,47 @@ export async function unlinkFromAccount(inst, flags) {
257
271
  }
258
272
  }
259
273
 
274
+ /** No local browser to open — a Linux box with no display server. The PRIMARY self-host
275
+ * case, so it must be first-class, not a dim afterthought. Pure for tests. */
276
+ export function isHeadless(platform = process.platform, env = process.env) {
277
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
278
+ }
279
+
280
+ /**
281
+ * Present the consent URL honestly, by what this machine can actually do:
282
+ * - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel FIRST
283
+ * (the redirect lands on the desktop's loopback and must reach this box), URL bright.
284
+ * - DESKTOP + TTY: ask before taking over the browser — the URL is printed either way,
285
+ * so "open it yourself" is always available.
286
+ * - DESKTOP non-TTY (scripts): old behavior — print + best-effort open, nothing blocks.
287
+ */
288
+ export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
289
+ const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
290
+ if (headless) {
291
+ log("");
292
+ step("This machine has no browser — finish the sign-in from your computer:");
293
+ log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
294
+ log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
295
+ log(` 2. Open this link in a browser signed in to your alexandr account:`);
296
+ log(` ${cyan(authUrl)}`);
297
+ log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
298
+ return;
299
+ }
300
+ log(dim(authUrl));
301
+ if (process.stdin.isTTY && process.stdout.isTTY) {
302
+ await new Promise((resolve) => {
303
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
304
+ rl.question(`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `, () => {
305
+ rl.close();
306
+ resolve();
307
+ });
308
+ });
309
+ }
310
+ openURL(authUrl);
311
+ }
312
+
260
313
  /** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
261
- function startLoopback() {
314
+ export function startLoopback() {
262
315
  let resolveFn, rejectFn;
263
316
  const done = new Promise((res, rej) => {
264
317
  resolveFn = res;
@@ -266,6 +319,20 @@ function startLoopback() {
266
319
  });
267
320
  const server = http.createServer((req, res) => {
268
321
  const u = new URL(req.url, "http://127.0.0.1");
322
+ // Reachability probe for the consent page: /cli-auth pings this before the user
323
+ // clicks Link, so a missing ssh tunnel becomes a guided "start the tunnel" notice
324
+ // instead of a dead browser error page after the click. The PNA header answers
325
+ // Chrome's public→loopback preflight; ACAO lets the page read the success.
326
+ if (u.pathname === "/ping") {
327
+ res.writeHead(204, {
328
+ "access-control-allow-origin": "*",
329
+ "access-control-allow-methods": "GET, OPTIONS",
330
+ "access-control-allow-headers": "*",
331
+ "access-control-allow-private-network": "true",
332
+ });
333
+ res.end();
334
+ return;
335
+ }
269
336
  if (u.pathname !== "/callback") {
270
337
  res.writeHead(404);
271
338
  res.end();
@@ -21,6 +21,8 @@
21
21
  # ALEXANDR_INSTANCE_ID=
22
22
  # ALEXANDR_RUNTIME_SECRET=
23
23
  # ALEXANDR_WORKSPACE_ID= # account-side record id (used by `destroy --unlink`)
24
+ # ALEXANDR_WORKSPACE_NAME= # display name for registration (the wizard writes it,
25
+ # # so a retried sign-in reuses your answer)
24
26
 
25
27
  # Public domain for Caddy auto-HTTPS. Setting this starts the "public" profile — the
26
28
  # supported way to reach the runtime from another machine. Without a domain the