alexandr 0.1.2 → 0.2.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
@@ -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 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.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alexandr",
3
- "version": "0.1.2",
3
+ "version": "0.2.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
@@ -258,13 +258,46 @@ export async function destroy(flags) {
258
258
  ensureDocker();
259
259
  const inst = resolveInstance(flags);
260
260
  if (!isMaterialized(inst.dir)) fail("No alexandr instance here.", EXIT.NO_INSTANCE);
261
- const wipe = Boolean(flags.volumes || flags.data);
262
- if (wipe && !confirmed(flags)) {
261
+ let wipe = Boolean(flags.volumes || flags.data);
262
+ let unlink = Boolean(flags.unlink);
263
+
264
+ // Interactive teardown (owner feedback, 2026-08-14): a destructive verb should ASK about
265
+ // everything it could take, not hide the data behind a flag you learn about afterwards.
266
+ // On a TTY without --yes, walk the choices; flags pre-answer their question. Non-TTY
267
+ // keeps the strict behavior: destructive combinations demand --yes (exit 8), scripts
268
+ // stay explicit.
269
+ const interactive = !confirmed(flags) && Boolean(process.stdin.isTTY && process.stdout.isTTY);
270
+ if (interactive) {
271
+ if (!wipe) {
272
+ wipe = await select("Also delete the workspace data (the /data volume)?", [
273
+ { label: "Keep the data", hint: "containers go, /data stays — `alexandr up` resumes it", value: false },
274
+ { label: "Delete everything", hint: "workspace DB, apps, files — irreversible", value: true },
275
+ ]);
276
+ }
277
+ if (!unlink && isLinked(inst.dir)) {
278
+ unlink = await select("Also remove this runtime from your alexandr account?", [
279
+ { label: "Keep the account link", hint: "the workspace stays listed in your hub", value: false },
280
+ { label: "Remove it", hint: "sign-in required — the hub entry disappears", value: true },
281
+ ]);
282
+ }
283
+ const go = await select(
284
+ wipe ? "Destroy this runtime AND its data?" : "Destroy this runtime (data preserved)?",
285
+ [
286
+ { label: "Cancel", value: false },
287
+ { label: wipe ? "Yes, destroy everything" : "Yes, destroy it", value: true },
288
+ ],
289
+ );
290
+ if (!go) {
291
+ log(dim("Nothing touched."));
292
+ return;
293
+ }
294
+ } else if (wipe && !confirmed(flags)) {
263
295
  fail("`destroy --volumes` deletes /data (workspace DB, apps, blobs) irreversibly. Re-run with --yes to confirm.", EXIT.CONFIRMATION);
264
296
  }
297
+
265
298
  // Optional account-side cleanup: sign in and remove the workspace record too, so the hub
266
299
  // doesn't keep a phantom entry. Off by default — destroying the containers never needs it.
267
- if (flags.unlink && isLinked(inst.dir)) {
300
+ if (unlink && isLinked(inst.dir)) {
268
301
  if (await unlinkFromAccount(inst, flags)) ok("Removed from your account.");
269
302
  else warn("Couldn't remove it from your account — remove it from your account page instead.");
270
303
  }
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 browser OAuth+PKCE 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. */
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
@@ -105,6 +107,23 @@ export async function runLinkCeremony(inst, flags) {
105
107
  (readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() ||
106
108
  "My self-hosted workspace";
107
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 }) {
108
127
  // PKCE (S256) + a CSRF state for the loopback redirect.
109
128
  const verifier = b64url(crypto.randomBytes(32));
110
129
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
@@ -123,7 +142,6 @@ export async function runLinkCeremony(inst, flags) {
123
142
  name,
124
143
  }).toString();
125
144
 
126
- step(`Link ${boxUrl} to your alexandr account`);
127
145
  await presentAuthUrl(authUrl, { port, domain });
128
146
 
129
147
  let cb;
@@ -143,10 +161,56 @@ export async function runLinkCeremony(inst, flags) {
143
161
  if (!tok.data?.token) {
144
162
  fail(`Link failed: could not exchange the authorization code (${tok.error ?? "malformed response"}).`);
145
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 }) {
146
210
  const reg = await postJson(
147
211
  `${CP_URL}/instances`,
148
212
  { name, url: boxUrl },
149
- { authorization: `Bearer ${tok.data.token}` },
213
+ { authorization: `Bearer ${sessionToken}` },
150
214
  );
151
215
  if (!reg.data?.instanceId || !reg.data?.telemetryToken) {
152
216
  fail(`Link failed: could not register this runtime (${reg.error ?? "malformed response"}).`);
@@ -173,7 +237,7 @@ export async function runLinkCeremony(inst, flags) {
173
237
  setEnv(inst.dir, "ALEXANDR_RUNTIME_SECRET", reg.data.telemetryToken);
174
238
  if (reg.data.workspaceId) setEnv(inst.dir, "ALEXANDR_WORKSPACE_ID", reg.data.workspaceId);
175
239
  ok("Signed in — this runtime is linked to your account.");
176
- await registryLogin(tok.data.token);
240
+ await registryLogin(sessionToken);
177
241
  }
178
242
 
179
243
  /**
@@ -194,7 +258,14 @@ async function registryLogin(sessionToken) {
194
258
  {},
195
259
  { authorization: `Bearer ${sessionToken}` },
196
260
  );
197
- if (!r.data?.token || !r.data?.username) return;
261
+ if (!r.data?.token || !r.data?.username) {
262
+ // LOUD on purpose (the silent skip hid a dead prod credential on the first real
263
+ // install, 2026-08-14): the image is private, so no credential = the pull WILL fail.
264
+ warn(
265
+ `The control plane couldn't provide the runtime-image credential (${r.error ?? "malformed response"}) — the image pull will fail. Retry later, or contact your operator.`,
266
+ );
267
+ return;
268
+ }
198
269
  pendingPullCred = r.data;
199
270
  applyRegistryLogin();
200
271
  }
@@ -226,6 +297,25 @@ export async function unlinkFromAccount(inst, flags) {
226
297
  log(dim(" (no workspace id recorded — remove it from your account page instead)"));
227
298
  return false;
228
299
  }
300
+ step("Sign in to remove this runtime from your account…");
301
+ if (useDeviceFlow()) {
302
+ let token;
303
+ try {
304
+ token = await deviceGrantToken(kernelUrl(kernelPort(inst.dir)), "Unlink this runtime", "unlink");
305
+ } catch (e) {
306
+ log(dim(` (unlink skipped: ${e.message})`));
307
+ return false;
308
+ }
309
+ try {
310
+ const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
311
+ method: "DELETE",
312
+ headers: { authorization: `Bearer ${token}` },
313
+ });
314
+ return res.ok;
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
229
319
  const verifier = b64url(crypto.randomBytes(32));
230
320
  const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
231
321
  const state = b64url(crypto.randomBytes(16));
@@ -241,7 +331,6 @@ export async function unlinkFromAccount(inst, flags) {
241
331
  host: kernelUrl(kernelPort(inst.dir)),
242
332
  name: "Unlink this runtime",
243
333
  }).toString();
244
- step("Sign in to remove this runtime from your account…");
245
334
  await presentAuthUrl(authUrl, { port, domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined });
246
335
  let cb;
247
336
  try {