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
package/src/app/link.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// `alexandr app link` — point THIS project at a workspace.
|
|
2
|
+
//
|
|
3
|
+
// Two lanes, deliberately different:
|
|
4
|
+
//
|
|
5
|
+
// alexandr app link the account lane. The consent ceremony
|
|
6
|
+
// with scope "app-dev" (a 30-day sliding
|
|
7
|
+
// `cli` session, so a laptop stays linked),
|
|
8
|
+
// then GET /me -> pick a workspace.
|
|
9
|
+
// alexandr app link --url http://… the local-trust lane. A box booted with
|
|
10
|
+
// ALEXANDR_LOCAL_TRUST=1 has no CP and no
|
|
11
|
+
// login: record the URL and stop. Nothing
|
|
12
|
+
// is minted because nothing is presented.
|
|
13
|
+
//
|
|
14
|
+
// The account session lands in ~/.alexandr/auth.json (one per machine); the
|
|
15
|
+
// workspace choice lands in <project>/.alexandr/link.json (one per project,
|
|
16
|
+
// gitignored). See ./store.js for why the two are separate files.
|
|
17
|
+
|
|
18
|
+
import { hostname } from "node:os";
|
|
19
|
+
import { bold, cyan, dim, fail, log, ok, step, warn } from "../util.js";
|
|
20
|
+
import { select } from "../prompt.js";
|
|
21
|
+
import { CP_URL, consentSession, getJson } from "../consent.js";
|
|
22
|
+
import { EXIT } from "../exit.js";
|
|
23
|
+
import { authUsable, clearToken, linkPath, readAuth, writeAuth, writeLink } from "./store.js";
|
|
24
|
+
|
|
25
|
+
export const APP_DEV_SCOPE = "app-dev";
|
|
26
|
+
|
|
27
|
+
export const LINK_HELP = `${bold("alexandr app link")} — point this project at a workspace
|
|
28
|
+
|
|
29
|
+
${bold("USAGE")}
|
|
30
|
+
alexandr app link [--workspace <id|slug>] [--url <runtime-url>]
|
|
31
|
+
|
|
32
|
+
${bold("OPTIONS")}
|
|
33
|
+
--workspace <id> Pick the workspace without the prompt (id or slug)
|
|
34
|
+
--url <url> Link to a local-trust box by URL; no account, no token
|
|
35
|
+
--relink Sign in again even if this machine already has a session
|
|
36
|
+
|
|
37
|
+
${dim("Writes ~/.alexandr/auth.json (your account session) and .alexandr/link.json (this project's workspace).")}`;
|
|
38
|
+
|
|
39
|
+
export async function appLink(flags, project = process.cwd()) {
|
|
40
|
+
if (flags.help || flags.h) return void log(LINK_HELP);
|
|
41
|
+
|
|
42
|
+
// ── the local-trust lane ────────────────────────────────────────────────────
|
|
43
|
+
if (typeof flags.url === "string" && flags.url.trim()) {
|
|
44
|
+
const instanceUrl = normalizeUrl(flags.url);
|
|
45
|
+
writeLink({ instanceUrl, localTrust: true, linkedAt: new Date().toISOString() }, project);
|
|
46
|
+
clearToken(project); // a stale token from an account link would only confuse
|
|
47
|
+
ok(`Linked to ${cyan(instanceUrl)} ${dim("(local trust — no account, no token)")}`);
|
|
48
|
+
log(dim(` ${linkPath(project)}`));
|
|
49
|
+
log("");
|
|
50
|
+
log(`${cyan("›")} Next: ${bold("alexandr app dev")}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── the account lane ────────────────────────────────────────────────────────
|
|
55
|
+
const session = await ensureAccountSession(Boolean(flags.relink));
|
|
56
|
+
|
|
57
|
+
const me = await getJson(`${CP_URL}/me`, { authorization: `Bearer ${session.token}` });
|
|
58
|
+
if (me.error) {
|
|
59
|
+
if (me.status === 401) {
|
|
60
|
+
fail("Your session is no longer valid. Run `alexandr app link --relink`.", EXIT.GENERAL);
|
|
61
|
+
}
|
|
62
|
+
fail(`Couldn't read your workspaces: ${me.error}`, EXIT.GENERAL);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const workspaces = (me.data?.workspaces ?? []).filter((w) => w?.instanceId);
|
|
66
|
+
if (workspaces.length === 0) {
|
|
67
|
+
fail(
|
|
68
|
+
"No workspace on your account has a runtime yet. Open the Alexandr app and create one, then run this again.",
|
|
69
|
+
EXIT.NO_INSTANCE,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const chosen = await pickWorkspace(workspaces, flags.workspace);
|
|
74
|
+
const link = {
|
|
75
|
+
cpUrl: CP_URL,
|
|
76
|
+
workspaceId: chosen.id,
|
|
77
|
+
workspaceName: chosen.name,
|
|
78
|
+
instanceId: chosen.instanceId,
|
|
79
|
+
instanceUrl: chosen.instance?.url ?? null,
|
|
80
|
+
linkedAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
writeLink(link, project);
|
|
83
|
+
clearToken(project);
|
|
84
|
+
|
|
85
|
+
ok(`Linked to ${bold(chosen.name)} ${dim(`(${chosen.id})`)}`);
|
|
86
|
+
if (!link.instanceUrl) {
|
|
87
|
+
warn("Its runtime isn't running yet — `alexandr app dev` will wait for it.");
|
|
88
|
+
} else {
|
|
89
|
+
log(dim(` ${link.instanceUrl}`));
|
|
90
|
+
}
|
|
91
|
+
log("");
|
|
92
|
+
log(`${cyan("›")} Next: ${bold("alexandr app dev")}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The account session this machine holds, signing in if it has none.
|
|
97
|
+
*
|
|
98
|
+
* ⚠ Reusing a stored session is the whole reason the `app-dev` scope exists: a
|
|
99
|
+
* 10-minute `cli-link` grant would mean a browser round trip every time a
|
|
100
|
+
* developer opened their laptop. `authUsable` checks the CP URL too — a session
|
|
101
|
+
* minted against a dev control plane is worthless against production.
|
|
102
|
+
*/
|
|
103
|
+
export async function ensureAccountSession(relink = false) {
|
|
104
|
+
const stored = readAuth();
|
|
105
|
+
if (!relink && authUsable(stored, CP_URL)) return stored;
|
|
106
|
+
|
|
107
|
+
step(`Sign in to your alexandr account ${dim(`(${CP_URL})`)}`);
|
|
108
|
+
let granted;
|
|
109
|
+
try {
|
|
110
|
+
// `name` is what the consent card shows under "Machine" — the box the
|
|
111
|
+
// developer is sitting at, so they can tell it apart from any other session
|
|
112
|
+
// on their account page later.
|
|
113
|
+
granted = await consentSession({
|
|
114
|
+
host: hostname(),
|
|
115
|
+
name: hostname(),
|
|
116
|
+
intent: "link",
|
|
117
|
+
scope: APP_DEV_SCOPE,
|
|
118
|
+
});
|
|
119
|
+
} catch (e) {
|
|
120
|
+
fail(`Sign-in aborted: ${e.message}`, EXIT.GENERAL);
|
|
121
|
+
}
|
|
122
|
+
if (granted.scope && granted.scope !== APP_DEV_SCOPE) {
|
|
123
|
+
// Fail-closed downgrade: the CP recorded a narrower grant than we asked for.
|
|
124
|
+
// Say so — a silent 10-minute session becomes a mysterious 401 an hour later.
|
|
125
|
+
warn(
|
|
126
|
+
`The control plane granted '${granted.scope}' rather than '${APP_DEV_SCOPE}' — this session is short-lived and you will be asked to sign in again.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const session = {
|
|
130
|
+
cpUrl: CP_URL,
|
|
131
|
+
token: granted.token,
|
|
132
|
+
expiresAt: granted.expiresAt ?? null,
|
|
133
|
+
client: granted.client ?? null,
|
|
134
|
+
scope: granted.scope ?? APP_DEV_SCOPE,
|
|
135
|
+
};
|
|
136
|
+
writeAuth(session);
|
|
137
|
+
ok("Signed in.");
|
|
138
|
+
return session;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** `--workspace <id|slug>` when given, the picker when a TTY, an error otherwise. */
|
|
142
|
+
async function pickWorkspace(workspaces, wanted) {
|
|
143
|
+
if (typeof wanted === "string" && wanted.trim()) {
|
|
144
|
+
const needle = wanted.trim().toLowerCase();
|
|
145
|
+
const found = workspaces.find(
|
|
146
|
+
(w) => w.id.toLowerCase() === needle || (w.slug ?? "").toLowerCase() === needle,
|
|
147
|
+
);
|
|
148
|
+
if (!found) {
|
|
149
|
+
fail(
|
|
150
|
+
`No workspace '${wanted}' with a runtime on your account. Available: ${workspaces
|
|
151
|
+
.map((w) => w.slug || w.id)
|
|
152
|
+
.join(", ")}.`,
|
|
153
|
+
EXIT.USAGE,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return found;
|
|
157
|
+
}
|
|
158
|
+
if (workspaces.length === 1) return workspaces[0];
|
|
159
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
160
|
+
fail(
|
|
161
|
+
`Several workspaces — name one with --workspace <id|slug>: ${workspaces
|
|
162
|
+
.map((w) => w.slug || w.id)
|
|
163
|
+
.join(", ")}.`,
|
|
164
|
+
EXIT.USAGE,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return select(
|
|
168
|
+
"Which workspace does this app develop against?",
|
|
169
|
+
workspaces.map((w, i) => ({
|
|
170
|
+
label: `${i + 1}. ${w.name}`,
|
|
171
|
+
hint: w.instance?.url || w.slug || w.id,
|
|
172
|
+
value: w,
|
|
173
|
+
})),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A runtime URL the CLI will actually talk to: origin + no trailing slash. */
|
|
178
|
+
export function normalizeUrl(raw) {
|
|
179
|
+
const trimmed = String(raw).trim();
|
|
180
|
+
const withScheme = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
|
|
181
|
+
try {
|
|
182
|
+
const u = new URL(withScheme);
|
|
183
|
+
return `${u.protocol}//${u.host}`;
|
|
184
|
+
} catch {
|
|
185
|
+
return fail(`'${raw}' is not a URL.`, EXIT.USAGE);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A `multipart/form-data` body, built by hand.
|
|
2
|
+
//
|
|
3
|
+
// ⚠ Hand-rolled rather than `FormData` + `File` on purpose. This CLI declares
|
|
4
|
+
// Node >= 18, where `File` is only in `node:buffer` (it became a global in 20)
|
|
5
|
+
// and `FormData`'s multipart serializer is not something a test can inspect. The
|
|
6
|
+
// sideload route reads exactly two parts — a `package` file and a `sha256`
|
|
7
|
+
// field — so the body is a dozen lines, and a dozen lines that a unit test can
|
|
8
|
+
// read byte for byte beat a global that behaves differently across the versions
|
|
9
|
+
// we support.
|
|
10
|
+
//
|
|
11
|
+
// Zero dependencies, like everything else here.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const CRLF = "\r\n";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build the body for `POST /_kernel/apps/sideload`.
|
|
19
|
+
*
|
|
20
|
+
* `parts` is an ordered list of `{ name, value }` (a text field) or
|
|
21
|
+
* `{ name, filename, data, contentType }` (a file part, `data` a Buffer).
|
|
22
|
+
* Returns `{ body, contentType, boundary }` — hand `body` straight to `fetch`.
|
|
23
|
+
*/
|
|
24
|
+
export function buildMultipart(parts, boundary = `alexandrFormBoundary${randomBytes(16).toString("hex")}`) {
|
|
25
|
+
const chunks = [];
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
if (!part?.name) throw new Error("every multipart part needs a name");
|
|
28
|
+
const headers = [`--${boundary}`];
|
|
29
|
+
if (part.filename !== undefined) {
|
|
30
|
+
headers.push(
|
|
31
|
+
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"`,
|
|
32
|
+
`Content-Type: ${part.contentType || "application/octet-stream"}`,
|
|
33
|
+
);
|
|
34
|
+
} else {
|
|
35
|
+
headers.push(`Content-Disposition: form-data; name="${part.name}"`);
|
|
36
|
+
}
|
|
37
|
+
chunks.push(Buffer.from(headers.join(CRLF) + CRLF + CRLF, "utf8"));
|
|
38
|
+
chunks.push(part.filename !== undefined ? toBuffer(part.data) : Buffer.from(String(part.value), "utf8"));
|
|
39
|
+
chunks.push(Buffer.from(CRLF, "utf8"));
|
|
40
|
+
}
|
|
41
|
+
chunks.push(Buffer.from(`--${boundary}--${CRLF}`, "utf8"));
|
|
42
|
+
return {
|
|
43
|
+
body: Buffer.concat(chunks),
|
|
44
|
+
contentType: `multipart/form-data; boundary=${boundary}`,
|
|
45
|
+
boundary,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toBuffer(data) {
|
|
50
|
+
if (Buffer.isBuffer(data)) return data;
|
|
51
|
+
if (data instanceof Uint8Array) return Buffer.from(data);
|
|
52
|
+
return Buffer.from(String(data ?? ""), "utf8");
|
|
53
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
// `alexandr app publish` — put THIS version of the app in the catalog.
|
|
2
|
+
//
|
|
3
|
+
// The laptop half of the release story (app-system-stage-3.md WP-P, Part 7's
|
|
4
|
+
// two-doors rule). `deploy` puts a build in ONE workspace; `publish` puts a
|
|
5
|
+
// signed, immutable release where every workspace can install it. Six steps:
|
|
6
|
+
//
|
|
7
|
+
// 1. sign in the account session `app link` already earned
|
|
8
|
+
// 2. read manifest.json — the id and the version ARE the release
|
|
9
|
+
// 3. key mint this machine's publisher key once, register the
|
|
10
|
+
// PUBLIC half with the control plane
|
|
11
|
+
// 4. build + pack the same builder and the same packer `deploy` runs
|
|
12
|
+
// 5. sign ed25519 over `id\nversion\nsha256` (see ./signing.js)
|
|
13
|
+
// 6. POST /apps/releases as multipart, and print the entry
|
|
14
|
+
//
|
|
15
|
+
// ⚠ A VERSION IS IMMUTABLE. There is no overwrite and no `--force`: the CP
|
|
16
|
+
// answers 409 for a version it already holds, because republishing a number
|
|
17
|
+
// would silently change what every box that already installed it believes it
|
|
18
|
+
// has. The fix is always a version bump, and the message says so.
|
|
19
|
+
//
|
|
20
|
+
// ⚠⚠ THE PRIVATE KEY IS NOT PART OF THIS REQUEST. It is not a field, not an
|
|
21
|
+
// argument, not a log line — only the signature it produced travels. See the
|
|
22
|
+
// header of ./signing.js, and the test that reads the built body back looking
|
|
23
|
+
// for it (`test/app-publish.test.js`).
|
|
24
|
+
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
import { readFile, rm } from "node:fs/promises";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { resolve } from "node:path";
|
|
29
|
+
import { bold, cyan, dim, fail, log, ok, step, warn } from "../util.js";
|
|
30
|
+
import { CP_URL, postJson } from "../consent.js";
|
|
31
|
+
import { EXIT } from "../exit.js";
|
|
32
|
+
import { appDirOf, buildApp, loadBuilder } from "./build.js";
|
|
33
|
+
import { formatBytes, postMultipart } from "./deploy.js";
|
|
34
|
+
import { ensureAccountSession } from "./link.js";
|
|
35
|
+
import { buildMultipart } from "./multipart.js";
|
|
36
|
+
import { APP_ID_RE } from "./reach.js";
|
|
37
|
+
import { authUsable, readAuth } from "./store.js";
|
|
38
|
+
import { loadOrMintPublisherKey, markRegistered, mintEphemeralPublisherKey, publisherKeyFromEnv, signRelease } from "./signing.js";
|
|
39
|
+
|
|
40
|
+
export const PUBLISH_HELP = `${bold("alexandr app publish")} — publish this version to the catalog
|
|
41
|
+
|
|
42
|
+
${bold("USAGE")}
|
|
43
|
+
alexandr app publish [--visibility public|private] [--changelog <file|text>]
|
|
44
|
+
[--license <spdx>] [--dir <path>]
|
|
45
|
+
|
|
46
|
+
${bold("OPTIONS")}
|
|
47
|
+
--visibility <v> private (only workspaces you entitled — the default) or public (anyone may
|
|
48
|
+
install). Public listing needs a publisher grant from an admin; private
|
|
49
|
+
publishing is self-serve and always has been
|
|
50
|
+
--changelog <x> A file to read, or the notes themselves
|
|
51
|
+
--license <spdx> e.g. Apache-2.0 — shown on the app page
|
|
52
|
+
--dir <path> The app folder, when it isn't the current one
|
|
53
|
+
--non-interactive Never open a sign-in: publish with what is already here, or fail at once (CI sets this)
|
|
54
|
+
|
|
55
|
+
${bold("IN CI")} ${dim("(unattended — no account, no browser)")}
|
|
56
|
+
ALEXANDR_INSTANCE_ID + ALEXANDR_RUNTIME_SECRET the workspace's own credential — the release is
|
|
57
|
+
published AS THAT WORKSPACE (publisher: workspace:<id>)
|
|
58
|
+
ALEXANDR_PUBLISHER_KEY an ed25519 private key PEM (openssl genpkey -algorithm ed25519);
|
|
59
|
+
without it every run mints and registers a fresh key
|
|
60
|
+
ALEXANDR_CP_URL the control plane, when it is not the public one
|
|
61
|
+
CI=1 implies --non-interactive.
|
|
62
|
+
|
|
63
|
+
${dim("Builds, packages, signs with this machine's publisher key (~/.alexandr/publisher.json, minted on first use) and uploads to the control plane. The version in manifest.json is the release, and a published version is never overwritten — bump it to publish again.")}`;
|
|
64
|
+
|
|
65
|
+
/** semver.org's grammar, loosely: core + optional prerelease + optional build.
|
|
66
|
+
* ⚠ Deliberately a touch looser than the CP's `isSemver` — this check exists to
|
|
67
|
+
* say "bump `version`" BEFORE a two-minute build, not to be the authority. The
|
|
68
|
+
* control plane is the authority and refuses anything this lets through. */
|
|
69
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
70
|
+
|
|
71
|
+
const VISIBILITIES = ["public", "private"];
|
|
72
|
+
|
|
73
|
+
export async function appPublish(flags, project) {
|
|
74
|
+
// ⚠ `--help` is answered BEFORE the folder is resolved, which is why `project`
|
|
75
|
+
// has no `= appDirOf(flags)` default: a default parameter is evaluated before
|
|
76
|
+
// the body runs, and `appDirOf` EXITS when there is no manifest.json — so
|
|
77
|
+
// `alexandr app publish --help` outside an app folder would print nothing but
|
|
78
|
+
// a refusal. The parameter stays injectable for tests.
|
|
79
|
+
if (flags.help || flags.h) return void log(PUBLISH_HELP);
|
|
80
|
+
const dir = project ?? appDirOf(flags);
|
|
81
|
+
|
|
82
|
+
const visibility = readVisibility(flags.visibility);
|
|
83
|
+
const changelog = readChangelog(flags.changelog);
|
|
84
|
+
const license = typeof flags.license === "string" && flags.license.trim() ? flags.license.trim() : null;
|
|
85
|
+
|
|
86
|
+
// ── 1. who publishes ────────────────────────────────────────────────────────
|
|
87
|
+
// A PERSON (the account session — the release belongs to whoever signed it) or,
|
|
88
|
+
// unattended, a BOX: with `ALEXANDR_INSTANCE_ID` + `ALEXANDR_RUNTIME_SECRET` the
|
|
89
|
+
// release is published AS THAT WORKSPACE through the envelope the control plane
|
|
90
|
+
// already accepts (app-system-stage-4.md D9). Non-interactive mode never opens
|
|
91
|
+
// a sign-in — the alternative on a headless runner was a five-minute device-code
|
|
92
|
+
// poll nobody would ever answer.
|
|
93
|
+
const publisher = await resolvePublisher(flags, process.env);
|
|
94
|
+
const cpUrl = publisher.cpUrl;
|
|
95
|
+
|
|
96
|
+
// ── 2. the manifest IS the release ──────────────────────────────────────────
|
|
97
|
+
const manifestRaw = readManifest(dir);
|
|
98
|
+
const { id, version } = identityOf(manifestRaw, dir);
|
|
99
|
+
|
|
100
|
+
// ── 3. the publisher key ────────────────────────────────────────────────────
|
|
101
|
+
let record;
|
|
102
|
+
if (publisher.kind === "instance") {
|
|
103
|
+
// CI: the key from the environment, or one for this run alone.
|
|
104
|
+
record = publisherKeyFromEnv(process.env);
|
|
105
|
+
if (!record) {
|
|
106
|
+
record = mintEphemeralPublisherKey();
|
|
107
|
+
warn(
|
|
108
|
+
`No ALEXANDR_PUBLISHER_KEY — minted a key for this run (${record.keyId}); the workspace keeps ` +
|
|
109
|
+
"it registered so the release stays verifiable. Set the variable to make every run sign with one key.",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
await registerPublisherKey(record, publisher, cpUrl);
|
|
113
|
+
} else {
|
|
114
|
+
let minted;
|
|
115
|
+
({ record, minted } = loadOrMintPublisherKey());
|
|
116
|
+
if (minted) step(`Minted this machine's publisher key ${dim(record.keyId)}`);
|
|
117
|
+
if (minted || !record.registrations?.[cpUrl]) {
|
|
118
|
+
await registerPublisherKey(record, publisher, cpUrl);
|
|
119
|
+
record = markRegistered(undefined, record, cpUrl);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── 4. build, then pack ─────────────────────────────────────────────────────
|
|
124
|
+
const built = await buildApp(dir, { quiet: false });
|
|
125
|
+
if (!built.ok) fail(built.error, EXIT.GENERAL);
|
|
126
|
+
|
|
127
|
+
const { pack } = await loadBuilder(dir);
|
|
128
|
+
step("Packaging…");
|
|
129
|
+
const packed = await pack(dir, {
|
|
130
|
+
outFile: resolve(tmpdir(), `alexandr-publish-${process.pid}-${Date.now()}.tar.gz`),
|
|
131
|
+
});
|
|
132
|
+
for (const w of packed.warnings ?? []) warn(w);
|
|
133
|
+
if (!packed.ok) fail(packed.error ?? "packaging failed", EXIT.GENERAL);
|
|
134
|
+
log(dim(` ${packed.files.length} files, ${formatBytes(packed.bytes)}, sha256 ${packed.sha256.slice(0, 12)}…`));
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
// ── 5. sign ───────────────────────────────────────────────────────────────
|
|
138
|
+
const signature = signRelease(record.privateKey, id, version, packed.sha256);
|
|
139
|
+
|
|
140
|
+
// ── 6. upload ─────────────────────────────────────────────────────────────
|
|
141
|
+
const archive = await readFile(packed.outFile);
|
|
142
|
+
const { body, contentType } = buildMultipart(
|
|
143
|
+
publishParts({
|
|
144
|
+
manifestRaw,
|
|
145
|
+
archive,
|
|
146
|
+
id,
|
|
147
|
+
version,
|
|
148
|
+
signature,
|
|
149
|
+
keyId: record.keyId,
|
|
150
|
+
visibility,
|
|
151
|
+
changelog,
|
|
152
|
+
license,
|
|
153
|
+
// The BOX's envelope rides the body; a person's session rides the header.
|
|
154
|
+
...(publisher.kind === "instance" ? { instanceId: publisher.instanceId, secret: publisher.secret } : {}),
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
step(
|
|
158
|
+
`Publishing ${bold(`${id} ${version}`)} to ${cyan(cpUrl)}${
|
|
159
|
+
publisher.kind === "instance" ? dim(" as the workspace") : ""
|
|
160
|
+
}…`,
|
|
161
|
+
);
|
|
162
|
+
const res = await postMultipart(
|
|
163
|
+
`${cpUrl}/apps/releases`,
|
|
164
|
+
body,
|
|
165
|
+
contentType,
|
|
166
|
+
publisher.kind === "session" ? `Bearer ${publisher.token}` : undefined,
|
|
167
|
+
);
|
|
168
|
+
if (!res.ok) fail(publishFailure(res, id, version), EXIT.GENERAL);
|
|
169
|
+
|
|
170
|
+
const entry = res.data ?? {};
|
|
171
|
+
ok(
|
|
172
|
+
`Published ${bold(`${entry.id ?? id} ${entry.version ?? version}`)} ${dim(
|
|
173
|
+
`(${entry.visibility ?? visibility})`,
|
|
174
|
+
)}`,
|
|
175
|
+
);
|
|
176
|
+
if (entry.sha256) log(dim(` sha256 ${entry.sha256}`));
|
|
177
|
+
if (entry.releasedAt) log(dim(` released ${entry.releasedAt}`));
|
|
178
|
+
log("");
|
|
179
|
+
if ((entry.visibility ?? visibility) === "private") {
|
|
180
|
+
// A private release is LISTED for everyone and downloadable by nobody until
|
|
181
|
+
// a workspace is entitled — so the next step is not optional, it is the
|
|
182
|
+
// difference between a release and an installable one.
|
|
183
|
+
log(`${cyan("›")} Let a workspace install it: ${bold(`alexandr app entitle <workspaceId>`)}`);
|
|
184
|
+
} else {
|
|
185
|
+
log(`${cyan("›")} It reaches the catalog on the registry's next merge.`);
|
|
186
|
+
log(`${cyan("›")} Move a workspace onto it: ${bold("alexandr app update")}`);
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
// ⚠ The tarball is a build artifact in the system temp dir, and it outlives
|
|
190
|
+
// the command unless someone removes it. `finally`, so a refused publish
|
|
191
|
+
// cleans up exactly like a good one.
|
|
192
|
+
await rm(packed.outFile, { force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The multipart parts, in the order the CP's route reads them.
|
|
198
|
+
*
|
|
199
|
+
* Split out so a test can read the body back byte for byte: this is the one
|
|
200
|
+
* place a private key could plausibly be added by accident, and the assertion
|
|
201
|
+
* that it never appears has to have something to assert against.
|
|
202
|
+
*/
|
|
203
|
+
export function publishParts({
|
|
204
|
+
manifestRaw,
|
|
205
|
+
archive,
|
|
206
|
+
id,
|
|
207
|
+
version,
|
|
208
|
+
signature,
|
|
209
|
+
keyId,
|
|
210
|
+
visibility,
|
|
211
|
+
changelog,
|
|
212
|
+
license,
|
|
213
|
+
instanceId,
|
|
214
|
+
secret,
|
|
215
|
+
}) {
|
|
216
|
+
return [
|
|
217
|
+
// The RAW manifest text, not a re-serialization: the CP parses this to take
|
|
218
|
+
// the id, the version and the apiVersion, and stores it whole. Re-encoding it
|
|
219
|
+
// here would mean the record differs from the file the developer wrote.
|
|
220
|
+
{ name: "manifest", value: manifestRaw },
|
|
221
|
+
{ name: "package", filename: `${id}-${version}.tar.gz`, data: archive, contentType: "application/gzip" },
|
|
222
|
+
{ name: "signature", value: signature },
|
|
223
|
+
{ name: "keyId", value: keyId },
|
|
224
|
+
{ name: "visibility", value: visibility },
|
|
225
|
+
// Absent, not empty: the CP reads a blank field as unset anyway, but sending
|
|
226
|
+
// one would claim the developer wrote empty release notes.
|
|
227
|
+
...(changelog ? [{ name: "changelog", value: changelog }] : []),
|
|
228
|
+
...(license ? [{ name: "license", value: license }] : []),
|
|
229
|
+
// The workspace's credential, ONLY when a box publishes (CI). The control
|
|
230
|
+
// plane resolves the pair to `workspace:<id>` and ignores any bearer.
|
|
231
|
+
...(instanceId && secret
|
|
232
|
+
? [
|
|
233
|
+
{ name: "instanceId", value: instanceId },
|
|
234
|
+
{ name: "secret", value: secret },
|
|
235
|
+
]
|
|
236
|
+
: []),
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Who this command publishes as.
|
|
242
|
+
*
|
|
243
|
+
* { kind: "instance", instanceId, secret, cpUrl } — a BOX (CI), from the environment
|
|
244
|
+
* { kind: "session", token, cpUrl } — a PERSON, from the stored account session
|
|
245
|
+
*
|
|
246
|
+
* Non-interactive (`--non-interactive`, or `CI` set to anything but 0/false): no
|
|
247
|
+
* ceremony is ever started. A usable stored session still counts — a developer's
|
|
248
|
+
* own script is not a stranger — but nothing else does, and the failure names
|
|
249
|
+
* exactly the two variables a runner needs.
|
|
250
|
+
*/
|
|
251
|
+
export async function resolvePublisher(flags, env = process.env, deps = {}) {
|
|
252
|
+
const machine = machineCredential(env);
|
|
253
|
+
if (machine) return machine;
|
|
254
|
+
const stored = (deps.readAuth ?? readAuth)();
|
|
255
|
+
if ((deps.authUsable ?? authUsable)(stored, CP_URL)) {
|
|
256
|
+
return { kind: "session", token: stored.token, cpUrl: stored.cpUrl || CP_URL };
|
|
257
|
+
}
|
|
258
|
+
if (isNonInteractive(flags, env)) {
|
|
259
|
+
return (deps.fail ?? fail)(
|
|
260
|
+
"Nothing to publish as: no account session here and no workspace credential. In CI set " +
|
|
261
|
+
"ALEXANDR_INSTANCE_ID and ALEXANDR_RUNTIME_SECRET (the workspace's own credential — the " +
|
|
262
|
+
"release is published as that workspace), or run `alexandr app link` on a machine with a browser.",
|
|
263
|
+
EXIT.USAGE,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
const session = await (deps.ensureAccountSession ?? ensureAccountSession)();
|
|
267
|
+
return { kind: "session", token: session.token, cpUrl: session.cpUrl || CP_URL };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The workspace credential a CI job holds, or null. Both halves or nothing. */
|
|
271
|
+
export function machineCredential(env = process.env) {
|
|
272
|
+
const instanceId = typeof env.ALEXANDR_INSTANCE_ID === "string" ? env.ALEXANDR_INSTANCE_ID.trim() : "";
|
|
273
|
+
const secret = typeof env.ALEXANDR_RUNTIME_SECRET === "string" ? env.ALEXANDR_RUNTIME_SECRET.trim() : "";
|
|
274
|
+
if (!instanceId || !secret) return null;
|
|
275
|
+
const cpUrl = (typeof env.ALEXANDR_CP_URL === "string" && env.ALEXANDR_CP_URL.trim()) || CP_URL;
|
|
276
|
+
return { kind: "instance", instanceId, secret, cpUrl: cpUrl.replace(/\/+$/, "") };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** `--non-interactive`, or the `CI` convention every runner sets. */
|
|
280
|
+
export function isNonInteractive(flags, env = process.env) {
|
|
281
|
+
if (flags?.["non-interactive"] === true) return true;
|
|
282
|
+
const ci = typeof env.CI === "string" ? env.CI.trim().toLowerCase() : "";
|
|
283
|
+
return ci !== "" && ci !== "0" && ci !== "false";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** The raw manifest text — the release's own record of itself. */
|
|
287
|
+
function readManifest(project) {
|
|
288
|
+
try {
|
|
289
|
+
return readFileSync(resolve(project, "manifest.json"), "utf8");
|
|
290
|
+
} catch (e) {
|
|
291
|
+
return fail(`Couldn't read ${resolve(project, "manifest.json")}: ${e.message}`, EXIT.NO_INSTANCE);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* The `id` + `version` this folder publishes as, refused here rather than after
|
|
297
|
+
* a build.
|
|
298
|
+
*
|
|
299
|
+
* ⚠ The version gets its OWN message. "manifest.json is invalid" is useless to
|
|
300
|
+
* someone whose real problem is that they already published 1.0.0 — the one
|
|
301
|
+
* thing they have to do is bump a number, so the error says that and nothing
|
|
302
|
+
* else.
|
|
303
|
+
*/
|
|
304
|
+
export function identityOf(manifestRaw, project = ".") {
|
|
305
|
+
let manifest;
|
|
306
|
+
try {
|
|
307
|
+
manifest = JSON.parse(manifestRaw);
|
|
308
|
+
} catch (e) {
|
|
309
|
+
return fail(`${resolve(project, "manifest.json")} is not valid JSON: ${e.message}`, EXIT.USAGE);
|
|
310
|
+
}
|
|
311
|
+
const id = typeof manifest?.id === "string" ? manifest.id.trim() : "";
|
|
312
|
+
const version = typeof manifest?.version === "string" ? manifest.version.trim() : "";
|
|
313
|
+
if (!APP_ID_RE.test(id)) {
|
|
314
|
+
fail(`manifest.json's id ${id ? `('${id}') ` : ""}must be lowercase letters, digits and dashes.`, EXIT.USAGE);
|
|
315
|
+
}
|
|
316
|
+
if (!SEMVER_RE.test(version)) {
|
|
317
|
+
fail(`'${version}' is not a version — bump \`version\` in manifest.json (e.g. 1.0.0).`, EXIT.USAGE);
|
|
318
|
+
}
|
|
319
|
+
// Build metadata is legal semver and NOT installable (a version is a directory name in a
|
|
320
|
+
// box's release pool). The control plane refuses it too; saying so here saves the build.
|
|
321
|
+
if (version.includes("+")) {
|
|
322
|
+
const core = version.split("+")[0];
|
|
323
|
+
const instead = core.includes("-") ? core : `${core}-rc.1`;
|
|
324
|
+
fail(
|
|
325
|
+
`'${version}' carries build metadata (+…), which no workspace can install — use '${instead}' instead (a prerelease tag carries what build metadata would).`,
|
|
326
|
+
EXIT.USAGE,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
return { id, version };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* `private` unless `public` was asked for; anything else is a typo, not a third policy.
|
|
334
|
+
*
|
|
335
|
+
* ⚠ THE DEFAULT CHANGED on 2026-09-06 (app-system-stage-5.md WP-X, D18): it was `public`.
|
|
336
|
+
* A publish that says nothing about who may take the app is a publish nobody thought about,
|
|
337
|
+
* and "listed for everyone, downloadable by the workspaces you entitled" is the answer that
|
|
338
|
+
* cannot surprise anybody. The control plane defaults the same way, so an old CLI against a
|
|
339
|
+
* new control plane and a new CLI against either both land on private.
|
|
340
|
+
*/
|
|
341
|
+
export function readVisibility(raw) {
|
|
342
|
+
if (raw === undefined || raw === true || raw === "") return "private";
|
|
343
|
+
const value = String(raw).trim().toLowerCase();
|
|
344
|
+
if (!VISIBILITIES.includes(value)) {
|
|
345
|
+
fail(`--visibility must be ${VISIBILITIES.join(" or ")} (got '${raw}').`, EXIT.USAGE);
|
|
346
|
+
}
|
|
347
|
+
return value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* `--changelog` is a path OR the notes themselves.
|
|
352
|
+
*
|
|
353
|
+
* ⚠ A path that does not exist is TEXT, not an error. Release notes are short
|
|
354
|
+
* prose, and prose that happens to look like a filename ("v2.md rewrite") must
|
|
355
|
+
* not become a refusal — so the file is tried, and its absence simply means the
|
|
356
|
+
* developer typed the notes.
|
|
357
|
+
*/
|
|
358
|
+
export function readChangelog(raw, readText = (f) => readFileSync(f, "utf8")) {
|
|
359
|
+
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
360
|
+
try {
|
|
361
|
+
return readText(resolve(raw)).trim() || null;
|
|
362
|
+
} catch {
|
|
363
|
+
return raw.trim();
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Register the PUBLIC half with the control plane.
|
|
369
|
+
*
|
|
370
|
+
* ⚠ A 409 IS SUCCESS. The key id is derived from the key bytes, so "already
|
|
371
|
+
* registered" can only mean the CP holds exactly this key — the same reasoning
|
|
372
|
+
* that lets the box re-register on every boot (`os/kernel/src/apps/publisher-key.ts`).
|
|
373
|
+
* Treating it as a failure would strand any laptop whose local record of the
|
|
374
|
+
* registration was lost while the CP's was not.
|
|
375
|
+
*/
|
|
376
|
+
async function registerPublisherKey(record, publisher, cpUrl) {
|
|
377
|
+
step(publisher.kind === "instance" ? "Registering the signing key as the workspace…" : "Registering this machine's signing key…");
|
|
378
|
+
const res = await postJson(
|
|
379
|
+
`${cpUrl}/publishers/keys`,
|
|
380
|
+
// The PUBLIC half only. There is no branch of this function that sends the
|
|
381
|
+
// private one, and there must never be. A box's envelope rides the body.
|
|
382
|
+
{
|
|
383
|
+
keyId: record.keyId,
|
|
384
|
+
publicKey: record.publicKey,
|
|
385
|
+
...(publisher.kind === "instance" ? { instanceId: publisher.instanceId, secret: publisher.secret } : {}),
|
|
386
|
+
},
|
|
387
|
+
publisher.kind === "session" ? { authorization: `Bearer ${publisher.token}` } : {},
|
|
388
|
+
);
|
|
389
|
+
if (res.error && statusOf(res.error) !== 409) {
|
|
390
|
+
fail(`Couldn't register your signing key: ${res.error}`, EXIT.GENERAL);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* The HTTP status inside one of `postJson`'s error strings.
|
|
396
|
+
*
|
|
397
|
+
* ⚠ `postJson` folds the status into the message (`HTTP 409 — …`) and returns no
|
|
398
|
+
* `status` field, unlike `getJson` and `postMultipart`. Reading it back out here
|
|
399
|
+
* is deliberately local: consent.js is the shared ceremony both `link` verbs
|
|
400
|
+
* run, and widening its return shape for one caller is a change to the
|
|
401
|
+
* security-critical half of the CLI for a cosmetic reason.
|
|
402
|
+
*/
|
|
403
|
+
export function statusOf(error) {
|
|
404
|
+
const m = /^HTTP (\d{3})\b/.exec(String(error ?? ""));
|
|
405
|
+
return m ? Number(m[1]) : 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** What went wrong, said as the thing to do about it. */
|
|
409
|
+
export function publishFailure(res, id, version) {
|
|
410
|
+
if (res.status === 409) {
|
|
411
|
+
return `${id} ${version} is already published — bump \`version\` in manifest.json and publish again.`;
|
|
412
|
+
}
|
|
413
|
+
if (res.status === 403) {
|
|
414
|
+
// The CP refuses a key it does not hold for this account, and a re-run
|
|
415
|
+
// re-registers it: the local record of the registration is the only thing
|
|
416
|
+
// that can be wrong here, and publishing again is what repairs it.
|
|
417
|
+
return `${res.error} — run \`alexandr app publish\` again to re-register your signing key.`;
|
|
418
|
+
}
|
|
419
|
+
if (res.status === 503) return `${res.error} — the control plane cannot store releases right now.`;
|
|
420
|
+
return `Publish failed: ${res.error}`;
|
|
421
|
+
}
|