alexandr 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -16,6 +16,18 @@ npm install -g alexandr
16
16
  alexandr up
17
17
  ```
18
18
 
19
+ > **Prerequisite: Node 18+** (`npx` ships with it). On a fresh server without it, the
20
+ > bootstrap does everything — installs Node LTS, then runs the command above (append
21
+ > flags after `sh -s --`):
22
+ >
23
+ > ```sh
24
+ > curl -fsSL https://get.alexandr.so | sh
25
+ > ```
26
+ >
27
+ > The script is short and readable — its SHA-256 is published at
28
+ > `https://get.alexandr.so/sha256` if you want to verify before running. Everything
29
+ > else — Docker included — the installer handles itself.
30
+
19
31
  On a fresh machine `up` is interactive — it asks how the runtime should be
20
32
  reachable (just this machine, or a public domain with automatic HTTPS), the
21
33
  port or domain, and the workspace name. Flags (`--domain`, `--port`, `--name`),
@@ -37,7 +49,9 @@ when Docker or Compose v2 is missing it offers to install them right there
37
49
  always asked first). macOS/Windows get instructions instead. The runtime image
38
50
  itself is a private package — it pulls with a credential the control plane mints
39
51
  for your signed-in account, so the download is gated by the same entitlement as
40
- registration.
52
+ registration. If the registry ever rejects a pull (say, after a credential
53
+ rotation), `up` and `update` offer a quick re-sign-in on the spot — or run
54
+ `alexandr login` any time.
41
55
 
42
56
  ## Commands
43
57
 
@@ -50,6 +64,7 @@ registration.
50
64
  | `alexandr logs -f` | Tail kernel logs |
51
65
  | `alexandr connect` | Print the `alexandr://connect` link / paste-string |
52
66
  | `alexandr link` | Re-link this runtime to your account (`--force` re-registers) |
67
+ | `alexandr login` | Refresh the runtime-image pull credential (sign in, no re-register) |
53
68
  | `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip) |
54
69
  | `alexandr backup` / `restore <f>` | Archive / restore the data volume |
55
70
  | `alexandr config set <k> <v>` | Edit config (`ai.url`, `model`, `port`, `domain`, …) |
@@ -57,9 +72,69 @@ registration.
57
72
  | `alexandr destroy [--volumes] [--unlink]` | Remove containers (and optionally `/data` / the account record) |
58
73
  | `alexandr completion <bash\|zsh\|fish>` | Print a shell completion script |
59
74
  | `alexandr doctor` | Check Docker, ports, config |
75
+ | `alexandr app <cmd>` | Develop an app on this machine — see below |
60
76
 
61
77
  Common flags: `--port`, `--domain`, `--name`, `--dir`, `--open`, `--offline`, `--yes`.
62
78
 
79
+ ## Building an app
80
+
81
+ The laptop half of the alexandr app system. Everything the assistant can do inside a
82
+ workspace, you can do from a terminal, against the same engine.
83
+
84
+ ```sh
85
+ npm create alexandr-app@latest my-app # page | full-stack | tool
86
+ cd my-app
87
+ alexandr app link # sign in, pick the workspace — once
88
+ alexandr app dev # Vite, proxied to it — while you work
89
+ alexandr app deploy # build, package, install — when it is ready
90
+ alexandr app publish # sign it, release it — when you want to share it
91
+ ```
92
+
93
+ | Command | What it does |
94
+ |---|---|
95
+ | `alexandr app link` | Sign in and point this project at a workspace. `--workspace <id>` skips the picker; `--url <url>` links a local-trust box with no account |
96
+ | `alexandr app dev` | Runs the project's Vite with `/_kernel` and `/apps/<id>/api` proxied to the workspace, keeping a short-lived workspace token fresh for the proxy |
97
+ | `alexandr app build` | Runs the project's own `@alexandr/app-build` — the exact builder the workspace runs — into `dist/` |
98
+ | `alexandr app deploy` | Build, package the whole folder, checksum it, and install it in the linked workspace |
99
+ | `alexandr app publish` | Build, package, sign, and publish this version as a release. **Private by default** — listed for everyone, installable only by workspaces you entitled (`alexandr app entitle`); `--visibility public` needs a publisher grant from an admin. Also `--changelog`, `--license` |
100
+ | `alexandr app update` | Move the linked workspace onto the newest release of this app (`--app <id>` for another one) |
101
+ | `alexandr app rollback` | Put it back on the previous release. Your data keeps the newer layout — only additive changes are safe across a rollback |
102
+ | `alexandr app entitle` | Let one workspace install this private app; `--remove` takes it away |
103
+
104
+ `link` writes your account session to `~/.alexandr/auth.json` (owner-only) and this
105
+ project's workspace to `.alexandr/link.json` (gitignored). The workspace token lives in
106
+ `.alexandr/token.json`, is re-minted while `dev` runs, and is attached **by the proxy** —
107
+ it never reaches the browser.
108
+
109
+ `deploy` needs the `os.apps.create` permission in that workspace; `update` and `rollback`
110
+ need `os.apps.manage`.
111
+
112
+ `publish` mints one ed25519 signing key per machine on first use
113
+ (`~/.alexandr/publisher.json`, owner-only) and registers its **public** half with the
114
+ control plane. The private half never leaves that file — not into an argument, a log line,
115
+ or the upload — so a control plane can refuse a release but never forge one. A published
116
+ version is immutable: to publish again, bump `version` in `manifest.json`.
117
+
118
+ ### Publishing from CI
119
+
120
+ A runner has no account and no browser, so it publishes **as the workspace** instead. A
121
+ scaffolded project (and every repo alexandr creates) carries `.github/workflows/publish.yml`:
122
+ push a tag `v<version>` — the same version as `manifest.json` — and the job builds, packages,
123
+ signs and publishes the release. Every workspace that installed the app is offered the update.
124
+
125
+ Repository secrets it reads:
126
+
127
+ | Secret | What |
128
+ |---|---|
129
+ | `ALEXANDR_INSTANCE_ID` + `ALEXANDR_RUNTIME_SECRET` | the workspace's own credential — both are in a self-hosted box's `.env` (written by `npx alexandr link`) |
130
+ | `ALEXANDR_PUBLISHER_KEY` | an ed25519 private key PEM, made once with `openssl genpkey -algorithm ed25519`; without it every run mints and registers a fresh key |
131
+ | `ALEXANDR_CP_URL` | optional — the control plane, when it is not the public one |
132
+
133
+ By hand: `CI=1 alexandr app publish` (or `--non-interactive`) with those variables set. With
134
+ nothing to publish as, it fails immediately and names the two variables — it never waits for a
135
+ sign-in that cannot happen. The runtime never pulls from git: a release is published, and the
136
+ workspace's own update path installs it.
137
+
63
138
  ## Updates are reversible
64
139
 
65
140
  `alexandr update` snapshots the data volume into `<instance>/backups/` *before* it
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "alexandr",
3
- "version": "0.2.1",
4
- "description": "Run the alexandr workspace runtime locally a thin Docker front door (npx alexandr up). Pulls + boots the published kernel image.",
3
+ "version": "0.3.0",
4
+ "description": "Run the alexandr workspace runtime locally (npx alexandr up), and develop alexandr apps against a workspace (alexandr app link | dev | build | deploy).",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "alexandr": "./bin.js"
8
8
  },
9
9
  "scripts": {
10
- "check": "node scripts/check.mjs"
10
+ "check": "node scripts/check.mjs",
11
+ "test": "node --test \"test/**/*.test.js\""
11
12
  },
12
13
  "files": [
13
14
  "bin.js",
@@ -0,0 +1,124 @@
1
+ // `alexandr app build` — run THE builder, the one the workspace runs.
2
+ //
3
+ // ⚠ The builder is not reimplemented here, and it is not bundled into this CLI
4
+ // either. `@alexandr/app-build` is resolved from the PROJECT's own
5
+ // node_modules, which is what makes a laptop build and a workspace build the
6
+ // same bytes: the app pins a builder version in its package.json, and the app
7
+ // that pinned it gets it. A builder baked into the CLI would drift from the
8
+ // runtime the moment either shipped.
9
+ //
10
+ // A project without it is refused with the command that fixes it, never with a
11
+ // module-not-found stack.
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { dirname, resolve } from "node:path";
15
+ import { pathToFileURL } from "node:url";
16
+ import { bold, dim, fail, log, ok, step, warn } from "../util.js";
17
+ import { EXIT } from "../exit.js";
18
+
19
+ export const BUILD_HELP = `${bold("alexandr app build")} — build this app the way the workspace does
20
+
21
+ ${bold("USAGE")}
22
+ alexandr app build [--dir <path>]
23
+
24
+ ${dim("Runs the project's own @alexandr/app-build: the frontend bundle into dist/app.iife.js, and dist/server.bundle.js when the manifest declares a server.")}`;
25
+
26
+ /**
27
+ * Find `@alexandr/app-build` for `project`: walk up from the project looking for
28
+ * `node_modules/@alexandr/app-build`, then read its package.json for the entry.
29
+ *
30
+ * ⚠ Hand-rolled rather than `require.resolve`. The published package's `exports`
31
+ * map declares only `types` + `import` (it is ESM), so CJS resolution answers
32
+ * ERR_PACKAGE_PATH_NOT_EXPORTED — and `import.meta.resolve` is not stable across
33
+ * every Node 18 this CLI supports. Reading the manifest is boring and works
34
+ * everywhere, including inside a pnpm store layout.
35
+ */
36
+ export function resolveAppBuild(project) {
37
+ let dir = resolve(project);
38
+ for (;;) {
39
+ const pkgDir = resolve(dir, "node_modules", "@alexandr", "app-build");
40
+ const manifest = resolve(pkgDir, "package.json");
41
+ if (existsSync(manifest)) {
42
+ try {
43
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
44
+ const dot = pkg.exports?.["."];
45
+ const entry =
46
+ (typeof dot === "string" ? dot : dot?.import ?? dot?.default) ??
47
+ pkg.module ??
48
+ pkg.main ??
49
+ "index.js";
50
+ return { ok: true, dir: pkgDir, entry: resolve(pkgDir, entry), version: pkg.version };
51
+ } catch (e) {
52
+ return { ok: false, error: `@alexandr/app-build is installed but unreadable: ${e.message}` };
53
+ }
54
+ }
55
+ const parent = dirname(dir);
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ return {
60
+ ok: false,
61
+ error:
62
+ "this project has no @alexandr/app-build. Install it: `npm install --save-dev @alexandr/app-build` (the app scaffold lists it already).",
63
+ };
64
+ }
65
+
66
+ /** Load the builder for `project`, or exit with the reason. */
67
+ export async function loadBuilder(project) {
68
+ const found = resolveAppBuild(project);
69
+ if (!found.ok) fail(found.error, EXIT.NO_INSTANCE);
70
+ if (found.entry.endsWith(".ts")) {
71
+ fail(
72
+ `@alexandr/app-build at ${found.dir} resolves to TypeScript source, which Node cannot load. Install the published package, or run this verb with bun.`,
73
+ EXIT.GENERAL,
74
+ );
75
+ }
76
+ return import(pathToFileURL(found.entry).href);
77
+ }
78
+
79
+ /** The app folder this command acts on: `--dir`, else the cwd. */
80
+ export function appDirOf(flags, cwd = process.cwd()) {
81
+ const dir = typeof flags.dir === "string" && flags.dir.trim() ? resolve(cwd, flags.dir) : resolve(cwd);
82
+ if (!existsSync(resolve(dir, "manifest.json"))) {
83
+ fail(
84
+ `No manifest.json in ${dir} — run this inside an app folder, or pass --dir. (New app: \`npm create alexandr-app@latest my-app\`.)`,
85
+ EXIT.NO_INSTANCE,
86
+ );
87
+ }
88
+ return dir;
89
+ }
90
+
91
+ /**
92
+ * Build the app. Returns `{ ok, bundlePath, serverBundled }`.
93
+ *
94
+ * Shared with `deploy`, which is `build` + `pack` + upload — so the two can never
95
+ * disagree about what "built" means.
96
+ */
97
+ export async function buildApp(project, { quiet = false } = {}) {
98
+ const { buildFrontend, buildServer } = await loadBuilder(project);
99
+
100
+ if (!quiet) step("Building the frontend…");
101
+ const front = await buildFrontend(project);
102
+ for (const line of front.log ?? []) log(dim(` ${line}`));
103
+ for (const w of front.warnings ?? []) warn(w);
104
+ if (!front.ok) return { ok: false, error: front.error ?? "frontend build failed" };
105
+
106
+ if (!quiet) step("Building the server…");
107
+ const server = await buildServer(project);
108
+ for (const line of server.log ?? []) log(dim(` ${line}`));
109
+ // ⚠ The esbuild fallback (a laptop with no bun) is CLOSE, not byte-identical —
110
+ // it says so, and so do we, rather than letting a developer discover it when a
111
+ // release behaves differently from their local build.
112
+ for (const w of server.warnings ?? []) warn(w);
113
+ if (!server.ok) return { ok: false, error: server.error ?? "server build failed" };
114
+
115
+ return { ok: true, bundlePath: front.bundlePath, serverBundled: !server.skipped };
116
+ }
117
+
118
+ export async function appBuild(flags) {
119
+ if (flags.help || flags.h) return void log(BUILD_HELP);
120
+ const project = appDirOf(flags);
121
+ const res = await buildApp(project);
122
+ if (!res.ok) fail(res.error, EXIT.GENERAL);
123
+ ok(`Built ${dim(res.bundlePath)}${res.serverBundled ? dim(" + dist/server.bundle.js") : ""}`);
124
+ }
@@ -0,0 +1,326 @@
1
+ // `alexandr app config assign <NAME> --connection <id>` — the terminal door to
2
+ // an app's slot BINDINGS (app-system-stage-2.md §2 WP-L, Part 7's two-doors rule).
3
+ //
4
+ // A slot is a name the app declared. This verb says where its value comes from:
5
+ // a Database connection somebody made in Connections, or the literal that
6
+ // `alexandr app secrets set` put in the secrets engine. Saving restarts the
7
+ // app's server, so the process that comes back is the one with the new
8
+ // environment.
9
+ //
10
+ // ⚠⚠ NO VALUE PASSES THROUGH HERE, in either direction. `GET` answers
11
+ // `hasValue` booleans and connection NAMES; `PUT` accepts `{ connection }` or
12
+ // `{ secret: true }` and REFUSES anything shaped like a value. A connection
13
+ // string typed into this route would land in a plaintext record with no
14
+ // encryption and no audit row — which is the exact thing the secrets engine
15
+ // exists to prevent.
16
+ //
17
+ // ⚠ AND THE PUT IS A REPLACE, NOT A MERGE. Every command here therefore READS
18
+ // the whole slot map first and sends it back with one entry changed. Sending
19
+ // only the entry being assigned would silently un-bind every other slot the app
20
+ // has — an app that started yesterday would stop starting today, for a reason
21
+ // nothing on screen would explain.
22
+ //
23
+ // ⚠ ONE MODULE FOR BOTH VERBS, and the filename is `config.js` for a reason a
24
+ // reader will otherwise trip over: `scripts/check.mjs` refuses to publish any
25
+ // path in the tarball matching /secret/i — the guard that keeps a stray `.env`
26
+ // or token file out of the npm package. A source file called `secrets.js` trips
27
+ // it, and weakening a leak guard to accommodate a filename is the wrong trade.
28
+ // The two verbs are one subject anyway: an app's configuration is its VALUES and
29
+ // its BINDINGS, and `secrets set` performs both steps.
30
+
31
+ import { bold, cyan, dim, fail, log, ok, step } from "../util.js";
32
+ import { EXIT } from "../exit.js";
33
+ import { getJson } from "../consent.js";
34
+ import { readPipedSecret, secret as promptSecret } from "../prompt.js";
35
+ import { appDirOf } from "./build.js";
36
+ import { appIdOf } from "./deploy.js";
37
+ import { APP_ID_RE, SECRET_NAME_RE, headersFor, putJson, reachWorkspace } from "./reach.js";
38
+
39
+ export const CONFIG_HELP = `${bold("alexandr app config")} — say where each of this app's declared values comes from
40
+
41
+ ${bold("USAGE")}
42
+ alexandr app config assign <NAME> --connection <id> ${dim("point a slot at a Database connection")}
43
+ alexandr app config assign <NAME> --secret ${dim("point it at the value you set with `secrets set`")}
44
+ alexandr app config show ${dim("what this app declared, and what fills it")}
45
+
46
+ ${dim("Saving restarts the app's server so the new environment is the one its process has. No value is ever sent or returned here — a literal goes to `alexandr app secrets set`, and a connection is made in Connections.")}`;
47
+
48
+ /** Read the app's configuration. Exits with the reason rather than half-answering. */
49
+ export async function readConfig(url, authorization, appId) {
50
+ const res = await getJson(
51
+ `${url}/_kernel/apps/${encodeURIComponent(appId)}/config`,
52
+ headersFor(authorization),
53
+ );
54
+ if (res.error) {
55
+ if (res.status === 404) {
56
+ fail(`The workspace has no app called "${appId}" — deploy it first.`, EXIT.NO_INSTANCE);
57
+ }
58
+ if (res.status === 401 || res.status === 403) {
59
+ fail(
60
+ `The workspace refused it (${res.status}) — an app's configuration needs the 'os.apps.manage' permission there. ${res.error}`,
61
+ EXIT.GENERAL,
62
+ );
63
+ }
64
+ fail(`Couldn't read ${appId}'s configuration: ${res.error}`, EXIT.GENERAL);
65
+ }
66
+ return res.data;
67
+ }
68
+
69
+ /**
70
+ * The stored bindings, as the PUT wants them back.
71
+ *
72
+ * ⚠ THE READ-MODIFY-WRITE, in one function, so no caller can forget the read.
73
+ * `{ secret: true, hasValue: false }` is a slot with nothing behind it and is
74
+ * DROPPED — sending it back would claim a literal exists where none does.
75
+ */
76
+ export function bindingsFrom(config) {
77
+ const out = {};
78
+ for (const [name, state] of Object.entries(config.slots ?? {})) {
79
+ if (state && typeof state === "object" && "connection" in state) out[name] = { connection: state.connection };
80
+ else if (state && state.secret === true && state.hasValue) out[name] = { secret: true };
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /** Write the whole map back and restart the app. */
86
+ export async function writeBindings(url, authorization, appId, slots) {
87
+ const res = await putJson(
88
+ `${url}/_kernel/apps/${encodeURIComponent(appId)}/config`,
89
+ { slots },
90
+ headersFor(authorization),
91
+ );
92
+ if (res.error) {
93
+ if (res.status === 401 || res.status === 403) {
94
+ fail(
95
+ `The workspace refused it (${res.status}) — an app's configuration needs the 'os.apps.manage' permission there. ${res.error}`,
96
+ EXIT.GENERAL,
97
+ );
98
+ }
99
+ fail(`Couldn't save the configuration: ${res.error}`, EXIT.GENERAL);
100
+ }
101
+ return res.data;
102
+ }
103
+
104
+ /**
105
+ * Bind ONE slot, keeping every other binding exactly as it was — the shape both
106
+ * `config assign` and `secrets set` need.
107
+ */
108
+ export async function bindSlot(url, authorization, appId, name, binding) {
109
+ const config = await readConfig(url, authorization, appId);
110
+ const declared = (config.declared ?? []).some((s) => s.name === name);
111
+ if (!declared) return { bound: false, config };
112
+ const slots = { ...bindingsFrom(config), [name]: binding };
113
+ const after = await writeBindings(url, authorization, appId, slots);
114
+ // ⚠ The PUT answers the new slot STATES but not the declarations (it has no
115
+ // reason to repeat them) — so the caller gets the GET's shape with the fresh
116
+ // states folded in, and prints one table rather than two half-tables.
117
+ return { bound: true, config: { ...config, slots: after.slots ?? config.slots } };
118
+ }
119
+
120
+ /** One line per declared slot — what it is, and what fills it today. */
121
+ function printSlots(config) {
122
+ for (const slot of config.declared ?? []) {
123
+ const state = config.slots?.[slot.name];
124
+ const filled =
125
+ state && "connection" in state
126
+ ? `→ ${state.name ?? state.connection}`
127
+ : state && state.hasValue
128
+ ? "→ ••••••••"
129
+ : dim("not set");
130
+ const need = slot.required === false ? dim(" (optional)") : "";
131
+ log(` ${bold(slot.name.padEnd(24))} ${dim(slot.kind.padEnd(9))} ${filled}${need}`);
132
+ if (slot.description) log(` ${dim(" ".repeat(24) + " " + slot.description)}`);
133
+ }
134
+ }
135
+
136
+ export async function appConfig(flags) {
137
+ const sub = flags._.shift();
138
+ if (flags.help || flags.h || !sub) return void log(CONFIG_HELP);
139
+ if (sub !== "assign" && sub !== "show") {
140
+ fail(`Unknown command 'app config ${sub}'. Try: assign | show.`, EXIT.USAGE);
141
+ }
142
+
143
+ const project = appDirOf(flags);
144
+ const appId = appIdOf(project);
145
+ if (!appId) fail("This folder's manifest.json names no app id.", EXIT.USAGE);
146
+ if (!APP_ID_RE.test(appId)) fail(`"${appId}" isn't a valid app id.`, EXIT.USAGE);
147
+ const { url, authorization } = await reachWorkspace(project);
148
+
149
+ if (sub === "show") {
150
+ const config = await readConfig(url, authorization, appId);
151
+ log(`${bold(appId)} ${dim(`· data: ${config.data}`)}`);
152
+ if ((config.declared ?? []).length === 0) {
153
+ log(dim(" This app declares no configuration."));
154
+ return;
155
+ }
156
+ printSlots(config);
157
+ return;
158
+ }
159
+
160
+ const name = flags._.shift();
161
+ if (!name) {
162
+ fail("Which slot? Usage: alexandr app config assign <NAME> --connection <id>", EXIT.USAGE);
163
+ }
164
+ if (!SECRET_NAME_RE.test(name)) {
165
+ fail(`"${name}" isn't a valid slot name. Letters, digits, _ and - only, up to 64 characters.`, EXIT.USAGE);
166
+ }
167
+ // ⚠ THE SAME REFUSAL AS `secrets set`, for the same reason: this route takes
168
+ // references, so a value on the command line is both unsafe and meaningless.
169
+ if (flags._.length > 0 || flags.value !== undefined) {
170
+ fail(
171
+ "This command takes a reference, not a value. Use --connection <id> for a Database connection, " +
172
+ "or set the literal with `alexandr app secrets set <NAME>` and then --secret.",
173
+ EXIT.USAGE,
174
+ );
175
+ }
176
+
177
+ const connection = typeof flags.connection === "string" ? flags.connection.trim() : "";
178
+ const literal = flags.secret === true;
179
+ if (!connection && !literal) {
180
+ fail("Say where the value comes from: --connection <id>, or --secret.", EXIT.USAGE);
181
+ }
182
+ if (connection && literal) {
183
+ fail("A slot has one source: --connection <id> OR --secret, not both.", EXIT.USAGE);
184
+ }
185
+
186
+ step(`Assigning ${bold(name)} on ${bold(appId)}…`);
187
+ const binding = connection ? { connection } : { secret: true };
188
+ const { bound, config } = await bindSlot(url, authorization, appId, name, binding);
189
+ if (!bound) {
190
+ fail(
191
+ `${appId} declares no slot called "${name}". Its manifest lists: ` +
192
+ ((config.declared ?? []).map((s) => s.name).join(", ") || "nothing") +
193
+ ".",
194
+ EXIT.USAGE,
195
+ );
196
+ }
197
+
198
+ ok(`${bold(name)} is assigned. ${dim("The app's server was restarted with it.")}`);
199
+ log("");
200
+ printSlots(config);
201
+ log("");
202
+ log(`${cyan("›")} ${dim("Watch it come back: alexandr app deploy tails the same log, or open the app's page in the workspace.")}`);
203
+ }
204
+
205
+ ///////////////////////////////////////////////////////////////////////////////
206
+ export const SECRETS_HELP = `${bold("alexandr app secrets")} — give this app a configuration value
207
+
208
+ ${bold("USAGE")}
209
+ alexandr app secrets set <NAME> [--dir <path>]
210
+
211
+ ${bold("THE VALUE COMES FROM A PROMPT OR A PIPE — never the command line")}
212
+ alexandr app secrets set STRIPE_SECRET_KEY ${dim("asks, with the input hidden")}
213
+ printf %s "$KEY" | alexandr app secrets set STRIPE_SECRET_KEY
214
+ alexandr app secrets set KEY < key.txt
215
+
216
+ ${dim("Passing the value as an argument is refused: a command line is in your shell history and in ps output. The value is PUT to the workspace's secrets engine, encrypted at rest, and can never be read back — only replaced.")}
217
+
218
+ ${bold("IT ALSO BINDS THE SLOT")}
219
+ ${dim("Storing a value is only half of it: the app's process is given a slot's value only when the slot is BOUND, so this binds it and restarts the server — the same two steps the app's Configuration page takes on Save. To point a slot at a Database connection instead, use `alexandr app config assign`.")}`;
220
+
221
+ /**
222
+ * Where the value comes from. Exported for the test, and separated from the
223
+ * request so the ONE rule this command exists to enforce can be checked without
224
+ * a workspace, a token or a network.
225
+ *
226
+ * `argvRest` is what is left on the command line after the NAME — anything at
227
+ * all is a refusal.
228
+ */
229
+ export function refuseValueInArgv(argvRest, flags = {}) {
230
+ if (argvRest.length > 0) {
231
+ return (
232
+ "A value can't be passed on the command line — it would be in your shell history and in " +
233
+ "`ps` output. Run `alexandr app secrets set <NAME>` and paste it at the prompt, or pipe it: " +
234
+ `printf %s "$VALUE" | alexandr app secrets set ${argvRest.length ? "<NAME>" : "<NAME>"}`
235
+ );
236
+ }
237
+ for (const key of ["value", "secret", "val"]) {
238
+ if (flags[key] !== undefined) {
239
+ return `--${key} isn't accepted — a value comes from the prompt or from stdin, never from the command line.`;
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Read the value: the pipe when there is one, the hidden prompt otherwise.
247
+ *
248
+ * ⚠ `isTTY` IS THE TEST, not an interactivity flag. A piped run has no terminal
249
+ * to prompt on, so prompting there would hang a CI job forever with no output.
250
+ */
251
+ export async function readSecretValue(name, { input = process.stdin, output = process.stdout } = {}) {
252
+ if (!input.isTTY) return await readPipedSecret(input);
253
+ return await promptSecret(`Value for ${name}`, { input, output });
254
+ }
255
+
256
+ export async function appSecrets(flags) {
257
+ const sub = flags._.shift();
258
+ if (flags.help || flags.h || !sub) return void log(SECRETS_HELP);
259
+ if (sub !== "set") {
260
+ fail(`Unknown command 'app secrets ${sub}'. The only one is: set.`, EXIT.USAGE);
261
+ }
262
+
263
+ const name = flags._.shift();
264
+ if (!name) fail("Which value? Usage: alexandr app secrets set <NAME>", EXIT.USAGE);
265
+ // ⚠ THE REFUSAL, BEFORE ANYTHING ELSE. Nothing is read, resolved or sent when
266
+ // a value was typed on the command line — the point is that it never travels.
267
+ const refusal = refuseValueInArgv(flags._, flags);
268
+ if (refusal) fail(refusal, EXIT.USAGE);
269
+ if (!SECRET_NAME_RE.test(name)) {
270
+ fail(
271
+ `"${name}" isn't a valid name. Letters, digits, _ and - only, starting with a letter or digit, up to 64 characters.`,
272
+ EXIT.USAGE,
273
+ );
274
+ }
275
+
276
+ const project = appDirOf(flags);
277
+ const appId = appIdOf(project);
278
+ if (!appId) fail("This folder's manifest.json names no app id.", EXIT.USAGE);
279
+ if (!APP_ID_RE.test(appId)) fail(`"${appId}" isn't a valid app id.`, EXIT.USAGE);
280
+
281
+ const value = await readSecretValue(name);
282
+ if (!value.trim()) fail("Nothing was given — the value is empty.", EXIT.USAGE);
283
+
284
+ const { url, authorization } = await reachWorkspace(project);
285
+ step(`Setting ${bold(name)} for ${bold(appId)}…`);
286
+ const res = await putJson(
287
+ `${url}/_kernel/secrets/app/${encodeURIComponent(appId)}/${encodeURIComponent(name)}`,
288
+ { value },
289
+ headersFor(authorization),
290
+ );
291
+ if (res.error) {
292
+ if (res.status === 401 || res.status === 403) {
293
+ fail(
294
+ `The workspace refused it (${res.status}) — setting an app's secret needs the 'os.apps.manage' permission there. ${res.error}`,
295
+ EXIT.GENERAL,
296
+ );
297
+ }
298
+ fail(`Couldn't set ${name}: ${res.error}`, EXIT.GENERAL);
299
+ }
300
+
301
+ // ⚠⚠ VALUE FIRST, BINDING SECOND — the same order the Configuration page saves
302
+ // in, and for the same reason: the binding PUT restarts the app, and a value
303
+ // written after it would be missing from the very start it was meant for.
304
+ //
305
+ // ⚠ AND THE BINDING IS PART OF THE COMMAND. `resolveAppEnv` reads the CONFIG
306
+ // record, not the secrets engine: a slot with a stored value and no binding
307
+ // still counts as missing, so a `set` that stopped at the value would leave the
308
+ // app refusing to start with the value it was just given sitting right there.
309
+ const { bound, config } = await bindSlot(url, authorization, appId, name, { secret: true });
310
+
311
+ ok(`${bold(name)} is set. ${dim("It is encrypted at rest and can never be read back — only replaced.")}`);
312
+ if (bound) {
313
+ log(dim(` ${appId} is bound to it, and its server was restarted.`));
314
+ } else {
315
+ // Stored, deliberately, rather than refused: a manifest is edited and
316
+ // deployed separately, and a value waiting for its declaration is useful.
317
+ log(
318
+ dim(
319
+ ` ⚠ ${appId} declares no slot called ${name} yet, so nothing is bound. Its manifest lists: ` +
320
+ ((config.declared ?? []).map((s) => s.name).join(", ") || "nothing") +
321
+ ".",
322
+ ),
323
+ );
324
+ log(`${cyan("›")} Add it to the manifest's ${bold("secrets")} and deploy, then run this again.`);
325
+ }
326
+ }