alexandr 0.2.2 → 0.3.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.
@@ -0,0 +1,234 @@
1
+ // `alexandr app deploy` — build, package, checksum, upload.
2
+ //
3
+ // THE developer's push-to-update (app-system-foundation.md §4 Part 7). Four
4
+ // steps, no magic:
5
+ //
6
+ // 1. build the project's own @alexandr/app-build, same as `app build`
7
+ // 2. pack the WHOLE app folder + its sha256 (never node_modules, a
8
+ // .git, a database, or .alexandr/)
9
+ // 3. POST /_kernel/apps/sideload as multipart: the `package` file
10
+ // part and the `sha256` field, with a fresh workspace JWT
11
+ // 4. tail GET /_kernel/apps/<id>/logs until the install settles
12
+ //
13
+ // ⚠ The sha256 is REQUIRED and the kernel checks it before a byte reaches the
14
+ // volume. It is not a convenience: sideload is the door that introduces NEW
15
+ // executable code into a shared container, and the catalog path has verified for
16
+ // as long as it has existed.
17
+
18
+ import { readFileSync } from "node:fs";
19
+ import { readFile, rm } from "node:fs/promises";
20
+ import { tmpdir } from "node:os";
21
+ import { resolve } from "node:path";
22
+ import { bold, cyan, dim, fail, log, ok, step, warn, sleep } from "../util.js";
23
+ import { getJson } from "../consent.js";
24
+ import { EXIT } from "../exit.js";
25
+ import { appDirOf, buildApp, loadBuilder } from "./build.js";
26
+ import { buildMultipart } from "./multipart.js";
27
+ import { isLocalTrust, readAuth, authUsable } from "./store.js";
28
+ import { ensureWorkspaceToken } from "./token.js";
29
+ import { requireLink } from "./dev.js";
30
+
31
+ export const DEPLOY_HELP = `${bold("alexandr app deploy")} — put this app in the linked workspace
32
+
33
+ ${bold("USAGE")}
34
+ alexandr app deploy [--dir <path>] [--timeout <seconds>]
35
+
36
+ ${dim("Builds, packages the whole app folder with its sha256, and uploads it to /_kernel/apps/sideload. Needs the os.apps.create permission in that workspace.")}`;
37
+
38
+ /** How long we tail the install log before saying so and stopping. */
39
+ const DEFAULT_TIMEOUT_S = 180;
40
+
41
+ export async function appDeploy(flags) {
42
+ if (flags.help || flags.h) return void log(DEPLOY_HELP);
43
+ const project = appDirOf(flags);
44
+ const link = requireLink(project);
45
+
46
+ // ── auth ────────────────────────────────────────────────────────────────────
47
+ let authorization = null;
48
+ let runtimeUrl = link.instanceUrl;
49
+ if (!isLocalTrust(link)) {
50
+ const session = readAuth();
51
+ if (!authUsable(session, link.cpUrl)) {
52
+ fail("Your account session has expired. Run `alexandr app link --relink`.", EXIT.GENERAL);
53
+ }
54
+ const minted = await ensureWorkspaceToken(link, session, { project, force: true });
55
+ if (!minted.ok) {
56
+ if (minted.pending) {
57
+ fail(`The workspace is ${minted.status} — start it, then deploy again.`, EXIT.NO_INSTANCE);
58
+ }
59
+ fail(`Couldn't get a workspace token: ${minted.error}`, EXIT.GENERAL);
60
+ }
61
+ authorization = `Bearer ${minted.token}`;
62
+ runtimeUrl = minted.url || runtimeUrl;
63
+ }
64
+ if (!runtimeUrl) fail("The linked workspace has no URL yet. Start it, then deploy again.", EXIT.NO_INSTANCE);
65
+ runtimeUrl = runtimeUrl.replace(/\/+$/, "");
66
+
67
+ // ── 1. build ────────────────────────────────────────────────────────────────
68
+ const built = await buildApp(project, { quiet: false });
69
+ if (!built.ok) fail(built.error, EXIT.GENERAL);
70
+
71
+ // ── 2. pack ─────────────────────────────────────────────────────────────────
72
+ const { pack } = await loadBuilder(project);
73
+ const outFile = resolve(tmpdir(), `alexandr-deploy-${process.pid}-${Date.now()}.tar.gz`);
74
+ step("Packaging…");
75
+ const packed = await pack(project, { outFile });
76
+ for (const w of packed.warnings ?? []) warn(w);
77
+ if (!packed.ok) fail(packed.error ?? "packaging failed", EXIT.GENERAL);
78
+ log(dim(` ${packed.files.length} files, ${formatBytes(packed.bytes)}, sha256 ${packed.sha256.slice(0, 12)}…`));
79
+
80
+ // ── 3. upload ───────────────────────────────────────────────────────────────
81
+ // How long this app's log ring already is, so the tail below shows THIS
82
+ // install and not the history of every earlier one.
83
+ const logsBefore = await logRingLength(runtimeUrl, appIdOf(project), authorization);
84
+ try {
85
+ const archive = await readFile(packed.outFile);
86
+ const { body, contentType } = buildMultipart([
87
+ { name: "package", filename: "app.tar.gz", data: archive, contentType: "application/gzip" },
88
+ { name: "sha256", value: packed.sha256 },
89
+ ]);
90
+ step(`Uploading to ${cyan(runtimeUrl)}…`);
91
+ const res = await postMultipart(`${runtimeUrl}/_kernel/apps/sideload`, body, contentType, authorization);
92
+ if (!res.ok) {
93
+ if (res.status === 401 || res.status === 403) {
94
+ fail(
95
+ `The workspace refused the upload (${res.status}) — installing an app needs the 'os.apps.create' permission there. ${res.error ?? ""}`.trim(),
96
+ EXIT.GENERAL,
97
+ );
98
+ }
99
+ fail(`Upload failed: ${res.error}`, EXIT.GENERAL);
100
+ }
101
+ const { id, state } = res.data ?? {};
102
+ if (!id) fail("The workspace accepted the package but named no app.", EXIT.GENERAL);
103
+ ok(`Installed ${bold(id)} ${dim(`(${state ?? "installed"})`)}`);
104
+
105
+ // ── 4. tail ───────────────────────────────────────────────────────────────
106
+ const timeoutS = Number(flags.timeout) > 0 ? Number(flags.timeout) : DEFAULT_TIMEOUT_S;
107
+ const settled = await tailInstall(runtimeUrl, id, authorization, {
108
+ timeoutMs: timeoutS * 1000,
109
+ installedState: state,
110
+ startAt: logsBefore,
111
+ });
112
+ if (settled.state === "failed") {
113
+ fail(`${id} failed to start. Fix it and deploy again.`, EXIT.RUNTIME);
114
+ }
115
+ log("");
116
+ log(`${cyan("›")} Open it: ${dim(`${runtimeUrl}/apps/${id}/`)}`);
117
+ } finally {
118
+ await rm(packed.outFile, { force: true });
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Follow the app's log ring until the install settles, printing new lines.
124
+ *
125
+ * ⚠ It does NOT wait for `ready`, and that is deliberate. `sideload` stages,
126
+ * migrates and builds synchronously, so a 200 already means installed; the app's
127
+ * SERVER, meanwhile, is spawned on demand by the first proxied request, so a
128
+ * freshly deployed app sits at `stopped` forever and a poll for `ready` would
129
+ * simply time out. What we wait for is the log going quiet — the build lines the
130
+ * kernel appended — or the state going `failed`.
131
+ */
132
+ export async function tailInstall(
133
+ runtimeUrl,
134
+ id,
135
+ authorization,
136
+ {
137
+ timeoutMs = DEFAULT_TIMEOUT_S * 1000,
138
+ intervalMs = 1000,
139
+ quietPolls = 2,
140
+ installedState,
141
+ startAt = 0,
142
+ fetchJson = getJson,
143
+ // Injected so a unit test can drive the poll loop without writing to the
144
+ // process's stdout — Node's test runner multiplexes its own protocol there.
145
+ print = (line) => log(dim(` ${line}`)),
146
+ } = {},
147
+ ) {
148
+ const headers = authorization ? { authorization } : {};
149
+ const deadline = Date.now() + timeoutMs;
150
+ // ⚠ Start from the ring length we measured BEFORE the upload, not from zero.
151
+ // `app_logs` is a per-app ring that survives reinstalls, so a second deploy
152
+ // would otherwise replay the first one's failures as if they had just
153
+ // happened — the most confusing possible output for someone who just fixed
154
+ // exactly those errors. Counting rather than timestamping keeps it free of
155
+ // clock skew between this laptop and the box.
156
+ let seen = startAt;
157
+ let quiet = 0;
158
+ let state = installedState ?? "unknown";
159
+
160
+ while (Date.now() < deadline) {
161
+ const res = await fetchJson(`${runtimeUrl}/_kernel/apps/${encodeURIComponent(id)}/logs`, headers);
162
+ if (res.error) {
163
+ // The logs door is a nicety; losing it must not fail a good deploy.
164
+ return { state, timedOut: false, unreachable: true };
165
+ }
166
+ const lines = res.data?.logs ?? [];
167
+ // A ring that WRAPPED is shorter than where we started — show what is there.
168
+ if (lines.length < seen) seen = 0;
169
+ for (const line of lines.slice(seen)) print(line.line ?? line);
170
+ quiet = lines.length === seen ? quiet + 1 : 0;
171
+ seen = lines.length;
172
+ state = res.data?.state ?? state;
173
+
174
+ if (state === "failed") return { state, timedOut: false };
175
+ if (state === "ready") return { state, timedOut: false };
176
+ if (quiet >= quietPolls) return { state, timedOut: false };
177
+ await sleep(intervalMs);
178
+ }
179
+ warn(`Stopped following the install after ${Math.round(timeoutMs / 1000)}s — check the app page.`);
180
+ return { state, timedOut: true };
181
+ }
182
+
183
+ /** The app id this folder declares. Unknown ids simply mean "no ring yet". */
184
+ export function appIdOf(project) {
185
+ try {
186
+ const manifest = JSON.parse(readFileSync(resolve(project, "manifest.json"), "utf8"));
187
+ return typeof manifest?.id === "string" ? manifest.id : null;
188
+ } catch {
189
+ return null;
190
+ }
191
+ }
192
+
193
+ /** How many lines the app's log ring holds right now. 0 for an app that is new. */
194
+ export async function logRingLength(runtimeUrl, id, authorization, fetchJson = getJson) {
195
+ if (!id) return 0;
196
+ const res = await fetchJson(`${runtimeUrl}/_kernel/apps/${encodeURIComponent(id)}/logs`, {
197
+ ...(authorization ? { authorization } : {}),
198
+ });
199
+ return res.data?.logs?.length ?? 0;
200
+ }
201
+
202
+ /** POST a prebuilt multipart body. Mirrors consent.js's postJson error shape. */
203
+ export async function postMultipart(url, body, contentType, authorization) {
204
+ try {
205
+ const res = await fetch(url, {
206
+ method: "POST",
207
+ headers: { "content-type": contentType, ...(authorization ? { authorization } : {}) },
208
+ body,
209
+ });
210
+ const data = await res.json().catch(() => null);
211
+ if (!res.ok) {
212
+ const detail = typeof data?.error === "string" && data.error ? ` — ${data.error}` : "";
213
+ return { ok: false, status: res.status, error: `HTTP ${res.status}${detail}` };
214
+ }
215
+ return { ok: true, status: res.status, data };
216
+ } catch (e) {
217
+ return { ok: false, status: 0, error: `couldn't reach ${safeOrigin(url)}: ${e?.message ?? e}` };
218
+ }
219
+ }
220
+
221
+ function safeOrigin(url) {
222
+ try {
223
+ return new URL(url).origin;
224
+ } catch {
225
+ return url;
226
+ }
227
+ }
228
+
229
+ export function formatBytes(bytes) {
230
+ if (!Number.isFinite(bytes)) return "?";
231
+ if (bytes < 1024) return `${bytes} B`;
232
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
233
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
234
+ }
package/src/app/dev.js ADDED
@@ -0,0 +1,177 @@
1
+ // `alexandr app dev` — Vite against a real workspace.
2
+ //
3
+ // The whole verb is three things:
4
+ // 1. make sure a workspace token exists and stays fresh (the CP mints a
5
+ // 5-minute, audience-bound JWT; we re-mint on a timer while Vite runs),
6
+ // 2. write it where the project's vite.config.ts proxy reads it
7
+ // (`.alexandr/token.json`, re-read per request so a re-mint needs no
8
+ // restart),
9
+ // 3. spawn the project's OWN vite with ALEXANDR_RUNTIME_URL set.
10
+ //
11
+ // ⚠ The token never reaches the browser. The proxy attaches it server-side,
12
+ // which is why local dev uses the proxied posture rather than the SDK's direct
13
+ // mode: a workspace JWT in page JavaScript would be a credential in the DOM.
14
+ //
15
+ // ⚠ We spawn `node_modules/.bin/vite` directly, never `npm run dev` — the
16
+ // template's `dev` script IS this command, and shelling back through it would
17
+ // recurse.
18
+
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+ import { resolve } from "node:path";
22
+ import { bold, dim, fail, log, step, warn } from "../util.js";
23
+ import { EXIT } from "../exit.js";
24
+ import { appDirOf } from "./build.js";
25
+ import { authUsable, clearToken, isLocalTrust, readAuth, readLink } from "./store.js";
26
+ import { ensureWorkspaceToken, nextRemintDelay } from "./token.js";
27
+
28
+ export const DEV_HELP = `${bold("alexandr app dev")} — run this app against the linked workspace
29
+
30
+ ${bold("USAGE")}
31
+ alexandr app dev [--dir <path>] [--port <n>]
32
+
33
+ ${dim("Keeps a fresh workspace token in .alexandr/token.json (the Vite proxy attaches it) and runs the project's vite. Link first: alexandr app link.")}`;
34
+
35
+ /** The vite executable a project installed. Windows ships a `.cmd` shim. */
36
+ export function viteBin(project, platform = process.platform) {
37
+ const name = platform === "win32" ? "vite.cmd" : "vite";
38
+ return resolve(project, "node_modules", ".bin", name);
39
+ }
40
+
41
+ /** Read the project's link, or exit saying how to make one. */
42
+ export function requireLink(project) {
43
+ const link = readLink(project);
44
+ if (!link) {
45
+ fail("This project isn't linked to a workspace yet. Run `alexandr app link`.", EXIT.NO_INSTANCE);
46
+ }
47
+ return link;
48
+ }
49
+
50
+ /**
51
+ * The account session for a linked project, or exit.
52
+ * Local-trust projects never get here — they have nothing to present.
53
+ */
54
+ export function requireSession(link) {
55
+ const session = readAuth();
56
+ if (!authUsable(session, link.cpUrl)) {
57
+ fail("Your account session has expired. Run `alexandr app link --relink`.", EXIT.GENERAL);
58
+ }
59
+ return session;
60
+ }
61
+
62
+ export async function appDev(flags) {
63
+ if (flags.help || flags.h) return void log(DEV_HELP);
64
+ const project = appDirOf(flags);
65
+ const link = requireLink(project);
66
+
67
+ const bin = viteBin(project);
68
+ if (!existsSync(bin)) {
69
+ fail(
70
+ `No vite in ${project}/node_modules — run \`npm install\` (or \`bun install\`) first.`,
71
+ EXIT.NO_INSTANCE,
72
+ );
73
+ }
74
+
75
+ let runtimeUrl = link.instanceUrl;
76
+ let stopRefresh = () => {};
77
+
78
+ if (isLocalTrust(link)) {
79
+ // A local-trust box serves the signed-in app on loopback with no CP rung —
80
+ // there is no token to mint, and presenting one would be meaningless.
81
+ clearToken(project);
82
+ step(`Local-trust box ${dim(runtimeUrl)} — no workspace token needed.`);
83
+ } else {
84
+ const session = requireSession(link);
85
+ const first = await ensureWorkspaceToken(link, session, { project, force: true });
86
+ if (!first.ok) {
87
+ if (first.pending) {
88
+ fail(
89
+ `The workspace is ${first.status} — start it from the Alexandr app, then run this again.`,
90
+ EXIT.NO_INSTANCE,
91
+ );
92
+ }
93
+ fail(`Couldn't get a workspace token: ${first.error}`, EXIT.GENERAL);
94
+ }
95
+ runtimeUrl = first.url || runtimeUrl;
96
+ if (!runtimeUrl) fail("The workspace has no URL yet — start it, then run this again.", EXIT.NO_INSTANCE);
97
+ step(`Linked to ${bold(link.workspaceName ?? link.workspaceId)} ${dim(runtimeUrl)}`);
98
+ stopRefresh = startTokenRefresh(link, session, project, first.expiresAt);
99
+ }
100
+
101
+ log(dim(" proxying /_kernel and /apps/<id>/api to the workspace"));
102
+ log("");
103
+
104
+ const args = ["--host", "127.0.0.1"];
105
+ // `--port` is the one argument a person supplies; with the Windows shell in
106
+ // play (below) it must be a bare number and nothing else.
107
+ if (flags.port) {
108
+ const port = Number.parseInt(String(flags.port), 10);
109
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
110
+ throw new Error(`--port must be a number between 1 and 65535, got "${flags.port}"`);
111
+ }
112
+ args.push("--port", String(port));
113
+ }
114
+ const child = spawn(bin, args, {
115
+ cwd: project,
116
+ stdio: "inherit",
117
+ env: { ...process.env, ALEXANDR_RUNTIME_URL: runtimeUrl },
118
+ // `vite.cmd` is a shell shim: since Node's CVE-2024-27980 fix a `.cmd` cannot
119
+ // be spawned without `shell` (EINVAL). Arguments are literals + the checked port.
120
+ shell: process.platform === "win32",
121
+ });
122
+
123
+ const stop = (signal) => {
124
+ stopRefresh();
125
+ if (!child.killed) child.kill(signal);
126
+ };
127
+ process.on("SIGINT", () => stop("SIGINT"));
128
+ process.on("SIGTERM", () => stop("SIGTERM"));
129
+
130
+ await new Promise((done) => {
131
+ child.on("exit", (code) => {
132
+ stopRefresh();
133
+ process.exitCode = code ?? 0;
134
+ done();
135
+ });
136
+ child.on("error", (err) => {
137
+ stopRefresh();
138
+ fail(`Couldn't start vite: ${err.message}`, EXIT.RUNTIME);
139
+ });
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Keep `.alexandr/token.json` fresh for as long as the dev server runs.
145
+ *
146
+ * ⚠ Self-rescheduling rather than a fixed `setInterval`: the delay is computed
147
+ * from the token we actually hold (`nextRemintDelay`), so a CP that changes the
148
+ * TTL changes the cadence too. `unref()` keeps the timer from holding the
149
+ * process open after Vite exits.
150
+ */
151
+ export function startTokenRefresh(link, session, project, expiresAt, deps = {}) {
152
+ const { ensure = ensureWorkspaceToken, delayOf = nextRemintDelay, timer = setTimeout } = deps;
153
+ let handle;
154
+ let stopped = false;
155
+
156
+ const schedule = (when) => {
157
+ handle = timer(async () => {
158
+ if (stopped) return;
159
+ const next = await ensure(link, session, { project, force: true });
160
+ if (!next.ok) {
161
+ // A blip is not fatal: the current token is still good for a while, and
162
+ // the next attempt is soon. A dead session says so once.
163
+ warn(`Couldn't refresh the workspace token (${next.error}) — retrying.`);
164
+ schedule(30_000);
165
+ return;
166
+ }
167
+ schedule(delayOf(next.expiresAt));
168
+ }, when);
169
+ handle?.unref?.();
170
+ };
171
+
172
+ schedule(delayOf(expiresAt));
173
+ return () => {
174
+ stopped = true;
175
+ if (handle) clearTimeout(handle);
176
+ };
177
+ }
@@ -0,0 +1,139 @@
1
+ // `alexandr app entitle <workspaceId>` — let one workspace install a PRIVATE app.
2
+ //
3
+ // A private release is LISTED for everyone and downloadable by nobody
4
+ // (app-system-stage-3.md WP-O §1.9): being discoverable is the point, and taking
5
+ // it is what needs a grant. This verb is the publisher's half of that — the
6
+ // control-plane door `POST /apps/:appId/entitlements`, and `--remove` for the
7
+ // DELETE.
8
+ //
9
+ // ⚠ AN ACCOUNT ACT, NOT A WORKSPACE ONE. It never touches the box: the CP asks
10
+ // only whether the caller is the app's publisher (or an operator), because a
11
+ // workspace cannot be allowed to entitle itself to somebody else's app. That is
12
+ // also why there is no `--workspace` link to read — the workspace being entitled
13
+ // is named on the command line, and it is somebody else's.
14
+ //
15
+ // ⚠ Public apps need none of this, and entitling one is a no-op with a
16
+ // misleading tick — so the copy always says what an entitlement is FOR.
17
+
18
+ import { bold, cyan, dim, fail, log, ok, step } from "../util.js";
19
+ import { CP_URL, postJson } from "../consent.js";
20
+ import { EXIT } from "../exit.js";
21
+ import { ensureAccountSession } from "./link.js";
22
+ import { projectFor, resolveAppId } from "./update.js";
23
+
24
+ export const ENTITLE_HELP = `${bold("alexandr app entitle")} — let a workspace install this private app
25
+
26
+ ${bold("USAGE")}
27
+ alexandr app entitle <workspaceId> [--remove] [--app <id>] [--dir <path>]
28
+
29
+ ${bold("OPTIONS")}
30
+ --remove Take the entitlement away again
31
+ --app <id> The app, when it isn't the one in this folder
32
+ --dir <path> The app folder, when it isn't the current one
33
+
34
+ ${dim("A private release is listed for everyone and downloadable only by an entitled workspace. Only the app's publisher (or an operator) may change that.")}`;
35
+
36
+ export async function appEntitle(flags, project) {
37
+ // ⚠ `--help` before anything that can exit — see the note in publish.js.
38
+ if (flags.help || flags.h) return void log(ENTITLE_HELP);
39
+
40
+ const workspaceId = String(flags._.shift() ?? "").trim();
41
+ if (!workspaceId) {
42
+ fail("Name the workspace: `alexandr app entitle <workspaceId>`.", EXIT.USAGE);
43
+ }
44
+
45
+ const dir = project ?? projectFor(flags);
46
+ const chosen = resolveAppId(flags, dir);
47
+ if (chosen.error) fail(chosen.error, EXIT.USAGE);
48
+ const appId = chosen.id;
49
+
50
+ const session = await ensureAccountSession();
51
+ const cpUrl = session.cpUrl || CP_URL;
52
+ const base = `${cpUrl}/apps/${encodeURIComponent(appId)}/entitlements`;
53
+ const headers = { authorization: `Bearer ${session.token}` };
54
+ const removing = Boolean(flags.remove);
55
+
56
+ step(
57
+ `${removing ? "Removing" : "Granting"} ${bold(appId)} for ${bold(workspaceId)} ${dim(`(${cpUrl})`)}…`,
58
+ );
59
+ const res = removing
60
+ ? await deleteJson(`${base}/${encodeURIComponent(workspaceId)}`, headers)
61
+ : await postEntitlement(base, workspaceId, headers);
62
+
63
+ if (!res.ok) {
64
+ if (res.status === 403) {
65
+ fail(
66
+ `The control plane refused it (403) — only the app's publisher (or an admin) can change entitlements. ${res.error ?? ""}`.trim(),
67
+ EXIT.GENERAL,
68
+ );
69
+ }
70
+ if (res.status === 404) {
71
+ // Two different 404s, and the CP's own sentence tells them apart: an app
72
+ // with no releases has no publisher yet, and an unknown workspace id is a
73
+ // typo. Print what it said rather than guessing which.
74
+ fail(`${res.error} — nothing changed.`, EXIT.GENERAL);
75
+ }
76
+ fail(`Couldn't ${removing ? "remove" : "grant"} the entitlement: ${res.error}`, EXIT.GENERAL);
77
+ }
78
+
79
+ if (removing) {
80
+ ok(`${bold(workspaceId)} can no longer install ${bold(appId)}.`);
81
+ } else {
82
+ ok(`${bold(workspaceId)} can now install ${bold(appId)}.`);
83
+ }
84
+ log("");
85
+ log(
86
+ `${cyan("›")} ${dim(
87
+ removing
88
+ ? "A workspace that already installed it keeps what it has — the entitlement gates the download, not the copy on its volume."
89
+ : "An entitled workspace may download this private app; everyone else still only sees it listed.",
90
+ )}`,
91
+ );
92
+ }
93
+
94
+ /** POST the grant. `postJson` folds the status into its error string, so the
95
+ * status is read back out — the same reason publish.js has `statusOf`. */
96
+ async function postEntitlement(base, workspaceId, headers) {
97
+ const res = await postJson(base, { workspaceId }, headers);
98
+ if (res.error) {
99
+ const m = /^HTTP (\d{3})\b/.exec(res.error);
100
+ return { ok: false, status: m ? Number(m[1]) : 0, error: res.error };
101
+ }
102
+ return { ok: true, status: 200, data: res.data };
103
+ }
104
+
105
+ /**
106
+ * DELETE json → `{ ok, status, data, error }`.
107
+ *
108
+ * Written here because the CLI's shared helpers stop at GET, POST and PUT, and
109
+ * this is the only DELETE in the family.
110
+ *
111
+ * ⚠ THE SUCCESS CASE HAS NO BODY. The route answers 204, so there is nothing to
112
+ * parse — `res.json()` is allowed to reject and the rejection is swallowed,
113
+ * because calling `JSON.parse("")` on an empty 204 would turn the successful
114
+ * answer into a thrown SyntaxError.
115
+ */
116
+ export async function deleteJson(url, headers = {}) {
117
+ try {
118
+ const res = await fetch(url, { method: "DELETE", headers });
119
+ const data = await res.json().catch(() => null);
120
+ if (!res.ok) {
121
+ return {
122
+ ok: false,
123
+ status: res.status,
124
+ error: typeof data?.error === "string" && data.error ? data.error : `HTTP ${res.status}`,
125
+ };
126
+ }
127
+ return { ok: true, status: res.status, data };
128
+ } catch (e) {
129
+ return { ok: false, status: 0, error: `couldn't reach ${safeOrigin(url)}: ${e?.message ?? e}` };
130
+ }
131
+ }
132
+
133
+ function safeOrigin(url) {
134
+ try {
135
+ return new URL(url).origin;
136
+ } catch {
137
+ return url;
138
+ }
139
+ }
@@ -0,0 +1,125 @@
1
+ // The `app` verb family — the LAPTOP door of the app system.
2
+ //
3
+ // `alexandr app link | dev | build | deploy` is the developer half of the
4
+ // two-doors rule (app-system-foundation.md §3): everything the assistant can do
5
+ // inside a workspace, a developer can do from a terminal, against the same
6
+ // kernel engine. The UI door stays the primary one; this exists so an outside
7
+ // developer is never blocked on it.
8
+ //
9
+ // link sign in, pick the workspace, remember both
10
+ // dev Vite against that workspace — live data, hot reload
11
+ // build the workspace's own builder, on your machine
12
+ // deploy build -> package -> checksum -> sideload
13
+ // secrets give the app a value it declared (prompt or pipe — never argv)
14
+ // config say where each declared value comes from, and restart the app
15
+ // publish build -> pack -> sign -> the catalog, as a release
16
+ // update move the linked workspace onto the newest release
17
+ // rollback put it back on the one before
18
+ // entitle let a workspace install this private app
19
+ //
20
+ // The last four are the RELEASE half (app-system-stage-3.md §2 WP-P): `deploy`
21
+ // puts a build in ONE workspace, `publish` puts a signed, immutable release
22
+ // where every workspace can install it.
23
+ //
24
+ // The verbs live one per file; this module is the dispatcher and the help.
25
+
26
+ import { bold, cyan, dim, fail, log } from "../util.js";
27
+ import { EXIT } from "../exit.js";
28
+ import { appLink, LINK_HELP } from "./link.js";
29
+ import { appDev, DEV_HELP } from "./dev.js";
30
+ import { appBuild, BUILD_HELP } from "./build.js";
31
+ import { appDeploy, DEPLOY_HELP } from "./deploy.js";
32
+ // The CONFIGURATION half (app-system-stage-2.md §2 WP-L): the same two steps the
33
+ // app's own Configuration page takes, from a terminal.
34
+ // ⚠ ONE module for both verbs — see the note at the top of ./config.js for why
35
+ // it is not called secrets.js.
36
+ import { appConfig, appSecrets, CONFIG_HELP, SECRETS_HELP } from "./config.js";
37
+ // The RELEASE half (app-system-stage-3.md §2 WP-P). `publish` and `entitle` talk
38
+ // to the CONTROL PLANE with the account session; `update` and `rollback` talk to
39
+ // the BOX with a fresh workspace token — two different doors, deliberately.
40
+ import { appPublish, PUBLISH_HELP } from "./publish.js";
41
+ import { appUpdate, UPDATE_HELP } from "./update.js";
42
+ import { appRollback, ROLLBACK_HELP } from "./rollback.js";
43
+ import { appEntitle, ENTITLE_HELP } from "./entitle.js";
44
+
45
+ /** One row per subcommand — help and completion read the same list. */
46
+ export const APP_SUBCOMMANDS = [
47
+ { name: "link", summary: "Sign in and point this project at a workspace" },
48
+ { name: "dev", summary: "Run this app against the linked workspace (Vite + hot reload)" },
49
+ { name: "build", summary: "Build this app the way the workspace does" },
50
+ { name: "deploy", summary: "Build, package, and install it in the linked workspace" },
51
+ { name: "secrets", summary: "Give it a value it declared (from a prompt or a pipe, never argv)" },
52
+ { name: "config", summary: "Say where each declared value comes from; restarts the app" },
53
+ { name: "publish", summary: "Sign this version and publish it to the catalog" },
54
+ { name: "update", summary: "Move the linked workspace onto the newest release" },
55
+ { name: "rollback", summary: "Put it back on the previous release (your data keeps the newer layout)" },
56
+ { name: "entitle", summary: "Let a workspace install this private app" },
57
+ ];
58
+
59
+ const HELP = `${bold("alexandr app")} — develop an alexandr app on this machine
60
+
61
+ ${bold("USAGE")}
62
+ alexandr app <command> [options]
63
+ npm create alexandr-app@latest my-app ${dim("start a new one")}
64
+
65
+ ${bold("COMMANDS")}
66
+ ${APP_SUBCOMMANDS.map((c) => ` ${c.name.padEnd(9)} ${c.summary}`).join("\n")}
67
+
68
+ ${bold("THE LOOP")}
69
+ ${cyan("1.")} alexandr app link ${dim("once per project")}
70
+ ${cyan("2.")} alexandr app dev ${dim("while you work")}
71
+ ${cyan("3.")} alexandr app deploy ${dim("when it is ready")}
72
+ ${cyan("4.")} alexandr app secrets set DATABASE_URL ${dim("if it declared one")}
73
+ alexandr app config assign DATABASE_URL --connection shop-db
74
+ ${cyan("5.")} alexandr app publish ${dim("when you want to share it")}
75
+
76
+ ${dim("alexandr app <command> --help for one command's options.")}`;
77
+
78
+ const HELPS = {
79
+ link: LINK_HELP,
80
+ dev: DEV_HELP,
81
+ build: BUILD_HELP,
82
+ deploy: DEPLOY_HELP,
83
+ secrets: SECRETS_HELP,
84
+ config: CONFIG_HELP,
85
+ publish: PUBLISH_HELP,
86
+ update: UPDATE_HELP,
87
+ rollback: ROLLBACK_HELP,
88
+ entitle: ENTITLE_HELP,
89
+ };
90
+
91
+ const TABLE = {
92
+ link: appLink,
93
+ dev: appDev,
94
+ build: appBuild,
95
+ deploy: appDeploy,
96
+ secrets: appSecrets,
97
+ config: appConfig,
98
+ publish: appPublish,
99
+ update: appUpdate,
100
+ rollback: appRollback,
101
+ entitle: appEntitle,
102
+ };
103
+
104
+ /** `alexandr app …`. Registered in cli.js's TABLE beside the runtime verbs. */
105
+ export async function app(flags) {
106
+ const sub = flags._.shift();
107
+ if (!sub) {
108
+ // `--help` with no subcommand is the family's help, not an error.
109
+ log(HELP);
110
+ if (!flags.help && !flags.h) process.exitCode = EXIT.OK;
111
+ return;
112
+ }
113
+ if (sub === "help") {
114
+ const target = flags._.shift();
115
+ return void log(HELPS[target] ?? HELP);
116
+ }
117
+ const handler = TABLE[sub];
118
+ if (!handler) {
119
+ fail(
120
+ `Unknown command 'app ${sub}'. Try: ${APP_SUBCOMMANDS.map((c) => c.name).join(" | ")}.`,
121
+ EXIT.USAGE,
122
+ );
123
+ }
124
+ await handler(flags);
125
+ }