alexandr 0.2.2 → 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 +72 -0
- 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 +6 -1
- package/src/completion.js +14 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// `alexandr app update` — move the linked workspace onto the newest release.
|
|
2
|
+
//
|
|
3
|
+
// The terminal door to `POST /_kernel/store/update` (app-system-stage-3.md
|
|
4
|
+
// WP-N), which re-reads the feed, refuses anything that is not strictly newer,
|
|
5
|
+
// then stages and switches. One request, one answer: `{ ok, version, previous }`.
|
|
6
|
+
//
|
|
7
|
+
// ⚠ THIS VERB DOES NOT PUBLISH AND DOES NOT BUILD. It moves a box between
|
|
8
|
+
// releases that already exist — which is why the box gates it on
|
|
9
|
+
// `os.apps.manage` (install/uninstall/switch) rather than `os.apps.create` (new
|
|
10
|
+
// code arriving from a laptop, the `deploy` gate).
|
|
11
|
+
//
|
|
12
|
+
// ⚠ A 409 HERE IS AN ANSWER, NOT A FAILURE. "notes is already at 1.2.0" is the
|
|
13
|
+
// truthful reply to "update notes", and a CLI that exited non-zero on it would
|
|
14
|
+
// make `alexandr app update` unusable in any script that runs it more than once.
|
|
15
|
+
// Only a refusal that leaves the box somewhere it should not be is an error.
|
|
16
|
+
//
|
|
17
|
+
// This module also holds the two helpers the three release verbs share
|
|
18
|
+
// (`postKernelJson`, `resolveAppId`) — `rollback` and `entitle` import them from
|
|
19
|
+
// here rather than each transcribing the same eight lines, and deliberately NOT
|
|
20
|
+
// from `reach.js`, which is the "how do I talk to the box" module and has no
|
|
21
|
+
// business knowing what an app id is.
|
|
22
|
+
|
|
23
|
+
import { resolve } from "node:path";
|
|
24
|
+
import { bold, cyan, dim, fail, log, ok, step, warn } from "../util.js";
|
|
25
|
+
import { EXIT } from "../exit.js";
|
|
26
|
+
import { appIdOf } from "./deploy.js";
|
|
27
|
+
import { APP_ID_RE, headersFor, reachWorkspace } from "./reach.js";
|
|
28
|
+
|
|
29
|
+
export const UPDATE_HELP = `${bold("alexandr app update")} — move the linked workspace onto the newest release
|
|
30
|
+
|
|
31
|
+
${bold("USAGE")}
|
|
32
|
+
alexandr app update [--app <id>] [--dir <path>]
|
|
33
|
+
|
|
34
|
+
${bold("OPTIONS")}
|
|
35
|
+
--app <id> The app to update, when it isn't the one in this folder
|
|
36
|
+
--dir <path> The app folder, when it isn't the current one
|
|
37
|
+
|
|
38
|
+
${dim("Re-reads the catalog and switches the workspace onto the newest release. Refuses anything that isn't strictly newer — that is `alexandr app rollback`'s job. Needs the os.apps.manage permission in that workspace.")}`;
|
|
39
|
+
|
|
40
|
+
export async function appUpdate(flags, project) {
|
|
41
|
+
// ⚠ `--help` before anything that can exit — see the note in publish.js.
|
|
42
|
+
if (flags.help || flags.h) return void log(UPDATE_HELP);
|
|
43
|
+
const dir = project ?? projectFor(flags);
|
|
44
|
+
const chosen = resolveAppId(flags, dir);
|
|
45
|
+
if (chosen.error) fail(chosen.error, EXIT.USAGE);
|
|
46
|
+
const id = chosen.id;
|
|
47
|
+
|
|
48
|
+
const { url, authorization } = await reachWorkspace(dir);
|
|
49
|
+
step(`Updating ${bold(id)} in ${cyan(url)}…`);
|
|
50
|
+
const res = await postKernelJson(`${url}/_kernel/store/update`, { id }, headersFor(authorization, true));
|
|
51
|
+
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
if (res.status === 403) {
|
|
54
|
+
fail(
|
|
55
|
+
`The workspace refused it (403) — changing which version an app runs needs the 'os.apps.manage' permission in that workspace. ${res.error ?? ""}`.trim(),
|
|
56
|
+
EXIT.GENERAL,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (res.status === 409) {
|
|
60
|
+
// The door's own sentence, verbatim: it already names the installed version
|
|
61
|
+
// and what the catalog offers, which is more than this side knows.
|
|
62
|
+
const conflict = updateConflict(res.error, id);
|
|
63
|
+
return void (conflict.kind === "ok" ? ok(conflict.message) : warn(conflict.message));
|
|
64
|
+
}
|
|
65
|
+
// A build that failed AFTER the switch has already put the previous release
|
|
66
|
+
// back by the time this answers — say so, or the developer goes looking for a
|
|
67
|
+
// half-updated box that does not exist.
|
|
68
|
+
if (res.rolledBack) warn(`${id} is back on its previous release.`);
|
|
69
|
+
fail(`Couldn't update ${id}: ${res.error}`, EXIT.GENERAL);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const { version, previous } = res.data ?? {};
|
|
73
|
+
ok(`${bold(id)} is on ${bold(version ?? "its newest release")}${previous ? dim(` (was ${previous})`) : ""}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A 409 from the update door, classified.
|
|
78
|
+
*
|
|
79
|
+
* "already at X" is the happy answer to a re-run and reads as a tick; anything
|
|
80
|
+
* else the door refuses with (a catalog that does not name a version for this
|
|
81
|
+
* app) is a real gap the developer should see as a warning. Neither is an exit
|
|
82
|
+
* code — see the header.
|
|
83
|
+
*/
|
|
84
|
+
export function updateConflict(error, id) {
|
|
85
|
+
const message = typeof error === "string" && error.trim() ? error.trim() : `${id} has nothing newer to move to.`;
|
|
86
|
+
return { kind: /already at/i.test(message) ? "ok" : "warn", message };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The folder these verbs act in: `--dir`, else the cwd.
|
|
91
|
+
*
|
|
92
|
+
* ⚠ NOT `appDirOf` — and the difference is the point. `update`, `rollback` and
|
|
93
|
+
* `entitle` can name their app with `--app`, so a folder with no manifest.json
|
|
94
|
+
* is a perfectly legitimate place to run them from (what they need is the
|
|
95
|
+
* project's `.alexandr/link.json`, not its manifest). `appDirOf` would refuse
|
|
96
|
+
* that folder, and refuse it with the wrong reason.
|
|
97
|
+
*/
|
|
98
|
+
export function projectFor(flags, cwd = process.cwd()) {
|
|
99
|
+
return typeof flags.dir === "string" && flags.dir.trim() ? resolve(cwd, flags.dir) : resolve(cwd);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Which app: `--app` when given, else the one this folder declares.
|
|
104
|
+
*
|
|
105
|
+
* Pure — it answers `{ id }` or `{ error }` rather than exiting, so the choice
|
|
106
|
+
* is testable without a process. `--app` is validated against the same grammar
|
|
107
|
+
* the kernel mints ids with, so a typo is refused here with the rule spelled
|
|
108
|
+
* out instead of arriving as a 404 from the box.
|
|
109
|
+
*/
|
|
110
|
+
export function resolveAppId(flags, project) {
|
|
111
|
+
const named = typeof flags.app === "string" ? flags.app.trim() : "";
|
|
112
|
+
if (named) {
|
|
113
|
+
if (!APP_ID_RE.test(named)) {
|
|
114
|
+
return { error: `'${named}' is not an app id — lowercase letters, digits and dashes.` };
|
|
115
|
+
}
|
|
116
|
+
return { id: named };
|
|
117
|
+
}
|
|
118
|
+
const declared = appIdOf(project);
|
|
119
|
+
if (!declared) {
|
|
120
|
+
return {
|
|
121
|
+
error: `No manifest.json in ${project} to read an app id from — name the app with \`--app <id>\`, or run this inside its folder.`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return { id: declared };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* POST json to a kernel door → `{ ok, status, data, error, rolledBack }`.
|
|
129
|
+
*
|
|
130
|
+
* The kernel's store routes answer with a BODY on failure too (`{ error }`, and
|
|
131
|
+
* `rolledBack` when an update undid itself), so the status alone is never the
|
|
132
|
+
* whole answer — a helper that threw away the body would lose the only sentence
|
|
133
|
+
* worth printing. Shaped like `postMultipart` in deploy.js for the same reason.
|
|
134
|
+
*/
|
|
135
|
+
export async function postKernelJson(url, body, headers = {}) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await fetch(url, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "content-type": "application/json", ...headers },
|
|
140
|
+
body: JSON.stringify(body),
|
|
141
|
+
});
|
|
142
|
+
const data = await res.json().catch(() => null);
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
status: res.status,
|
|
147
|
+
error: typeof data?.error === "string" && data.error ? data.error : `HTTP ${res.status}`,
|
|
148
|
+
rolledBack: data?.rolledBack === true,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
return { ok: true, status: res.status, data };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
return { ok: false, status: 0, error: `couldn't reach ${safeOrigin(url)}: ${e?.message ?? e}` };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function safeOrigin(url) {
|
|
158
|
+
try {
|
|
159
|
+
return new URL(url).origin;
|
|
160
|
+
} catch {
|
|
161
|
+
return url;
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import * as cmd from "./commands.js";
|
|
|
10
10
|
import { resolveInstance, isMaterialized, kernelPort } from "./instance.js";
|
|
11
11
|
import { kernelUrl, version as kVersion } from "./probe.js";
|
|
12
12
|
import { COMMANDS, completion } from "./completion.js";
|
|
13
|
+
import { app } from "./app/index.js";
|
|
13
14
|
import { EXIT } from "./exit.js";
|
|
14
15
|
|
|
15
16
|
const pkg = JSON.parse(
|
|
@@ -51,6 +52,7 @@ async function version(flags) {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
const TABLE = {
|
|
55
|
+
app,
|
|
54
56
|
up: cmd.up, down: cmd.down, destroy: cmd.destroy, status: cmd.status,
|
|
55
57
|
ls: cmd.ls, logs: cmd.logs, config: cmd.config, init: cmd.init,
|
|
56
58
|
update: cmd.update, connect: cmd.connect, link: cmd.link, login: cmd.login, backup: cmd.backup,
|
|
@@ -61,7 +63,10 @@ export async function run(argv = process.argv.slice(2)) {
|
|
|
61
63
|
const flags = parseArgs(argv);
|
|
62
64
|
const sub = flags._.shift();
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
// ⚠ `--help` is handled HERE for every verb except `app`, whose subcommands
|
|
67
|
+
// each have their own help (`alexandr app deploy --help`). Swallowing it at
|
|
68
|
+
// this level would make those unreachable.
|
|
69
|
+
if (!sub || ((flags.help || flags.h) && sub !== "app")) {
|
|
65
70
|
if (!sub && (flags.version || flags.v)) return version(flags);
|
|
66
71
|
log(HELP);
|
|
67
72
|
return;
|
package/src/completion.js
CHANGED
|
@@ -6,12 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { fail } from "./util.js";
|
|
8
8
|
import { EXIT } from "./exit.js";
|
|
9
|
+
import { APP_SUBCOMMANDS } from "./app/index.js";
|
|
9
10
|
|
|
10
11
|
// One row per command: { name, usage, summary }. `usage` is the display form for
|
|
11
12
|
// help (may carry an arg hint like "logs [-f]"); `name` is the bare verb used for
|
|
12
13
|
// completion. Order is the help display order.
|
|
13
14
|
export const COMMANDS = [
|
|
14
15
|
{ name: "up", usage: "up", summary: "Sign in (first run), pull + boot the runtime; print the URL" },
|
|
16
|
+
{
|
|
17
|
+
name: "app",
|
|
18
|
+
usage: "app <cmd>",
|
|
19
|
+
// ⚠ The CANDIDATES come from `APP_SUBCOMMANDS` (src/app/index.js), so tab
|
|
20
|
+
// completion is right on its own — this line is only the one-line summary a
|
|
21
|
+
// person reads, and it has to be kept in step by hand.
|
|
22
|
+
summary: "Develop an app: link | dev | build | deploy | secrets | config | publish | update | rollback | entitle",
|
|
23
|
+
},
|
|
15
24
|
{ name: "down", usage: "down", summary: "Stop the runtime (data preserved)" },
|
|
16
25
|
{ name: "status", usage: "status", summary: "Show state, version, URL, and data size" },
|
|
17
26
|
{ name: "ls", usage: "ls", summary: "List all alexandr instances" },
|
|
@@ -37,6 +46,8 @@ const NAMES = COMMANDS.map((c) => c.name);
|
|
|
37
46
|
|
|
38
47
|
// Sub-surfaces worth completing one level deep.
|
|
39
48
|
const CONFIG_SUBS = ["get", "set", "list", "unset"];
|
|
49
|
+
// The `app` verb family (app-system-stage-1.md §2 WP-E) — the laptop door.
|
|
50
|
+
const APP_SUBS = APP_SUBCOMMANDS.map((c) => c.name);
|
|
40
51
|
|
|
41
52
|
// ---- generators -------------------------------------------------------------
|
|
42
53
|
|
|
@@ -55,6 +66,7 @@ _alexandr() {
|
|
|
55
66
|
case "\$cmd" in
|
|
56
67
|
completion) COMPREPLY=( \$(compgen -W "${SHELLS.join(" ")}" -- "\$cur") ); return ;;
|
|
57
68
|
config) if [ "\$COMP_CWORD" -eq 2 ]; then COMPREPLY=( \$(compgen -W "${CONFIG_SUBS.join(" ")}" -- "\$cur") ); return; fi ;;
|
|
69
|
+
app) if [ "\$COMP_CWORD" -eq 2 ]; then COMPREPLY=( \$(compgen -W "${APP_SUBS.join(" ")}" -- "\$cur") ); return; fi ;;
|
|
58
70
|
esac
|
|
59
71
|
if [[ "\$cur" == -* ]]; then
|
|
60
72
|
COMPREPLY=( \$(compgen -W "${GLOBAL_FLAGS.join(" ")}" -- "\$cur") )
|
|
@@ -88,6 +100,7 @@ fi
|
|
|
88
100
|
case "\${words[2]}" in
|
|
89
101
|
completion) _values 'shell' ${SHELLS.join(" ")}; return ;;
|
|
90
102
|
config) (( CURRENT == 3 )) && { _values 'subcommand' ${CONFIG_SUBS.join(" ")}; return } ;;
|
|
103
|
+
app) (( CURRENT == 3 )) && { _values 'subcommand' ${APP_SUBS.join(" ")}; return } ;;
|
|
91
104
|
esac
|
|
92
105
|
_describe -t options 'option' _alexandr_flags
|
|
93
106
|
`;
|
|
@@ -104,6 +117,7 @@ function fishScript() {
|
|
|
104
117
|
}
|
|
105
118
|
lines.push("complete -c alexandr -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'");
|
|
106
119
|
lines.push(`complete -c alexandr -n '__fish_seen_subcommand_from config' -a '${CONFIG_SUBS.join(" ")}'`);
|
|
120
|
+
lines.push(`complete -c alexandr -n '__fish_seen_subcommand_from app' -a '${APP_SUBS.join(" ")}'`);
|
|
107
121
|
for (const f of GLOBAL_FLAGS) {
|
|
108
122
|
if (f.startsWith("--")) lines.push(`complete -c alexandr -l ${f.slice(2)}`);
|
|
109
123
|
}
|
package/src/consent.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// THE consent ceremony — one implementation, two callers.
|
|
2
|
+
//
|
|
3
|
+
// Extracted from link.js 2026-09-05 (app-system-stage-1.md §2 WP-E). It was
|
|
4
|
+
// written for `alexandr link` (bind this box to an account) and is now also what
|
|
5
|
+
// `alexandr app link` runs, asking for a different SCOPE. Copying it would have
|
|
6
|
+
// meant two ceremonies drifting apart on the security-critical half of the CLI,
|
|
7
|
+
// so it moved here and grew one parameter.
|
|
8
|
+
//
|
|
9
|
+
// Two grant shapes, one ceremony:
|
|
10
|
+
// - DEVICE (RFC 8628 shape) on a headless machine: the CLI mints a grant,
|
|
11
|
+
// prints a short code + URL, and polls until a signed-in owner confirms from
|
|
12
|
+
// ANY device. No tunnel.
|
|
13
|
+
// - LOOPBACK (OAuth authorization-code + PKCE) on a desktop: the browser and
|
|
14
|
+
// the CLI share a machine, so the redirect lands instantly.
|
|
15
|
+
//
|
|
16
|
+
// SCOPE (WP-E). The CLI asks at the START and the owner sees it on the consent
|
|
17
|
+
// card; the CP records it on the grant and reads it back at redeem, never from
|
|
18
|
+
// the token request — a PKCE verifier (or a device code) proves possession, not
|
|
19
|
+
// authorization.
|
|
20
|
+
//
|
|
21
|
+
// (omitted) -> client "cli-link", 10 minutes. `alexandr link` uses it once.
|
|
22
|
+
// "app-dev" -> client "cli", 30 days sliding. `alexandr app link` keeps it.
|
|
23
|
+
//
|
|
24
|
+
// Zero dependencies. Plain Node, like the rest of this CLI.
|
|
25
|
+
|
|
26
|
+
import http from "node:http";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import crypto from "node:crypto";
|
|
29
|
+
import readline from "node:readline";
|
|
30
|
+
import { log, dim, bold, cyan, fail, step, sleep, openURL } from "./util.js";
|
|
31
|
+
|
|
32
|
+
const b64url = (buf) => buf.toString("base64url");
|
|
33
|
+
|
|
34
|
+
// The hosted control plane + the WEBSITE (the one web property — the web app
|
|
35
|
+
// retired, website-account-surface.md). Override for dev (e.g.
|
|
36
|
+
// http://localhost:47400 + http://localhost:47300) via env. The link-consent
|
|
37
|
+
// page (/cli-auth) is a website surface.
|
|
38
|
+
export const CP_URL = (process.env.ALEXANDR_CP_URL || "https://api.alexandr.so").replace(/\/+$/, "");
|
|
39
|
+
export const APP_URL = (process.env.ALEXANDR_APP_URL || "https://alexandr.so").replace(/\/+$/, "");
|
|
40
|
+
export const TIMEOUT_MS = 5 * 60 * 1000;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Run the ceremony this machine can actually complete and return the session it
|
|
44
|
+
* earned: `{ token, expiresAt, client, scope }` (older control planes answer
|
|
45
|
+
* with `token` alone, so read the rest defensively).
|
|
46
|
+
*
|
|
47
|
+
* Throws on any break in the chain — the caller decides between `fail()` and a
|
|
48
|
+
* soft skip.
|
|
49
|
+
*/
|
|
50
|
+
export async function consentSession({ host, name, intent = "link", scope, domain } = {}) {
|
|
51
|
+
return useDeviceFlow()
|
|
52
|
+
? deviceGrantSession({ host, name, intent, scope })
|
|
53
|
+
: loopbackGrantSession({ host, name, intent, scope, domain });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The DESKTOP grant — authorization-code + PKCE against a loopback redirect. */
|
|
57
|
+
export async function loopbackGrantSession({ host, name, intent, scope, domain }) {
|
|
58
|
+
const verifier = b64url(crypto.randomBytes(32));
|
|
59
|
+
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
|
|
60
|
+
const state = b64url(crypto.randomBytes(16));
|
|
61
|
+
|
|
62
|
+
const { port, done } = await startLoopback();
|
|
63
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
64
|
+
const authUrl =
|
|
65
|
+
`${APP_URL}/cli-auth?` +
|
|
66
|
+
new URLSearchParams({
|
|
67
|
+
redirect_uri: redirectUri,
|
|
68
|
+
state,
|
|
69
|
+
code_challenge: challenge,
|
|
70
|
+
code_challenge_method: "S256",
|
|
71
|
+
host: host ?? "",
|
|
72
|
+
name: name ?? "",
|
|
73
|
+
// ⚠ The consent page relays this into POST /cli-auth/authorize. WITHOUT it
|
|
74
|
+
// every PKCE `app-dev` link silently gets the 10-minute session instead.
|
|
75
|
+
...(scope ? { scope } : {}),
|
|
76
|
+
// Copy-only hint for the consent card ("refresh" renews the image
|
|
77
|
+
// credential and registers nothing) — the grant's power is identical.
|
|
78
|
+
...(intent && intent !== "link" ? { intent } : {}),
|
|
79
|
+
}).toString();
|
|
80
|
+
|
|
81
|
+
await presentAuthUrl(authUrl, { port, domain });
|
|
82
|
+
|
|
83
|
+
const cb = await done;
|
|
84
|
+
if (cb.state !== state) throw new Error("state mismatch (possible interception).");
|
|
85
|
+
|
|
86
|
+
const tok = await postJson(`${CP_URL}/cli-auth/token`, {
|
|
87
|
+
code: cb.code,
|
|
88
|
+
codeVerifier: verifier,
|
|
89
|
+
redirectUri,
|
|
90
|
+
});
|
|
91
|
+
if (!tok.data?.token) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`could not exchange the authorization code (${tok.error ?? "malformed response"}).`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return tok.data;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The HEADLESS ceremony — the device-authorization flow, because a remote box
|
|
101
|
+
* can't receive a loopback redirect and making the user build an ssh tunnel for
|
|
102
|
+
* a sign-in was backwards. Network blips just keep polling; a real rejection
|
|
103
|
+
* throws.
|
|
104
|
+
*/
|
|
105
|
+
export async function deviceGrantSession({ host, name, intent, scope }) {
|
|
106
|
+
const mint = await postJson(`${CP_URL}/cli-auth/device`, { host, name, intent, scope });
|
|
107
|
+
if (!mint.data?.deviceCode || !mint.data?.userCode) {
|
|
108
|
+
throw new Error(`couldn't start the sign-in (${mint.error ?? "malformed response"}).`);
|
|
109
|
+
}
|
|
110
|
+
const { deviceCode, userCode, expiresIn = 600, interval = 3 } = mint.data;
|
|
111
|
+
log("");
|
|
112
|
+
step("Open this link on any device — your computer or your phone — and confirm the code:");
|
|
113
|
+
log(` ${cyan(`${APP_URL}/cli-auth?code=${encodeURIComponent(userCode)}`)}`);
|
|
114
|
+
log("");
|
|
115
|
+
log(` Code: ${bold(userCode)}`);
|
|
116
|
+
log("");
|
|
117
|
+
log(dim(` (waiting for the confirmation — ${Math.round(expiresIn / 60)} minutes; this updates by itself)`));
|
|
118
|
+
const deadline = Date.now() + expiresIn * 1000;
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
await sleep(interval * 1000);
|
|
121
|
+
const res = await postJson(`${CP_URL}/cli-auth/device/token`, { deviceCode });
|
|
122
|
+
if (res.data?.token) return res.data;
|
|
123
|
+
if (res.data?.status === "pending") continue;
|
|
124
|
+
if (res.error?.startsWith("HTTP")) throw new Error(`the sign-in was rejected (${res.error}).`);
|
|
125
|
+
// Network blip — keep polling until the code's own deadline.
|
|
126
|
+
}
|
|
127
|
+
throw new Error("the code expired before it was confirmed — run the command again.");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Which ceremony fits this machine. ALEXANDR_DEVICE_FLOW=1|0 overrides. */
|
|
131
|
+
export function useDeviceFlow() {
|
|
132
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "1") return true;
|
|
133
|
+
if (process.env.ALEXANDR_DEVICE_FLOW === "0") return false;
|
|
134
|
+
return isHeadless();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** No local browser to open — a Linux box with no display server. Pure for tests. */
|
|
138
|
+
export function isHeadless(platform = process.platform, env = process.env) {
|
|
139
|
+
return platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Present the consent URL honestly, by what this machine can actually do:
|
|
144
|
+
* - HEADLESS (a server): never pretend a browser opened. Numbered steps, tunnel
|
|
145
|
+
* FIRST (the redirect lands on the desktop's loopback and must reach this box).
|
|
146
|
+
* - DESKTOP + TTY: ask before taking over the browser — the URL is printed
|
|
147
|
+
* either way, so "open it yourself" is always available.
|
|
148
|
+
* - DESKTOP non-TTY (scripts): print + best-effort open, nothing blocks.
|
|
149
|
+
*/
|
|
150
|
+
export async function presentAuthUrl(authUrl, { port, domain, headless = isHeadless() }) {
|
|
151
|
+
const sshTarget = `${process.env.USER || "root"}@${domain || os.hostname()}`;
|
|
152
|
+
if (headless) {
|
|
153
|
+
log("");
|
|
154
|
+
step("This machine has no browser — finish the sign-in from your computer:");
|
|
155
|
+
log(` 1. Forward the callback port ${dim("(keep this running until you're done)")}:`);
|
|
156
|
+
log(` ${bold(`ssh -L ${port}:127.0.0.1:${port} ${sshTarget}`)}`);
|
|
157
|
+
log(` 2. Open this link in a browser signed in to your alexandr account:`);
|
|
158
|
+
log(` ${cyan(authUrl)}`);
|
|
159
|
+
log(dim(` (waiting for the confirmation — ${TIMEOUT_MS / 60000} minutes)`));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
log(dim(authUrl));
|
|
163
|
+
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
164
|
+
await new Promise((resolve) => {
|
|
165
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
166
|
+
rl.question(
|
|
167
|
+
`${cyan("›")} Press Enter to open your browser and confirm ${dim("(or open the link above yourself)")} `,
|
|
168
|
+
() => {
|
|
169
|
+
rl.close();
|
|
170
|
+
resolve();
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
openURL(authUrl);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Start a loopback listener for the OAuth redirect. Resolves {code,state} when /callback is hit. */
|
|
179
|
+
export function startLoopback() {
|
|
180
|
+
let resolveFn, rejectFn;
|
|
181
|
+
const done = new Promise((res, rej) => {
|
|
182
|
+
resolveFn = res;
|
|
183
|
+
rejectFn = rej;
|
|
184
|
+
});
|
|
185
|
+
const server = http.createServer((req, res) => {
|
|
186
|
+
const u = new URL(req.url, "http://127.0.0.1");
|
|
187
|
+
// Reachability probe for the consent page: /cli-auth pings this before the
|
|
188
|
+
// user clicks, so a missing ssh tunnel becomes a guided notice instead of a
|
|
189
|
+
// dead browser error page after the click. The PNA header answers Chrome's
|
|
190
|
+
// public->loopback preflight; ACAO lets the page read the success.
|
|
191
|
+
if (u.pathname === "/ping") {
|
|
192
|
+
res.writeHead(204, {
|
|
193
|
+
"access-control-allow-origin": "*",
|
|
194
|
+
"access-control-allow-methods": "GET, OPTIONS",
|
|
195
|
+
"access-control-allow-headers": "*",
|
|
196
|
+
"access-control-allow-private-network": "true",
|
|
197
|
+
});
|
|
198
|
+
res.end();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (u.pathname !== "/callback") {
|
|
202
|
+
res.writeHead(404);
|
|
203
|
+
res.end();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
207
|
+
res.end(
|
|
208
|
+
"<!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>",
|
|
209
|
+
);
|
|
210
|
+
clearTimeout(timer);
|
|
211
|
+
setTimeout(() => server.close(), 200);
|
|
212
|
+
const code = u.searchParams.get("code");
|
|
213
|
+
const st = u.searchParams.get("state");
|
|
214
|
+
if (code && st) resolveFn({ code, state: st });
|
|
215
|
+
else rejectFn(new Error("no authorization code in the redirect"));
|
|
216
|
+
});
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
server.close();
|
|
219
|
+
rejectFn(new Error("timed out waiting for the browser confirmation"));
|
|
220
|
+
}, TIMEOUT_MS);
|
|
221
|
+
return new Promise((ready, readyErr) => {
|
|
222
|
+
server.once("error", (e) => {
|
|
223
|
+
rejectFn(e);
|
|
224
|
+
readyErr(e);
|
|
225
|
+
});
|
|
226
|
+
server.listen(0, "127.0.0.1", () => ready({ port: server.address().port, done }));
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* POST json -> `{ data }` on success, `{ error }` on failure. The CP writes
|
|
232
|
+
* human-readable `error` strings (e.g. the closed-alpha 403 explains exactly who
|
|
233
|
+
* may register), so failures must carry WHY — a bare null renders as "could not
|
|
234
|
+
* register this runtime" with the real reason swallowed. Callers surface
|
|
235
|
+
* `error` in their fail/skip message.
|
|
236
|
+
*/
|
|
237
|
+
export async function postJson(url, body, headers = {}) {
|
|
238
|
+
try {
|
|
239
|
+
const res = await fetch(url, {
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "content-type": "application/json", ...headers },
|
|
242
|
+
body: JSON.stringify(body),
|
|
243
|
+
});
|
|
244
|
+
const data = await res.json().catch(() => null);
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
247
|
+
return { error: `HTTP ${res.status}${detail}` };
|
|
248
|
+
}
|
|
249
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
250
|
+
} catch (e) {
|
|
251
|
+
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** GET json -> `{ data }` / `{ error }`, the read half of postJson. */
|
|
256
|
+
export async function getJson(url, headers = {}) {
|
|
257
|
+
try {
|
|
258
|
+
const res = await fetch(url, { headers });
|
|
259
|
+
const data = await res.json().catch(() => null);
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
|
|
262
|
+
return { error: `HTTP ${res.status}${detail}`, status: res.status };
|
|
263
|
+
}
|
|
264
|
+
return data == null ? { error: "malformed response" } : { data };
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return { error: `couldn't reach ${new URL(url).origin}: ${e?.message ?? e}` };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// `fail` is re-exported so callers that want the process-exiting form don't have
|
|
271
|
+
// to import two modules for one ceremony.
|
|
272
|
+
export { fail };
|
package/src/deps.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
// LINUX box (the self-host case), it offers to install them right there — Docker's official
|
|
3
3
|
// convenience script (get.docker.com) + systemd start — instead of failing with a doc link.
|
|
4
4
|
// Interactive-only (a real TTY, an explicit yes), root or sudo. macOS/Windows stay
|
|
5
|
-
// guidance-only: Docker Desktop can't be installed silently
|
|
6
|
-
// ("On this Mac") uses the desktop app's own VM, never Docker.
|
|
5
|
+
// guidance-only: Docker Desktop can't be installed silently.
|
|
7
6
|
|
|
8
7
|
import { spawnSync } from "node:child_process";
|
|
9
8
|
import { log, ok, warn, step, dim, bold } from "./util.js";
|