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 +14 -8
- package/bin.js +15 -2
- package/package.json +2 -2
- package/src/commands.js +23 -11
- package/src/link.js +86 -19
- package/templates/env.example +2 -0
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/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
|
|
4
|
-
|
|
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.
|
|
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
|
+
"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)
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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
|
|
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()
|
|
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
|
-
|
|
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
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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();
|
package/templates/env.example
CHANGED
|
@@ -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
|