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 +14 -8
- package/package.json +1 -1
- package/src/commands.js +17 -11
- package/src/link.js +68 -17
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
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)
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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;
|