alexandr 0.0.1 → 0.1.0
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/LICENSE +202 -0
- package/README.md +105 -6
- package/bin.js +8 -13
- package/package.json +15 -5
- package/src/cli.js +83 -0
- package/src/commands.js +576 -0
- package/src/completion.js +122 -0
- package/src/connect.js +11 -0
- package/src/deps.js +91 -0
- package/src/docker.js +94 -0
- package/src/exit.js +37 -0
- package/src/instance.js +102 -0
- package/src/link.js +326 -0
- package/src/probe.js +47 -0
- package/src/prompt.js +116 -0
- package/src/util.js +96 -0
- package/templates/Caddyfile +6 -0
- package/templates/docker-compose.yml +51 -0
- package/templates/env.example +49 -0
package/src/link.js
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// The account-link ceremony — bind this runtime to an alexandr account via a browser login
|
|
2
|
+
// (docs/plans/runtime-connection-redesign.md Phase 4, Goal 2; account-required-runtimes D3).
|
|
3
|
+
// OAuth authorization-code + PKCE: we open the browser to the account site, the signed-in
|
|
4
|
+
// developer confirms linking THIS host, the loopback redirect hands back a code, we exchange it
|
|
5
|
+
// (with our PKCE verifier) for a short-lived token, and register this runtime. The connected
|
|
6
|
+
// coordinates land in the box's .env so it boots signed-in from the first request.
|
|
7
|
+
// Phishing-safe: whoever runs this on the box IS the authenticated owner (no confused deputy).
|
|
8
|
+
//
|
|
9
|
+
// Under account-required runtimes this is NOT optional: `alexandr up` runs `ensureLinked` as
|
|
10
|
+
// step one (an unlinked box refuses to serve), and `alexandr link` remains the explicit
|
|
11
|
+
// re-link/repair verb.
|
|
12
|
+
|
|
13
|
+
import http from "node:http";
|
|
14
|
+
import crypto from "node:crypto";
|
|
15
|
+
import { log, dim, fail, step, ok, warn, openURL } from "./util.js";
|
|
16
|
+
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv } from "./instance.js";
|
|
17
|
+
import { kernelUrl, health, waitPosture } from "./probe.js";
|
|
18
|
+
import { compose, exec } from "./docker.js";
|
|
19
|
+
|
|
20
|
+
const b64url = (buf) => buf.toString("base64url");
|
|
21
|
+
// Default to the hosted control plane + the WEBSITE (the one web property — the web app retired,
|
|
22
|
+
// website-account-surface.md); override for dev (e.g. http://localhost:4000 + http://localhost:3000)
|
|
23
|
+
// via env. The link-consent page (/cli-auth) + the account page are website surfaces now.
|
|
24
|
+
const CP_URL = (process.env.ALEXANDR_CP_URL || "https://api.alexandr.so").replace(/\/+$/, "");
|
|
25
|
+
const APP_URL = (process.env.ALEXANDR_APP_URL || "https://alexandr.so").replace(/\/+$/, "");
|
|
26
|
+
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
/** Whether this instance's .env already carries the connected credential trio. */
|
|
29
|
+
export function isLinked(dir) {
|
|
30
|
+
const env = readEnv(dir);
|
|
31
|
+
return Boolean(
|
|
32
|
+
(env.ALEXANDR_CP_URL || "").trim() &&
|
|
33
|
+
(env.ALEXANDR_INSTANCE_ID || "").trim() &&
|
|
34
|
+
(env.ALEXANDR_RUNTIME_SECRET || "").trim(),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Make sure this instance is linked to an account — the sign-in-first step of `alexandr up`.
|
|
40
|
+
* Already linked → returns false untouched. Otherwise runs the browser ceremony, writes the
|
|
41
|
+
* credential trio into .env, and returns true. Exits the process (fail) when the ceremony
|
|
42
|
+
* can't complete — there is no account-free fallback.
|
|
43
|
+
*/
|
|
44
|
+
export async function ensureLinked(inst, flags) {
|
|
45
|
+
if (isLinked(inst.dir)) return false;
|
|
46
|
+
step("This runtime isn't linked to an alexandr account yet — sign-in comes first.");
|
|
47
|
+
await runLinkCeremony(inst, flags);
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------- link (the explicit verb)
|
|
52
|
+
export async function link(flags) {
|
|
53
|
+
const inst = resolveInstance(flags);
|
|
54
|
+
if (!isMaterialized(inst.dir)) {
|
|
55
|
+
fail("No runtime here yet — run `alexandr up` (it signs you in as step one).");
|
|
56
|
+
}
|
|
57
|
+
if (isLinked(inst.dir) && !flags.force) {
|
|
58
|
+
// A revoked box (its workspace removed from the account) still carries the dead credential
|
|
59
|
+
// trio in .env — presence isn't validity. When the box is RUNNING and its health says it
|
|
60
|
+
// refuses, plain `link` is a repair, not a no-op. (--force stays the explicit override for
|
|
61
|
+
// a stopped box / re-registering afresh.)
|
|
62
|
+
const h = await health(kernelUrl(kernelPort(inst.dir)));
|
|
63
|
+
if (h?.auth?.posture === "unlinked") {
|
|
64
|
+
step(
|
|
65
|
+
h.auth.postureReason === "revoked"
|
|
66
|
+
? "This runtime's account link was revoked — signing you in to re-link…"
|
|
67
|
+
: "This runtime is running unlinked — signing you in to link it…",
|
|
68
|
+
);
|
|
69
|
+
} else {
|
|
70
|
+
ok("Already linked to an account. Re-run with --force to register it afresh.");
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await runLinkCeremony(inst, flags);
|
|
75
|
+
|
|
76
|
+
if (await isRunning(inst.dir)) {
|
|
77
|
+
step("Restarting the runtime so it boots signed-in…");
|
|
78
|
+
const r = compose(inst.dir, inst.projectName, ["up", "-d"], { stdio: "ignore" });
|
|
79
|
+
if (r.status !== 0) {
|
|
80
|
+
ok("Linked. Run `alexandr up` to apply (couldn't auto-restart).");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!(await waitPosture(kernelUrl(kernelPort(inst.dir)), "cp"))) {
|
|
84
|
+
warn("Linked, but the runtime didn't come back serving in time. Check `alexandr logs`.");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
ok(`Linked. This runtime signs in with your alexandr account — open it in the Alexandr app (your account: ${APP_URL}/account).`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The browser OAuth+PKCE ceremony + registration. Writes the .env credential trio on success;
|
|
92
|
+
* fails the process on any break in the chain. Exported for `up`'s revoked-recovery lane. */
|
|
93
|
+
export async function runLinkCeremony(inst, flags) {
|
|
94
|
+
// The URL the CP records + the SSO handoff redirects back to. --domain (or a domain already in
|
|
95
|
+
// .env) for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
96
|
+
// locally — and the runtime self-reports its origin on heartbeat either way).
|
|
97
|
+
const domain = flags.domain || readEnv(inst.dir).ALEXANDR_DOMAIN;
|
|
98
|
+
const boxUrl = domain
|
|
99
|
+
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
100
|
+
: kernelUrl(kernelPort(inst.dir));
|
|
101
|
+
const name =
|
|
102
|
+
typeof flags.name === "string" && flags.name.trim() ? flags.name.trim() : "My self-hosted workspace";
|
|
103
|
+
|
|
104
|
+
// PKCE (S256) + a CSRF state for the loopback redirect.
|
|
105
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
106
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
107
|
+
const state = b64url(crypto.randomBytes(16));
|
|
108
|
+
|
|
109
|
+
const { port, done } = await startLoopback();
|
|
110
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
111
|
+
const authUrl =
|
|
112
|
+
`${APP_URL}/cli-auth?` +
|
|
113
|
+
new URLSearchParams({
|
|
114
|
+
redirect_uri: redirectUri,
|
|
115
|
+
state,
|
|
116
|
+
code_challenge: challenge,
|
|
117
|
+
code_challenge_method: "S256",
|
|
118
|
+
host: boxUrl,
|
|
119
|
+
name,
|
|
120
|
+
}).toString();
|
|
121
|
+
|
|
122
|
+
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);
|
|
128
|
+
|
|
129
|
+
let cb;
|
|
130
|
+
try {
|
|
131
|
+
cb = await done;
|
|
132
|
+
} catch (e) {
|
|
133
|
+
fail(`Link aborted: ${e.message}`);
|
|
134
|
+
}
|
|
135
|
+
if (cb.state !== state) fail("Link aborted: state mismatch (possible interception).");
|
|
136
|
+
|
|
137
|
+
// Exchange the code (+ PKCE verifier) for a short-lived session, then register this runtime.
|
|
138
|
+
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
139
|
+
code: cb.code,
|
|
140
|
+
codeVerifier: verifier,
|
|
141
|
+
redirectUri,
|
|
142
|
+
});
|
|
143
|
+
if (!tok.data?.token) {
|
|
144
|
+
fail(`Link failed: could not exchange the authorization code (${tok.error ?? "malformed response"}).`);
|
|
145
|
+
}
|
|
146
|
+
const reg = await postJson(
|
|
147
|
+
`${CP_URL}/instances`,
|
|
148
|
+
{ name, url: boxUrl },
|
|
149
|
+
{ authorization: `Bearer ${tok.data.token}` },
|
|
150
|
+
);
|
|
151
|
+
if (!reg.data?.instanceId || !reg.data?.telemetryToken) {
|
|
152
|
+
fail(`Link failed: could not register this runtime (${reg.error ?? "malformed response"}).`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Write the connected coordinates into the box's .env so it boots signed-in. The box proves itself
|
|
156
|
+
// to the CP with its per-instance credential; for self-host that's the telemetry token (the CP
|
|
157
|
+
// hash-matches it on /code/exchange) carried in the runtime-secret slot. The workspace id isn't
|
|
158
|
+
// read by the kernel — it's kept for account-side operations (`destroy --unlink`).
|
|
159
|
+
//
|
|
160
|
+
// Loopback CP (a dev control plane on this machine): inside the kernel's container,
|
|
161
|
+
// "localhost" is the container itself — write the Docker host alias for the kernel's
|
|
162
|
+
// CP calls (JWKS, heartbeat) and keep the loopback form as the BROWSER-facing CP url
|
|
163
|
+
// (redirects/chrome run on the host). Real deployments (a resolvable CP domain) write
|
|
164
|
+
// one url, reachable from both.
|
|
165
|
+
const loopbackCp = /^https?:\/\/(localhost|127\.0\.0\.1)(:|$|\/)/.test(CP_URL);
|
|
166
|
+
if (loopbackCp) {
|
|
167
|
+
setEnv(inst.dir, "ALEXANDR_CP_URL", CP_URL.replace(/localhost|127\.0\.0\.1/, "host.docker.internal"));
|
|
168
|
+
setEnv(inst.dir, "ALEXANDR_CP_PUBLIC_URL", CP_URL);
|
|
169
|
+
} else {
|
|
170
|
+
setEnv(inst.dir, "ALEXANDR_CP_URL", CP_URL);
|
|
171
|
+
}
|
|
172
|
+
setEnv(inst.dir, "ALEXANDR_INSTANCE_ID", reg.data.instanceId);
|
|
173
|
+
setEnv(inst.dir, "ALEXANDR_RUNTIME_SECRET", reg.data.telemetryToken);
|
|
174
|
+
if (reg.data.workspaceId) setEnv(inst.dir, "ALEXANDR_WORKSPACE_ID", reg.data.workspaceId);
|
|
175
|
+
ok("Signed in — this runtime is linked to your account.");
|
|
176
|
+
await registryLogin(tok.data.token);
|
|
177
|
+
}
|
|
178
|
+
|
|
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.
|
|
187
|
+
*/
|
|
188
|
+
async function registryLogin(sessionToken) {
|
|
189
|
+
const r = await postJson(
|
|
190
|
+
`${CP_URL}/registry/pull-token`,
|
|
191
|
+
{},
|
|
192
|
+
{ authorization: `Bearer ${sessionToken}` },
|
|
193
|
+
);
|
|
194
|
+
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
|
+
});
|
|
199
|
+
if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
|
|
200
|
+
else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Sign in and remove this runtime's workspace from the account (`destroy --unlink`): the same
|
|
205
|
+
* browser ceremony for a fresh session, then `DELETE /instances/:workspaceId`. Best-effort by
|
|
206
|
+
* design — the caller already decided to destroy the local containers either way.
|
|
207
|
+
*/
|
|
208
|
+
export async function unlinkFromAccount(inst, flags) {
|
|
209
|
+
const env = readEnv(inst.dir);
|
|
210
|
+
const workspaceId = (env.ALEXANDR_WORKSPACE_ID || "").trim();
|
|
211
|
+
if (!workspaceId) {
|
|
212
|
+
log(dim(" (no workspace id recorded — remove it from your account page instead)"));
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
216
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
217
|
+
const state = b64url(crypto.randomBytes(16));
|
|
218
|
+
const { port, done } = await startLoopback();
|
|
219
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
220
|
+
const authUrl =
|
|
221
|
+
`${APP_URL}/cli-auth?` +
|
|
222
|
+
new URLSearchParams({
|
|
223
|
+
redirect_uri: redirectUri,
|
|
224
|
+
state,
|
|
225
|
+
code_challenge: challenge,
|
|
226
|
+
code_challenge_method: "S256",
|
|
227
|
+
host: kernelUrl(kernelPort(inst.dir)),
|
|
228
|
+
name: "Unlink this runtime",
|
|
229
|
+
}).toString();
|
|
230
|
+
step("Sign in to remove this runtime from your account…");
|
|
231
|
+
openURL(authUrl);
|
|
232
|
+
let cb;
|
|
233
|
+
try {
|
|
234
|
+
cb = await done;
|
|
235
|
+
} catch (e) {
|
|
236
|
+
log(dim(` (unlink skipped: ${e.message})`));
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
if (cb.state !== state) return false;
|
|
240
|
+
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
241
|
+
code: cb.code,
|
|
242
|
+
codeVerifier: verifier,
|
|
243
|
+
redirectUri,
|
|
244
|
+
});
|
|
245
|
+
if (!tok.data?.token) {
|
|
246
|
+
log(dim(` (unlink skipped: ${tok.error ?? "malformed response"})`));
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
try {
|
|
250
|
+
const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
|
|
251
|
+
method: "DELETE",
|
|
252
|
+
headers: { authorization: `Bearer ${tok.data.token}` },
|
|
253
|
+
});
|
|
254
|
+
return res.ok;
|
|
255
|
+
} catch {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
|
|
261
|
+
function startLoopback() {
|
|
262
|
+
let resolveFn, rejectFn;
|
|
263
|
+
const done = new Promise((res, rej) => {
|
|
264
|
+
resolveFn = res;
|
|
265
|
+
rejectFn = rej;
|
|
266
|
+
});
|
|
267
|
+
const server = http.createServer((req, res) => {
|
|
268
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
269
|
+
if (u.pathname !== "/callback") {
|
|
270
|
+
res.writeHead(404);
|
|
271
|
+
res.end();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
275
|
+
res.end(
|
|
276
|
+
"<!doctype html><meta charset=utf-8><body style='font:16px system-ui;padding:3rem;text-align:center'>Linked — you can close this tab and return to your terminal.</body>",
|
|
277
|
+
);
|
|
278
|
+
clearTimeout(timer);
|
|
279
|
+
setTimeout(() => server.close(), 200);
|
|
280
|
+
const code = u.searchParams.get("code");
|
|
281
|
+
const st = u.searchParams.get("state");
|
|
282
|
+
if (code && st) resolveFn({ code, state: st });
|
|
283
|
+
else rejectFn(new Error("no authorization code in the redirect"));
|
|
284
|
+
});
|
|
285
|
+
const timer = setTimeout(() => {
|
|
286
|
+
server.close();
|
|
287
|
+
rejectFn(new Error("timed out waiting for the browser confirmation"));
|
|
288
|
+
}, TIMEOUT_MS);
|
|
289
|
+
return new Promise((ready, readyErr) => {
|
|
290
|
+
server.once("error", (e) => {
|
|
291
|
+
rejectFn(e);
|
|
292
|
+
readyErr(e);
|
|
293
|
+
});
|
|
294
|
+
server.listen(0, "127.0.0.1", () => ready({ port: server.address().port, done }));
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** POST json → `{ data }` on success, `{ error }` on failure. The CP writes human-readable
|
|
299
|
+
* `error` strings (e.g. the closed-alpha 403 explains exactly who may register), so failures
|
|
300
|
+
* must carry WHY — a bare null renders as "could not register this runtime" with the real
|
|
301
|
+
* reason swallowed. Callers surface `error` in their fail/skip message. */
|
|
302
|
+
async function postJson(url, body, headers = {}) {
|
|
303
|
+
try {
|
|
304
|
+
const res = await fetch(url, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
headers: { "content-type": "application/json", ...headers },
|
|
307
|
+
body: JSON.stringify(body),
|
|
308
|
+
});
|
|
309
|
+
const data = await res.json().catch(() => null);
|
|
310
|
+
if (!res.ok) {
|
|
311
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
312
|
+
return { error: `HTTP ${res.status}${detail}` };
|
|
313
|
+
}
|
|
314
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
315
|
+
} catch (e) {
|
|
316
|
+
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function isRunning(dir) {
|
|
321
|
+
try {
|
|
322
|
+
return !!(await health(kernelUrl(kernelPort(dir))));
|
|
323
|
+
} catch {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
}
|
package/src/probe.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Health/version probing of a running kernel over its loopback port. Uses the
|
|
2
|
+
// global fetch (Node 18+). All probes are best-effort and never throw.
|
|
3
|
+
|
|
4
|
+
export const kernelUrl = (port = 3030) => `http://127.0.0.1:${port}`;
|
|
5
|
+
|
|
6
|
+
async function getJson(url, timeoutMs = 2500) {
|
|
7
|
+
const ctrl = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
9
|
+
try {
|
|
10
|
+
const res = await fetch(url, { signal: ctrl.signal });
|
|
11
|
+
if (!res.ok) return null;
|
|
12
|
+
return await res.json();
|
|
13
|
+
} catch {
|
|
14
|
+
return null;
|
|
15
|
+
} finally {
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// GET /_kernel/health — liveness + (when unauthenticated-and-open) inventory.
|
|
21
|
+
export const health = (base) => getJson(`${base}/_kernel/health`);
|
|
22
|
+
// GET /_kernel/version — version stamp, open pre-auth.
|
|
23
|
+
export const version = (base) => getJson(`${base}/_kernel/version`);
|
|
24
|
+
|
|
25
|
+
// Poll until the kernel reports healthy, or time out. Returns the health body or null.
|
|
26
|
+
export async function waitHealthy(base, timeoutMs = 90000) {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
while (Date.now() - start < timeoutMs) {
|
|
29
|
+
const h = await health(base);
|
|
30
|
+
if (h && h.ok) return h;
|
|
31
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Poll until the kernel serves under the wanted auth posture ("cp" | "local" | "unlinked"),
|
|
37
|
+
// or time out. Health alone isn't enough after a re-link: a box is healthy the moment it
|
|
38
|
+
// answers, but only the posture says whether it serves or refuses.
|
|
39
|
+
export async function waitPosture(base, posture, timeoutMs = 90000) {
|
|
40
|
+
const start = Date.now();
|
|
41
|
+
while (Date.now() - start < timeoutMs) {
|
|
42
|
+
const h = await health(base);
|
|
43
|
+
if (h && h.ok && h.auth?.posture === posture) return h;
|
|
44
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
package/src/prompt.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Tiny zero-dependency interactive prompts (arrow-key select · line input) for the
|
|
2
|
+
// first-run wizard. Pure Node, no Bun, no npm deps — same contract as util.js.
|
|
3
|
+
//
|
|
4
|
+
// Streams are injectable for tests (a fake stdin EventEmitter with setRawMode/isTTY
|
|
5
|
+
// drives the whole state machine); production callers pass nothing. Callers are
|
|
6
|
+
// expected to guard on a real TTY before prompting — these render ANSI unconditionally.
|
|
7
|
+
|
|
8
|
+
import readline from "node:readline";
|
|
9
|
+
import { bold, dim, green, cyan } from "./util.js";
|
|
10
|
+
|
|
11
|
+
const CTRL_C = "\x03";
|
|
12
|
+
const CTRL_D = "\x04";
|
|
13
|
+
|
|
14
|
+
/** Ctrl-C during a prompt = the user aborted the install. 130 = 128 + SIGINT. */
|
|
15
|
+
function abort(output) {
|
|
16
|
+
output.write("\x1b[?25h\n");
|
|
17
|
+
process.exit(130);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Arrow-key single select, create-vite style:
|
|
22
|
+
*
|
|
23
|
+
* › How should this runtime be reachable?
|
|
24
|
+
* ❯ Just this machine http://localhost:3030
|
|
25
|
+
* A public domain automatic HTTPS via Caddy
|
|
26
|
+
*
|
|
27
|
+
* choices: [{ label, hint?, value }]. ↑/↓ (or j/k, or 1-9) move, Enter picks.
|
|
28
|
+
* On pick the block collapses to one confirmed line: `✓ <question> · <label>`.
|
|
29
|
+
*/
|
|
30
|
+
export function select(question, choices, { input = process.stdin, output = process.stdout } = {}) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
let index = 0;
|
|
33
|
+
const width = Math.max(...choices.map((c) => c.label.length));
|
|
34
|
+
|
|
35
|
+
const render = (first = false) => {
|
|
36
|
+
if (!first) output.write(`\x1b[${choices.length + 1}A`); // back to the question line
|
|
37
|
+
output.write(`\x1b[2K${cyan("›")} ${bold(question)}\n`);
|
|
38
|
+
for (let i = 0; i < choices.length; i++) {
|
|
39
|
+
const c = choices[i];
|
|
40
|
+
const line =
|
|
41
|
+
i === index
|
|
42
|
+
? ` ${cyan("❯")} ${bold(c.label.padEnd(width))}${c.hint ? ` ${dim(c.hint)}` : ""}`
|
|
43
|
+
: ` ${c.label.padEnd(width)}${c.hint ? ` ${dim(c.hint)}` : ""}`;
|
|
44
|
+
output.write(`\x1b[2K${line}\n`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
output.write("\x1b[?25l"); // hide the cursor while the list is live
|
|
49
|
+
render(true);
|
|
50
|
+
input.setRawMode?.(true);
|
|
51
|
+
input.resume?.();
|
|
52
|
+
|
|
53
|
+
// One chunk can carry SEVERAL keys (fast typing, paste, test harnesses) — split it
|
|
54
|
+
// into key tokens: a CSI escape sequence (\x1b[ + final byte) or a single char.
|
|
55
|
+
const tokenize = (s) => {
|
|
56
|
+
const keys = [];
|
|
57
|
+
for (let i = 0; i < s.length; i++) {
|
|
58
|
+
if (s[i] === "\x1b" && s[i + 1] === "[" && i + 2 < s.length) {
|
|
59
|
+
keys.push(s.slice(i, i + 3));
|
|
60
|
+
i += 2;
|
|
61
|
+
} else keys.push(s[i]);
|
|
62
|
+
}
|
|
63
|
+
return keys;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const onData = (data) => {
|
|
67
|
+
for (const key of tokenize(String(data))) {
|
|
68
|
+
if (key === CTRL_C || key === CTRL_D) abort(output);
|
|
69
|
+
if (key === "\x1b[A" || key === "k") index = (index - 1 + choices.length) % choices.length;
|
|
70
|
+
else if (key === "\x1b[B" || key === "j" || key === "\t") index = (index + 1) % choices.length;
|
|
71
|
+
else if (/^[1-9]$/.test(key) && Number(key) <= choices.length) index = Number(key) - 1;
|
|
72
|
+
else if (key === "\r" || key === "\n") {
|
|
73
|
+
input.off("data", onData);
|
|
74
|
+
input.setRawMode?.(false);
|
|
75
|
+
input.pause?.();
|
|
76
|
+
// Collapse the whole block to a single confirmed line.
|
|
77
|
+
output.write(`\x1b[${choices.length + 1}A`);
|
|
78
|
+
for (let i = 0; i <= choices.length; i++) output.write("\x1b[2K\x1b[1B");
|
|
79
|
+
output.write(`\x1b[${choices.length + 1}A`);
|
|
80
|
+
output.write(`${green("✓")} ${question} ${dim("·")} ${choices[index].label}\n`);
|
|
81
|
+
output.write("\x1b[?25h");
|
|
82
|
+
resolve(choices[index].value);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
render();
|
|
87
|
+
};
|
|
88
|
+
input.on("data", onData);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* One-line text input with a default and an optional validator.
|
|
94
|
+
* `validate` returns an error string to re-ask, or null/undefined to accept.
|
|
95
|
+
*/
|
|
96
|
+
export async function ask(question, { def = "", validate, input = process.stdin, output = process.stdout } = {}) {
|
|
97
|
+
// eslint-disable-next-line no-constant-condition
|
|
98
|
+
while (true) {
|
|
99
|
+
const rl = readline.createInterface({ input, output });
|
|
100
|
+
const suffix = def ? ` ${dim(`(${def})`)}` : "";
|
|
101
|
+
const answer = await new Promise((resolve) => {
|
|
102
|
+
rl.on("SIGINT", () => abort(output));
|
|
103
|
+
rl.question(`${cyan("›")} ${bold(question)}${suffix} `, resolve);
|
|
104
|
+
});
|
|
105
|
+
rl.close();
|
|
106
|
+
const value = (answer || "").trim() || def;
|
|
107
|
+
const problem = validate?.(value);
|
|
108
|
+
if (problem) {
|
|
109
|
+
output.write(` ${dim(problem)}\n`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Re-print as a confirmed line (overwrite the prompt line the answer echoed onto).
|
|
113
|
+
output.write(`\x1b[1A\x1b[2K${green("✓")} ${question} ${dim("·")} ${value}\n`);
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Tiny zero-dependency utilities: colored output, arg parsing, port probing,
|
|
2
|
+
// and a cross-platform URL opener. Pure Node (no Bun, no npm deps).
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import net from "node:net";
|
|
6
|
+
|
|
7
|
+
const useColor = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
8
|
+
const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
9
|
+
|
|
10
|
+
export const bold = paint("1");
|
|
11
|
+
export const dim = paint("2");
|
|
12
|
+
export const red = paint("31");
|
|
13
|
+
export const green = paint("32");
|
|
14
|
+
export const yellow = paint("33");
|
|
15
|
+
export const cyan = paint("36");
|
|
16
|
+
|
|
17
|
+
export function log(msg = "") {
|
|
18
|
+
process.stdout.write(`${msg}\n`);
|
|
19
|
+
}
|
|
20
|
+
export function ok(msg) {
|
|
21
|
+
log(`${green("✓")} ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
export function warn(msg) {
|
|
24
|
+
log(`${yellow("!")} ${msg}`);
|
|
25
|
+
}
|
|
26
|
+
export function step(msg) {
|
|
27
|
+
log(`${cyan("›")} ${msg}`);
|
|
28
|
+
}
|
|
29
|
+
export function fail(msg, code = 1) {
|
|
30
|
+
process.stderr.write(`${red("✗")} ${msg}\n`);
|
|
31
|
+
process.exit(code);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
35
|
+
|
|
36
|
+
// Minimal flag parser. Returns { _: [positionals], <flag>: value|true, ... }.
|
|
37
|
+
// Supports --key value, --key=value, --bool, and bundled short bools (-fy).
|
|
38
|
+
// A token only counts as a flag if a letter follows the dash(es) — so values
|
|
39
|
+
// like negative numbers (-1) are consumed as values, not mistaken for flags.
|
|
40
|
+
const looksLikeFlag = (s) => /^--?[A-Za-z]/.test(s);
|
|
41
|
+
|
|
42
|
+
export function parseArgs(argv) {
|
|
43
|
+
const out = { _: [] };
|
|
44
|
+
for (let i = 0; i < argv.length; i++) {
|
|
45
|
+
const a = argv[i];
|
|
46
|
+
if (a.startsWith("--")) {
|
|
47
|
+
const eq = a.indexOf("=");
|
|
48
|
+
if (eq !== -1) {
|
|
49
|
+
out[a.slice(2, eq)] = a.slice(eq + 1);
|
|
50
|
+
} else {
|
|
51
|
+
const key = a.slice(2);
|
|
52
|
+
const next = argv[i + 1];
|
|
53
|
+
if (next !== undefined && !looksLikeFlag(next)) {
|
|
54
|
+
out[key] = next;
|
|
55
|
+
i++;
|
|
56
|
+
} else {
|
|
57
|
+
out[key] = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (/^-[A-Za-z]/.test(a)) {
|
|
61
|
+
for (const ch of a.slice(1)) out[ch] = true;
|
|
62
|
+
} else {
|
|
63
|
+
out._.push(a);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// True if a confirmation flag was passed (-y / --yes).
|
|
70
|
+
export const confirmed = (flags) => Boolean(flags.yes || flags.y);
|
|
71
|
+
|
|
72
|
+
// Resolve whether a TCP port is free to bind on the loopback interface.
|
|
73
|
+
export function isPortFree(port, host = "127.0.0.1") {
|
|
74
|
+
return new Promise((resolve) => {
|
|
75
|
+
const srv = net.createServer();
|
|
76
|
+
srv.once("error", () => resolve(false));
|
|
77
|
+
srv.once("listening", () => srv.close(() => resolve(true)));
|
|
78
|
+
srv.listen(port, host);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Open a URL in the default handler. Best-effort, non-fatal, cross-platform.
|
|
83
|
+
export function openURL(url) {
|
|
84
|
+
try {
|
|
85
|
+
if (process.platform === "darwin") {
|
|
86
|
+
spawn("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
87
|
+
} else if (process.platform === "win32") {
|
|
88
|
+
spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref();
|
|
89
|
+
} else {
|
|
90
|
+
spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# The public front door (only used when ALEXANDR_DOMAIN is set — see the
|
|
2
|
+
# "public" profile in docker-compose.yml). With a domain, Caddy provisions +
|
|
3
|
+
# renews HTTPS automatically; without one it would serve plain HTTP on :80.
|
|
4
|
+
{$ALEXANDR_DOMAIN::80} {
|
|
5
|
+
reverse_proxy kernel:3030
|
|
6
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# alexandr runtime — managed by the `alexandr` CLI.
|
|
2
|
+
# Do not edit by hand; use `alexandr config …` or re-run `alexandr up`.
|
|
3
|
+
# Pulls the published kernel image (no source build). Update with `alexandr update`.
|
|
4
|
+
#
|
|
5
|
+
# Host ports + image are templated so the CLI can override them via the .env it
|
|
6
|
+
# writes next to this file (ALEXANDR_KERNEL_PORT / ALEXANDR_HTTP_PORT / …).
|
|
7
|
+
|
|
8
|
+
services:
|
|
9
|
+
kernel:
|
|
10
|
+
image: ${ALEXANDR_IMAGE:-ghcr.io/alexandrco/alexandr-kernel:latest}
|
|
11
|
+
environment:
|
|
12
|
+
# The installer's placement stamp: a self-hosted box is an engine behind the
|
|
13
|
+
# desktop app — browsers at its root get the "open in the Alexandr app" page,
|
|
14
|
+
# never the web shell (that's the managed placement's surface).
|
|
15
|
+
ALEXANDR_PLACEMENT: self-hosted
|
|
16
|
+
# The HOST side of the port map below — inside the container the kernel only
|
|
17
|
+
# knows its internal port, and must not advertise that as its reachable URL.
|
|
18
|
+
ALEXANDR_ADVERTISED_PORT: ${ALEXANDR_KERNEL_PORT:-3030}
|
|
19
|
+
env_file:
|
|
20
|
+
- path: ./.env
|
|
21
|
+
required: false # every var is optional; the CLI writes this file
|
|
22
|
+
volumes:
|
|
23
|
+
- data:/data # instance state: OS db, installed apps, blobs
|
|
24
|
+
ports:
|
|
25
|
+
- "127.0.0.1:${ALEXANDR_KERNEL_PORT:-3030}:3030" # loopback only
|
|
26
|
+
restart: unless-stopped
|
|
27
|
+
|
|
28
|
+
# Public front door. Only started under the "public" profile — i.e. when a
|
|
29
|
+
# domain is configured (`alexandr up --domain …`). Pure-local runs skip it, so
|
|
30
|
+
# no root is needed for :80/:443 and there's no TLS machinery to babysit.
|
|
31
|
+
caddy:
|
|
32
|
+
image: caddy:2-alpine
|
|
33
|
+
profiles: ["public"]
|
|
34
|
+
depends_on:
|
|
35
|
+
- kernel
|
|
36
|
+
env_file:
|
|
37
|
+
- path: ./.env
|
|
38
|
+
required: false # reads ALEXANDR_DOMAIN
|
|
39
|
+
ports:
|
|
40
|
+
- "${ALEXANDR_HTTP_PORT:-80}:80"
|
|
41
|
+
- "${ALEXANDR_HTTPS_PORT:-443}:443"
|
|
42
|
+
volumes:
|
|
43
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
44
|
+
- caddy_data:/data
|
|
45
|
+
- caddy_config:/config
|
|
46
|
+
restart: unless-stopped
|
|
47
|
+
|
|
48
|
+
volumes:
|
|
49
|
+
data:
|
|
50
|
+
caddy_data:
|
|
51
|
+
caddy_config:
|