alexandr 0.1.1 → 0.2.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/README.md +5 -3
- package/bin.js +15 -2
- package/package.json +2 -2
- package/src/commands.js +6 -0
- package/src/link.js +107 -9
- package/templates/env.example +2 -0
package/README.md
CHANGED
|
@@ -25,9 +25,11 @@ never prompt (`up` stays an idempotent restart).
|
|
|
25
25
|
Sign-in comes right after the questions: every runtime is linked to an alexandr
|
|
26
26
|
account before it serves anyone, and the entitlement gate fires **before anything
|
|
27
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
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
holding three text files, not a Docker install. On a **headless server** the CLI
|
|
29
|
+
uses the device flow: it shows a short code + link, you confirm from any browser
|
|
30
|
+
on any device (no tunnels, no port forwarding), and the terminal continues by
|
|
31
|
+
itself. On a desktop it asks before opening your browser for the instant
|
|
32
|
+
loopback hand-off.
|
|
31
33
|
|
|
32
34
|
Only after sign-in does `up` handle dependencies: on a fresh **Linux** server,
|
|
33
35
|
when Docker or Compose v2 is missing it offers to install them right there
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
@@ -173,6 +173,12 @@ export async function up(flags) {
|
|
|
173
173
|
|
|
174
174
|
if (flags.port) setEnv(inst.dir, "ALEXANDR_KERNEL_PORT", String(flags.port));
|
|
175
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
|
+
}
|
|
176
182
|
|
|
177
183
|
// Sign-in comes FIRST (account-required-runtimes D3) — now literally: the entitlement
|
|
178
184
|
// gate fires before anything system-mutating, so a refused account walks away from a
|
package/src/link.js
CHANGED
|
@@ -14,7 +14,7 @@ import http from "node:http";
|
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import crypto from "node:crypto";
|
|
16
16
|
import readline from "node:readline";
|
|
17
|
-
import { log, dim, bold, cyan, fail, step, ok, warn, openURL } from "./util.js";
|
|
17
|
+
import { log, dim, bold, cyan, fail, step, ok, warn, openURL, sleep } from "./util.js";
|
|
18
18
|
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv } from "./instance.js";
|
|
19
19
|
import { kernelUrl, health, waitPosture } from "./probe.js";
|
|
20
20
|
import { compose, exec } from "./docker.js";
|
|
@@ -90,8 +90,10 @@ export async function link(flags) {
|
|
|
90
90
|
ok(`Linked. This runtime signs in with your alexandr account — open it in the Alexandr app (your account: ${APP_URL}/account).`);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/** The
|
|
94
|
-
* fails the process on any break in the chain. Exported for `up`'s revoked-recovery lane.
|
|
93
|
+
/** The account-link ceremony + registration. Writes the .env credential trio on success;
|
|
94
|
+
* fails the process on any break in the chain. Exported for `up`'s revoked-recovery lane.
|
|
95
|
+
* Two grant shapes, one registration: the DEVICE flow on headless machines (a short code
|
|
96
|
+
* confirmed from any browser — no tunnel), the instant loopback PKCE redirect on desktops. */
|
|
95
97
|
export async function runLinkCeremony(inst, flags) {
|
|
96
98
|
// The URL the CP records + the SSO handoff redirects back to. --domain (or a domain already in
|
|
97
99
|
// .env) for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
@@ -101,8 +103,27 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
101
103
|
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
102
104
|
: kernelUrl(kernelPort(inst.dir));
|
|
103
105
|
const name =
|
|
104
|
-
typeof flags.name === "string" && flags.name.trim()
|
|
106
|
+
(typeof flags.name === "string" && flags.name.trim()) ||
|
|
107
|
+
(readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() ||
|
|
108
|
+
"My self-hosted workspace";
|
|
105
109
|
|
|
110
|
+
step(`Link ${boxUrl} to your alexandr account`);
|
|
111
|
+
let sessionToken;
|
|
112
|
+
if (useDeviceFlow()) {
|
|
113
|
+
try {
|
|
114
|
+
sessionToken = await deviceGrantToken(boxUrl, name, "link");
|
|
115
|
+
} catch (e) {
|
|
116
|
+
fail(`Link aborted: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
sessionToken = await loopbackGrantToken({ boxUrl, name, domain });
|
|
120
|
+
}
|
|
121
|
+
await registerAndPersist(inst, { boxUrl, name, sessionToken });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The DESKTOP grant — OAuth authorization-code + PKCE against a loopback redirect the
|
|
125
|
+
* browser on THIS machine can reach. Returns the short-lived session token. */
|
|
126
|
+
async function loopbackGrantToken({ boxUrl, name, domain }) {
|
|
106
127
|
// PKCE (S256) + a CSRF state for the loopback redirect.
|
|
107
128
|
const verifier = b64url(crypto.randomBytes(32));
|
|
108
129
|
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
@@ -121,7 +142,6 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
121
142
|
name,
|
|
122
143
|
}).toString();
|
|
123
144
|
|
|
124
|
-
step(`Link ${boxUrl} to your alexandr account`);
|
|
125
145
|
await presentAuthUrl(authUrl, { port, domain });
|
|
126
146
|
|
|
127
147
|
let cb;
|
|
@@ -141,10 +161,56 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
141
161
|
if (!tok.data?.token) {
|
|
142
162
|
fail(`Link failed: could not exchange the authorization code (${tok.error ?? "malformed response"}).`);
|
|
143
163
|
}
|
|
164
|
+
return tok.data.token;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The HEADLESS ceremony — the device-authorization flow (RFC 8628 shape), because a remote
|
|
169
|
+
* box can't receive a loopback redirect and making the user build an ssh tunnel for a
|
|
170
|
+
* sign-in was backwards. The CLI mints a grant, shows a short code + URL (any browser on
|
|
171
|
+
* any device), and polls until the signed-in owner confirms. Throws on fatal (the caller
|
|
172
|
+
* decides between fail() and best-effort skip); network blips just keep polling.
|
|
173
|
+
*/
|
|
174
|
+
async function deviceGrantToken(host, name, intent) {
|
|
175
|
+
const mint = await postJson(`${CP_URL}/cli-auth/device`, { host, name, intent });
|
|
176
|
+
if (!mint.data?.deviceCode || !mint.data?.userCode) {
|
|
177
|
+
throw new Error(`couldn't start the sign-in (${mint.error ?? "malformed response"}).`);
|
|
178
|
+
}
|
|
179
|
+
const { deviceCode, userCode, expiresIn = 600, interval = 3 } = mint.data;
|
|
180
|
+
log("");
|
|
181
|
+
step("Open this link on any device — your computer or your phone — and confirm the code:");
|
|
182
|
+
log(` ${cyan(`${APP_URL}/cli-auth?code=${encodeURIComponent(userCode)}`)}`);
|
|
183
|
+
log("");
|
|
184
|
+
log(` Code: ${bold(userCode)}`);
|
|
185
|
+
log("");
|
|
186
|
+
log(dim(` (waiting for the confirmation — ${Math.round(expiresIn / 60)} minutes; this updates by itself)`));
|
|
187
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
188
|
+
while (Date.now() < deadline) {
|
|
189
|
+
await sleep(interval * 1000);
|
|
190
|
+
const res = await postJson(`${CP_URL}/cli-auth/device/token`, { deviceCode });
|
|
191
|
+
if (res.data?.token) return res.data.token;
|
|
192
|
+
if (res.data?.status === "pending") continue;
|
|
193
|
+
if (res.error?.startsWith("HTTP")) throw new Error(`the sign-in was rejected (${res.error}).`);
|
|
194
|
+
// Network blip — keep polling until the code's own deadline.
|
|
195
|
+
}
|
|
196
|
+
throw new Error("the code expired before it was confirmed — run the command again.");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Which ceremony fits this machine: the device flow wherever a local browser can't
|
|
200
|
+
* receive the redirect (headless servers), the instant loopback redirect elsewhere.
|
|
201
|
+
* ALEXANDR_DEVICE_FLOW=1|0 overrides either way. */
|
|
202
|
+
function useDeviceFlow() {
|
|
203
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "1") return true;
|
|
204
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "0") return false;
|
|
205
|
+
return isHeadless();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Register the runtime with a freshly-granted session and persist the credential trio. */
|
|
209
|
+
async function registerAndPersist(inst, { boxUrl, name, sessionToken }) {
|
|
144
210
|
const reg = await postJson(
|
|
145
211
|
`${CP_URL}/instances`,
|
|
146
212
|
{ name, url: boxUrl },
|
|
147
|
-
{ authorization: `Bearer ${
|
|
213
|
+
{ authorization: `Bearer ${sessionToken}` },
|
|
148
214
|
);
|
|
149
215
|
if (!reg.data?.instanceId || !reg.data?.telemetryToken) {
|
|
150
216
|
fail(`Link failed: could not register this runtime (${reg.error ?? "malformed response"}).`);
|
|
@@ -171,7 +237,7 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
171
237
|
setEnv(inst.dir, "ALEXANDR_RUNTIME_SECRET", reg.data.telemetryToken);
|
|
172
238
|
if (reg.data.workspaceId) setEnv(inst.dir, "ALEXANDR_WORKSPACE_ID", reg.data.workspaceId);
|
|
173
239
|
ok("Signed in — this runtime is linked to your account.");
|
|
174
|
-
await registryLogin(
|
|
240
|
+
await registryLogin(sessionToken);
|
|
175
241
|
}
|
|
176
242
|
|
|
177
243
|
/**
|
|
@@ -224,6 +290,25 @@ export async function unlinkFromAccount(inst, flags) {
|
|
|
224
290
|
log(dim(" (no workspace id recorded — remove it from your account page instead)"));
|
|
225
291
|
return false;
|
|
226
292
|
}
|
|
293
|
+
step("Sign in to remove this runtime from your account…");
|
|
294
|
+
if (useDeviceFlow()) {
|
|
295
|
+
let token;
|
|
296
|
+
try {
|
|
297
|
+
token = await deviceGrantToken(kernelUrl(kernelPort(inst.dir)), "Unlink this runtime", "unlink");
|
|
298
|
+
} catch (e) {
|
|
299
|
+
log(dim(` (unlink skipped: ${e.message})`));
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
try {
|
|
303
|
+
const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
|
|
304
|
+
method: "DELETE",
|
|
305
|
+
headers: { authorization: `Bearer ${token}` },
|
|
306
|
+
});
|
|
307
|
+
return res.ok;
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
227
312
|
const verifier = b64url(crypto.randomBytes(32));
|
|
228
313
|
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
229
314
|
const state = b64url(crypto.randomBytes(16));
|
|
@@ -239,7 +324,6 @@ export async function unlinkFromAccount(inst, flags) {
|
|
|
239
324
|
host: kernelUrl(kernelPort(inst.dir)),
|
|
240
325
|
name: "Unlink this runtime",
|
|
241
326
|
}).toString();
|
|
242
|
-
step("Sign in to remove this runtime from your account…");
|
|
243
327
|
await presentAuthUrl(authUrl, { port, domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined });
|
|
244
328
|
let cb;
|
|
245
329
|
try {
|
|
@@ -309,7 +393,7 @@ export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadl
|
|
|
309
393
|
}
|
|
310
394
|
|
|
311
395
|
/** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
|
|
312
|
-
function startLoopback() {
|
|
396
|
+
export function startLoopback() {
|
|
313
397
|
let resolveFn, rejectFn;
|
|
314
398
|
const done = new Promise((res, rej) => {
|
|
315
399
|
resolveFn = res;
|
|
@@ -317,6 +401,20 @@ function startLoopback() {
|
|
|
317
401
|
});
|
|
318
402
|
const server = http.createServer((req, res) => {
|
|
319
403
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
404
|
+
// Reachability probe for the consent page: /cli-auth pings this before the user
|
|
405
|
+
// clicks Link, so a missing ssh tunnel becomes a guided "start the tunnel" notice
|
|
406
|
+
// instead of a dead browser error page after the click. The PNA header answers
|
|
407
|
+
// Chrome's public→loopback preflight; ACAO lets the page read the success.
|
|
408
|
+
if (u.pathname === "/ping") {
|
|
409
|
+
res.writeHead(204, {
|
|
410
|
+
"access-control-allow-origin": "*",
|
|
411
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
412
|
+
"access-control-allow-headers": "*",
|
|
413
|
+
"access-control-allow-private-network": "true",
|
|
414
|
+
});
|
|
415
|
+
res.end();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
320
418
|
if (u.pathname !== "/callback") {
|
|
321
419
|
res.writeHead(404);
|
|
322
420
|
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
|