alexandr 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexandr",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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/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);
@@ -180,11 +174,23 @@ export async function up(flags) {
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));
182
176
 
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.
177
+ // Sign-in comes FIRST (account-required-runtimes D3) now literally: the entitlement
178
+ // gate fires before anything system-mutating, so a refused account walks away from a
179
+ // box holding three text files in ~/.alexandr, not a Docker install. Only the wizard
180
+ // precedes it, because its questions are free and the consent card should name the
181
+ // real domain. The ceremony is a no-op when .env already carries the credential trio,
182
+ // so re-running `alexandr up` never re-prompts.
187
183
  await ensureLinked(inst, flags);
184
+
185
+ // Dependencies AFTER auth — only an entitled account installs anything. On a fresh
186
+ // Linux box `up` offers to set up Docker + Compose itself (deps.js), then re-checks;
187
+ // declined / non-Linux / non-TTY → the normal per-cause failure.
188
+ let problems = dockerProblems();
189
+ if (problems.length && (await offerDependencyInstall(problems))) problems = dockerProblems();
190
+ ensureDocker(problems);
191
+ // The pull credential fetched at the ceremony was held until docker existed — apply it.
192
+ applyRegistryLogin();
193
+
188
194
  const hasDomain = envHas(inst.dir, "ALEXANDR_DOMAIN");
189
195
 
190
196
  // 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";
@@ -120,11 +122,7 @@ export async function runLinkCeremony(inst, flags) {
120
122
  }).toString();
121
123
 
122
124
  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);
125
+ await presentAuthUrl(authUrl, { port, domain });
128
126
 
129
127
  let cb;
130
128
  try {
@@ -177,14 +175,17 @@ export async function runLinkCeremony(inst, flags) {
177
175
  }
178
176
 
179
177
  /**
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.
178
+ * The CP-minted pull credential (docs/plans/private-runtime-image.md): the kernel image is
179
+ * a private package, and the CP gates the credential on the same entitlement as
180
+ * registration so a session that just registered can always fetch it. Fetched IMMEDIATELY
181
+ * after the ceremony (the login session is short-lived) but the `docker login` may run
182
+ * LATER: under sign-in-first ordering, auth precedes the Docker install, so the credential
183
+ * is held in memory until the docker CLI exists (`applyRegistryLogin`). Docker persists the
184
+ * login in its credential store that is what `alexandr update`'s later pulls ride on.
185
+ * Older CP (404) / credential unconfigured (503) / any other failure → skip quietly.
187
186
  */
187
+ let pendingPullCred = null;
188
+
188
189
  async function registryLogin(sessionToken) {
189
190
  const r = await postJson(
190
191
  `${CP_URL}/registry/pull-token`,
@@ -192,12 +193,23 @@ async function registryLogin(sessionToken) {
192
193
  { authorization: `Bearer ${sessionToken}` },
193
194
  );
194
195
  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,
196
+ pendingPullCred = r.data;
197
+ applyRegistryLogin();
198
+ }
199
+
200
+ /** Run the held `docker login` if the docker CLI is available; no-op otherwise (the caller
201
+ * re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering. */
202
+ export function applyRegistryLogin() {
203
+ if (!pendingPullCred) return;
204
+ if (exec("docker", ["--version"]).status !== 0) return; // not installed yet — hold on
205
+ const { username, token } = pendingPullCred;
206
+ const registry = pendingPullCred.registry || "ghcr.io";
207
+ const login = exec("docker", ["login", registry, "-u", username, "--password-stdin"], {
208
+ input: token,
198
209
  });
199
210
  if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
200
211
  else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
212
+ pendingPullCred = null;
201
213
  }
202
214
 
203
215
  /**
@@ -228,7 +240,7 @@ export async function unlinkFromAccount(inst, flags) {
228
240
  name: "Unlink this runtime",
229
241
  }).toString();
230
242
  step("Sign in to remove this runtime from your account…");
231
- openURL(authUrl);
243
+ await presentAuthUrl(authUrl, { port, domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined });
232
244
  let cb;
233
245
  try {
234
246
  cb = await done;
@@ -257,6 +269,45 @@ export async function unlinkFromAccount(inst, flags) {
257
269
  }
258
270
  }
259
271
 
272
+ /** No local browser to open — a Linux box with no display server. The PRIMARY self-host
273
+ * case, so it must be first-class, not a dim afterthought. Pure for tests. */
274
+ export function isHeadless(platform = process.platform, env = process.env) {
275
+ return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
276
+ }
277
+
278
+ /**
279
+ * Present the consent URL honestly, by what this machine can actually do:
280
+ * - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel FIRST
281
+ * (the redirect lands on the desktop's loopback and must reach this box), URL bright.
282
+ * - DESKTOP + TTY: ask before taking over the browser — the URL is printed either way,
283
+ * so "open it yourself" is always available.
284
+ * - DESKTOP non-TTY (scripts): old behavior — print + best-effort open, nothing blocks.
285
+ */
286
+ export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
287
+ const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
288
+ if (headless) {
289
+ log("");
290
+ step("This machine has no browser — finish the sign-in from your computer:");
291
+ log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
292
+ log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
293
+ log(` 2. Open this link in a browser signed in to your alexandr account:`);
294
+ log(` ${cyan(authUrl)}`);
295
+ log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
296
+ return;
297
+ }
298
+ log(dim(authUrl));
299
+ if (process.stdin.isTTY && process.stdout.isTTY) {
300
+ await new Promise((resolve) => {
301
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
302
+ rl.question(`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `, () => {
303
+ rl.close();
304
+ resolve();
305
+ });
306
+ });
307
+ }
308
+ openURL(authUrl);
309
+ }
310
+
260
311
  /** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
261
312
  function startLoopback() {
262
313
  let resolveFn, rejectFn;