alexandr 0.2.1 → 0.3.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 +76 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +7 -2
- package/src/commands.js +57 -3
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/link.js +68 -273
- package/src/prompt.js +56 -0
package/src/link.js
CHANGED
|
@@ -10,22 +10,19 @@
|
|
|
10
10
|
// step one (an unlinked box refuses to serve), and `alexandr link` remains the explicit
|
|
11
11
|
// re-link/repair verb.
|
|
12
12
|
|
|
13
|
-
import
|
|
14
|
-
import
|
|
15
|
-
import crypto from "node:crypto";
|
|
16
|
-
import readline from "node:readline";
|
|
17
|
-
import { log, dim, bold, cyan, fail, step, ok, warn, openURL, sleep } from "./util.js";
|
|
18
|
-
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv } from "./instance.js";
|
|
13
|
+
import { log, dim, fail, step, ok, warn } from "./util.js";
|
|
14
|
+
import { resolveInstance, isMaterialized, readEnv, kernelPort, setEnv, unsetEnv } from "./instance.js";
|
|
19
15
|
import { kernelUrl, health, waitPosture } from "./probe.js";
|
|
20
16
|
import { compose, exec } from "./docker.js";
|
|
17
|
+
// ⚠ THE CEREMONY MOVED to ./consent.js 2026-09-05 (app-system-stage-1.md §2 WP-E)
|
|
18
|
+
// so `alexandr app link` could run the same one with a different SCOPE. Copying
|
|
19
|
+
// it would have left two drifting implementations of the security-critical half
|
|
20
|
+
// of this CLI. Nothing about the verbs below changed: they ask for no scope, so
|
|
21
|
+
// they still get today's 10-minute `cli-link` session.
|
|
22
|
+
import { APP_URL, CP_URL, consentSession, isHeadless, postJson, presentAuthUrl, startLoopback } from "./consent.js";
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
// website-account-surface.md); override for dev (e.g. http://localhost:4000 + http://localhost:3000)
|
|
25
|
-
// via env. The link-consent page (/cli-auth) + the account page are website surfaces now.
|
|
26
|
-
const CP_URL = (process.env.ALEXANDR_CP_URL || "https://api.alexandr.so").replace(/\/+$/, "");
|
|
27
|
-
const APP_URL = (process.env.ALEXANDR_APP_URL || "https://alexandr.so").replace(/\/+$/, "");
|
|
28
|
-
const TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
|
+
// Re-exported: these were this module's public surface before the extraction.
|
|
25
|
+
export { isHeadless, presentAuthUrl, startLoopback };
|
|
29
26
|
|
|
30
27
|
/** Whether this instance's .env already carries the connected credential trio. */
|
|
31
28
|
export function isLinked(dir) {
|
|
@@ -95,13 +92,7 @@ export async function link(flags) {
|
|
|
95
92
|
* Two grant shapes, one registration: the DEVICE flow on headless machines (a short code
|
|
96
93
|
* confirmed from any browser — no tunnel), the instant loopback PKCE redirect on desktops. */
|
|
97
94
|
export async function runLinkCeremony(inst, flags) {
|
|
98
|
-
|
|
99
|
-
// .env) for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
100
|
-
// locally — and the runtime self-reports its origin on heartbeat either way).
|
|
101
|
-
const domain = flags.domain || readEnv(inst.dir).ALEXANDR_DOMAIN;
|
|
102
|
-
const boxUrl = domain
|
|
103
|
-
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
104
|
-
: kernelUrl(kernelPort(inst.dir));
|
|
95
|
+
const { domain, boxUrl } = instanceCoords(inst, flags);
|
|
105
96
|
const name =
|
|
106
97
|
(typeof flags.name === "string" && flags.name.trim()) ||
|
|
107
98
|
(readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() ||
|
|
@@ -109,100 +100,24 @@ export async function runLinkCeremony(inst, flags) {
|
|
|
109
100
|
|
|
110
101
|
step(`Link ${boxUrl} to your alexandr account`);
|
|
111
102
|
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 }) {
|
|
127
|
-
// PKCE (S256) + a CSRF state for the loopback redirect.
|
|
128
|
-
const verifier = b64url(crypto.randomBytes(32));
|
|
129
|
-
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
130
|
-
const state = b64url(crypto.randomBytes(16));
|
|
131
|
-
|
|
132
|
-
const { port, done } = await startLoopback();
|
|
133
|
-
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
134
|
-
const authUrl =
|
|
135
|
-
`${APP_URL}/cli-auth?` +
|
|
136
|
-
new URLSearchParams({
|
|
137
|
-
redirect_uri: redirectUri,
|
|
138
|
-
state,
|
|
139
|
-
code_challenge: challenge,
|
|
140
|
-
code_challenge_method: "S256",
|
|
141
|
-
host: boxUrl,
|
|
142
|
-
name,
|
|
143
|
-
}).toString();
|
|
144
|
-
|
|
145
|
-
await presentAuthUrl(authUrl, { port, domain });
|
|
146
|
-
|
|
147
|
-
let cb;
|
|
148
103
|
try {
|
|
149
|
-
|
|
104
|
+
// No scope: this ceremony wants today's short single-use `cli-link` session.
|
|
105
|
+
sessionToken = (await consentSession({ host: boxUrl, name, intent: "link", domain })).token;
|
|
150
106
|
} catch (e) {
|
|
151
107
|
fail(`Link aborted: ${e.message}`);
|
|
152
108
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
// Exchange the code (+ PKCE verifier) for a short-lived session, then register this runtime.
|
|
156
|
-
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
157
|
-
code: cb.code,
|
|
158
|
-
codeVerifier: verifier,
|
|
159
|
-
redirectUri,
|
|
160
|
-
});
|
|
161
|
-
if (!tok.data?.token) {
|
|
162
|
-
fail(`Link failed: could not exchange the authorization code (${tok.error ?? "malformed response"}).`);
|
|
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.");
|
|
109
|
+
await registerAndPersist(inst, { boxUrl, name, sessionToken });
|
|
197
110
|
}
|
|
198
111
|
|
|
199
|
-
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
112
|
+
/** The URL the CP records + the consent card names. --domain (or a domain already in .env)
|
|
113
|
+
* for a publicly-reachable box; otherwise the loopback kernel URL (fine for a box you open
|
|
114
|
+
* locally — and the runtime self-reports its origin on heartbeat either way). */
|
|
115
|
+
function instanceCoords(inst, flags) {
|
|
116
|
+
const domain = flags.domain || readEnv(inst.dir).ALEXANDR_DOMAIN;
|
|
117
|
+
const boxUrl = domain
|
|
118
|
+
? `https://${String(domain).replace(/^https?:\/\//, "").replace(/\/+$/, "")}`
|
|
119
|
+
: kernelUrl(kernelPort(inst.dir));
|
|
120
|
+
return { domain, boxUrl };
|
|
206
121
|
}
|
|
207
122
|
|
|
208
123
|
/** Register the runtime with a freshly-granted session and persist the credential trio. */
|
|
@@ -264,25 +179,46 @@ async function registryLogin(sessionToken) {
|
|
|
264
179
|
warn(
|
|
265
180
|
`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
181
|
);
|
|
267
|
-
return;
|
|
182
|
+
return "none";
|
|
268
183
|
}
|
|
269
184
|
pendingPullCred = r.data;
|
|
270
|
-
applyRegistryLogin();
|
|
185
|
+
return applyRegistryLogin();
|
|
271
186
|
}
|
|
272
187
|
|
|
273
|
-
/** Run the held `docker login` if the docker CLI is available;
|
|
274
|
-
* re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering.
|
|
188
|
+
/** Run the held `docker login` if the docker CLI is available; "held" otherwise (the caller
|
|
189
|
+
* re-invokes after installing dependencies). Exported for `up`'s sign-in-first ordering.
|
|
190
|
+
* Returns "done" | "failed" | "held" | "none" so recovery flows can branch on the outcome. */
|
|
275
191
|
export function applyRegistryLogin() {
|
|
276
|
-
if (!pendingPullCred) return;
|
|
277
|
-
if (exec("docker", ["--version"]).status !== 0) return; // not installed yet — hold on
|
|
192
|
+
if (!pendingPullCred) return "none";
|
|
193
|
+
if (exec("docker", ["--version"]).status !== 0) return "held"; // not installed yet — hold on
|
|
278
194
|
const { username, token } = pendingPullCred;
|
|
279
195
|
const registry = pendingPullCred.registry || "ghcr.io";
|
|
280
196
|
const login = exec("docker", ["login", registry, "-u", username, "--password-stdin"], {
|
|
281
197
|
input: token,
|
|
282
198
|
});
|
|
283
|
-
if (login.status === 0) log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
|
|
284
|
-
else warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
|
|
285
199
|
pendingPullCred = null;
|
|
200
|
+
if (login.status === 0) {
|
|
201
|
+
log(dim(` Registry sign-in ok — the runtime image pulls with your account.`));
|
|
202
|
+
return "done";
|
|
203
|
+
}
|
|
204
|
+
warn(`Couldn't sign in to ${registry} — a private runtime image won't pull. (${login.stderr || "docker login failed"})`);
|
|
205
|
+
return "failed";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Refresh THIS box's image-pull credential without touching its registration — the recovery
|
|
210
|
+
* for an expired/missing `docker login` (fleet-wide whenever the CP-held PAT rotates). A
|
|
211
|
+
* quick sign-in (device flow on servers, loopback on desktops, intent "refresh" so the
|
|
212
|
+
* consent card says what's actually happening) mints the short-lived session, and the
|
|
213
|
+
* pull credential rides it. Returns true when the docker login landed. Throws on ceremony
|
|
214
|
+
* failure — callers decide between fail() and a soft fallback.
|
|
215
|
+
*/
|
|
216
|
+
export async function refreshRegistryLogin(inst, flags) {
|
|
217
|
+
const { domain, boxUrl } = instanceCoords(inst, flags);
|
|
218
|
+
const name = (readEnv(inst.dir).ALEXANDR_WORKSPACE_NAME || "").trim() || "Self-hosted runtime";
|
|
219
|
+
step("Sign in to refresh this runtime's image credential…");
|
|
220
|
+
const { token } = await consentSession({ host: boxUrl, name, intent: "refresh", domain });
|
|
221
|
+
return (await registryLogin(token)) === "done";
|
|
286
222
|
}
|
|
287
223
|
|
|
288
224
|
/**
|
|
@@ -298,179 +234,38 @@ export async function unlinkFromAccount(inst, flags) {
|
|
|
298
234
|
return false;
|
|
299
235
|
}
|
|
300
236
|
step("Sign in to remove this runtime from your account…");
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
}
|
|
319
|
-
const verifier = b64url(crypto.randomBytes(32));
|
|
320
|
-
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
321
|
-
const state = b64url(crypto.randomBytes(16));
|
|
322
|
-
const { port, done } = await startLoopback();
|
|
323
|
-
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
324
|
-
const authUrl =
|
|
325
|
-
`${APP_URL}/cli-auth?` +
|
|
326
|
-
new URLSearchParams({
|
|
327
|
-
redirect_uri: redirectUri,
|
|
328
|
-
state,
|
|
329
|
-
code_challenge: challenge,
|
|
330
|
-
code_challenge_method: "S256",
|
|
237
|
+
let token;
|
|
238
|
+
try {
|
|
239
|
+
// No scope — a one-shot `cli-link` session is exactly what a DELETE needs.
|
|
240
|
+
({ token } = await consentSession({
|
|
331
241
|
host: kernelUrl(kernelPort(inst.dir)),
|
|
332
242
|
name: "Unlink this runtime",
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
cb = await done;
|
|
243
|
+
intent: "unlink",
|
|
244
|
+
domain: (env.ALEXANDR_DOMAIN || "").trim() || undefined,
|
|
245
|
+
}));
|
|
338
246
|
} catch (e) {
|
|
339
247
|
log(dim(` (unlink skipped: ${e.message})`));
|
|
340
248
|
return false;
|
|
341
249
|
}
|
|
342
|
-
if (cb.state !== state) return false;
|
|
343
|
-
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
344
|
-
code: cb.code,
|
|
345
|
-
codeVerifier: verifier,
|
|
346
|
-
redirectUri,
|
|
347
|
-
});
|
|
348
|
-
if (!tok.data?.token) {
|
|
349
|
-
log(dim(` (unlink skipped: ${tok.error ?? "malformed response"})`));
|
|
350
|
-
return false;
|
|
351
|
-
}
|
|
352
250
|
try {
|
|
353
251
|
const res = await fetch(`${CP_URL}/instances/${encodeURIComponent(workspaceId)}`, {
|
|
354
252
|
method: "DELETE",
|
|
355
|
-
headers: { authorization: `Bearer ${
|
|
253
|
+
headers: { authorization: `Bearer ${token}` },
|
|
356
254
|
});
|
|
255
|
+
if (res.ok) scrubCredentials(inst.dir);
|
|
357
256
|
return res.ok;
|
|
358
257
|
} catch {
|
|
359
258
|
return false;
|
|
360
259
|
}
|
|
361
260
|
}
|
|
362
261
|
|
|
363
|
-
/**
|
|
364
|
-
*
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
* Present the consent URL honestly, by what this machine can actually do:
|
|
371
|
-
* - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel FIRST
|
|
372
|
-
* (the redirect lands on the desktop's loopback and must reach this box), URL bright.
|
|
373
|
-
* - DESKTOP + TTY: ask before taking over the browser — the URL is printed either way,
|
|
374
|
-
* so "open it yourself" is always available.
|
|
375
|
-
* - DESKTOP non-TTY (scripts): old behavior — print + best-effort open, nothing blocks.
|
|
376
|
-
*/
|
|
377
|
-
export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
|
|
378
|
-
const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
|
|
379
|
-
if (headless) {
|
|
380
|
-
log("");
|
|
381
|
-
step("This machine has no browser — finish the sign-in from your computer:");
|
|
382
|
-
log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
|
|
383
|
-
log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
|
|
384
|
-
log(` 2. Open this link in a browser signed in to your alexandr account:`);
|
|
385
|
-
log(` ${cyan(authUrl)}`);
|
|
386
|
-
log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
log(dim(authUrl));
|
|
390
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
391
|
-
await new Promise((resolve) => {
|
|
392
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
393
|
-
rl.question(`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `, () => {
|
|
394
|
-
rl.close();
|
|
395
|
-
resolve();
|
|
396
|
-
});
|
|
397
|
-
});
|
|
398
|
-
}
|
|
399
|
-
openURL(authUrl);
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
|
|
403
|
-
export function startLoopback() {
|
|
404
|
-
let resolveFn, rejectFn;
|
|
405
|
-
const done = new Promise((res, rej) => {
|
|
406
|
-
resolveFn = res;
|
|
407
|
-
rejectFn = rej;
|
|
408
|
-
});
|
|
409
|
-
const server = http.createServer((req, res) => {
|
|
410
|
-
const u = new URL(req.url, "http://127.0.0.1");
|
|
411
|
-
// Reachability probe for the consent page: /cli-auth pings this before the user
|
|
412
|
-
// clicks Link, so a missing ssh tunnel becomes a guided "start the tunnel" notice
|
|
413
|
-
// instead of a dead browser error page after the click. The PNA header answers
|
|
414
|
-
// Chrome's public→loopback preflight; ACAO lets the page read the success.
|
|
415
|
-
if (u.pathname === "/ping") {
|
|
416
|
-
res.writeHead(204, {
|
|
417
|
-
"access-control-allow-origin": "*",
|
|
418
|
-
"access-control-allow-methods": "GET, OPTIONS",
|
|
419
|
-
"access-control-allow-headers": "*",
|
|
420
|
-
"access-control-allow-private-network": "true",
|
|
421
|
-
});
|
|
422
|
-
res.end();
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (u.pathname !== "/callback") {
|
|
426
|
-
res.writeHead(404);
|
|
427
|
-
res.end();
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
431
|
-
res.end(
|
|
432
|
-
"<!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>",
|
|
433
|
-
);
|
|
434
|
-
clearTimeout(timer);
|
|
435
|
-
setTimeout(() => server.close(), 200);
|
|
436
|
-
const code = u.searchParams.get("code");
|
|
437
|
-
const st = u.searchParams.get("state");
|
|
438
|
-
if (code && st) resolveFn({ code, state: st });
|
|
439
|
-
else rejectFn(new Error("no authorization code in the redirect"));
|
|
440
|
-
});
|
|
441
|
-
const timer = setTimeout(() => {
|
|
442
|
-
server.close();
|
|
443
|
-
rejectFn(new Error("timed out waiting for the browser confirmation"));
|
|
444
|
-
}, TIMEOUT_MS);
|
|
445
|
-
return new Promise((ready, readyErr) => {
|
|
446
|
-
server.once("error", (e) => {
|
|
447
|
-
rejectFn(e);
|
|
448
|
-
readyErr(e);
|
|
449
|
-
});
|
|
450
|
-
server.listen(0, "127.0.0.1", () => ready({ port: server.address().port, done }));
|
|
451
|
-
});
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/** POST json → `{ data }` on success, `{ error }` on failure. The CP writes human-readable
|
|
455
|
-
* `error` strings (e.g. the closed-alpha 403 explains exactly who may register), so failures
|
|
456
|
-
* must carry WHY — a bare null renders as "could not register this runtime" with the real
|
|
457
|
-
* reason swallowed. Callers surface `error` in their fail/skip message. */
|
|
458
|
-
async function postJson(url, body, headers = {}) {
|
|
459
|
-
try {
|
|
460
|
-
const res = await fetch(url, {
|
|
461
|
-
method: "POST",
|
|
462
|
-
headers: { "content-type": "application/json", ...headers },
|
|
463
|
-
body: JSON.stringify(body),
|
|
464
|
-
});
|
|
465
|
-
const data = await res.json().catch(() => null);
|
|
466
|
-
if (!res.ok) {
|
|
467
|
-
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
468
|
-
return { error: `HTTP ${res.status}${detail}` };
|
|
469
|
-
}
|
|
470
|
-
return data == null ? { error: "malformed response" } : { data };
|
|
471
|
-
} catch (e) {
|
|
472
|
-
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
473
|
-
}
|
|
262
|
+
/** After a successful unlink the .env trio is DEAD — presence isn't validity, and leaving
|
|
263
|
+
* it made the next `up` skip the ceremony entirely and die at an unauthorized pull (hit
|
|
264
|
+
* live 2026-08-14). Scrub it so `isLinked` answers honestly and `up` signs in afresh. */
|
|
265
|
+
function scrubCredentials(dir) {
|
|
266
|
+
unsetEnv(dir, "ALEXANDR_INSTANCE_ID");
|
|
267
|
+
unsetEnv(dir, "ALEXANDR_RUNTIME_SECRET");
|
|
268
|
+
unsetEnv(dir, "ALEXANDR_WORKSPACE_ID");
|
|
474
269
|
}
|
|
475
270
|
|
|
476
271
|
async function isRunning(dir) {
|
package/src/prompt.js
CHANGED
|
@@ -114,3 +114,59 @@ export async function ask(question, { def = "", validate, input = process.stdin,
|
|
|
114
114
|
return value;
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A HIDDEN one-line input — for a secret, and only for a secret.
|
|
120
|
+
*
|
|
121
|
+
* ⚠⚠ THE VALUE NEVER TOUCHES ARGV. A command line is in the shell's history, in
|
|
122
|
+
* `ps` output for every user on the box while the process lives, and in any CI
|
|
123
|
+
* log that echoes the command — so `alexandr app secrets set NAME value` is
|
|
124
|
+
* refused by the parser, and this is what replaces it. Nothing is echoed and
|
|
125
|
+
* nothing is re-printed on the confirmation line.
|
|
126
|
+
*
|
|
127
|
+
* ⚠ Windows-safe by construction: pure `readline` with the output muted, no
|
|
128
|
+
* `stty`, no `/bin/sh`. `readline`'s own terminal handling raw-modes the TTY on
|
|
129
|
+
* both platforms; the muted stream swallows every write it makes while the
|
|
130
|
+
* question is up, so the keystrokes leave no trace on screen. `terminal: true`
|
|
131
|
+
* is what makes it use that handling rather than plain line buffering.
|
|
132
|
+
*
|
|
133
|
+
* Returns the raw string, untrimmed — a secret may legitimately end in
|
|
134
|
+
* whitespace, and it is the caller that decides whether an empty one is a
|
|
135
|
+
* refusal.
|
|
136
|
+
*/
|
|
137
|
+
export function secret(question, { input = process.stdin, output = process.stdout } = {}) {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
let muted = false;
|
|
140
|
+
// A thin write-only proxy over the real stream: readline draws its prompt
|
|
141
|
+
// through this, and once the question is on screen every echo is dropped.
|
|
142
|
+
const masked = Object.create(output);
|
|
143
|
+
masked.write = (chunk, ...rest) => (muted ? true : output.write(chunk, ...rest));
|
|
144
|
+
|
|
145
|
+
const rl = readline.createInterface({ input, output: masked, terminal: true });
|
|
146
|
+
rl.on("SIGINT", () => abort(output));
|
|
147
|
+
rl.question(`${cyan("›")} ${bold(question)} `, (answer) => {
|
|
148
|
+
muted = false;
|
|
149
|
+
rl.close();
|
|
150
|
+
// The prompt line is overwritten rather than left with a blank tail —
|
|
151
|
+
// there is nothing to confirm back, so it says only that it was read.
|
|
152
|
+
output.write(`\x1b[2K\r${green("✓")} ${question} ${dim("·")} read from the prompt\n`);
|
|
153
|
+
resolve(answer ?? "");
|
|
154
|
+
});
|
|
155
|
+
muted = true;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A secret from a PIPE — `printf %s "$KEY" | alexandr app secrets set NAME`.
|
|
161
|
+
*
|
|
162
|
+
* ⚠ ONE TRAILING NEWLINE IS STRIPPED AND NOTHING ELSE IS. `echo` adds one and
|
|
163
|
+
* every shell user expects it gone; a second one, or leading whitespace, is
|
|
164
|
+
* part of the value the person piped and removing it would corrupt a key that
|
|
165
|
+
* legitimately holds it (a PEM block ends in a newline).
|
|
166
|
+
*/
|
|
167
|
+
export async function readPipedSecret(input = process.stdin) {
|
|
168
|
+
const chunks = [];
|
|
169
|
+
for await (const chunk of input) chunks.push(chunk);
|
|
170
|
+
const raw = Buffer.concat(chunks.map((c) => (typeof c === "string" ? Buffer.from(c) : c))).toString("utf8");
|
|
171
|
+
return raw.endsWith("\r\n") ? raw.slice(0, -2) : raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
172
|
+
}
|