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.
- package/README.md +74 -1
- package/package.json +4 -3
- package/src/app/build.js +124 -0
- package/src/app/config.js +326 -0
- package/src/app/deploy.js +234 -0
- package/src/app/dev.js +177 -0
- package/src/app/entitle.js +139 -0
- package/src/app/index.js +125 -0
- package/src/app/link.js +187 -0
- package/src/app/multipart.js +53 -0
- package/src/app/publish.js +421 -0
- package/src/app/reach.js +100 -0
- package/src/app/rollback.js +72 -0
- package/src/app/signing.js +175 -0
- package/src/app/store.js +191 -0
- package/src/app/token.js +83 -0
- package/src/app/update.js +163 -0
- package/src/cli.js +8 -1
- package/src/commands.js +97 -12
- package/src/completion.js +15 -0
- package/src/consent.js +272 -0
- package/src/deps.js +1 -2
- package/src/instance.js +34 -1
- package/src/link.js +23 -270
- package/src/prompt.js +56 -0
- package/src/updater.js +276 -0
- package/templates/docker-compose.yml +33 -0
- package/templates/env.example +10 -0
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`),
|
|
@@ -53,16 +65,77 @@ rotation), `up` and `update` offer a quick re-sign-in on the spot — or run
|
|
|
53
65
|
| `alexandr connect` | Print the `alexandr://connect` link / paste-string |
|
|
54
66
|
| `alexandr link` | Re-link this runtime to your account (`--force` re-registers) |
|
|
55
67
|
| `alexandr login` | Refresh the runtime-image pull credential (sign in, no re-register) |
|
|
56
|
-
| `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip) |
|
|
68
|
+
| `alexandr update` | Update the runtime image (`--to <tag>`, `--rollback`); auto-snapshots `/data` first (`--no-backup` to skip); keeps the newest 3 snapshots |
|
|
69
|
+
| `alexandr updater serve` | The updater sidecar's entrypoint — runs inside its container; `up` starts it, so the desktop app's update sheet can press "Update now" (`--no-updater` on `up` opts out) |
|
|
57
70
|
| `alexandr backup` / `restore <f>` | Archive / restore the data volume |
|
|
58
71
|
| `alexandr config set <k> <v>` | Edit config (`ai.url`, `model`, `port`, `domain`, …) |
|
|
59
72
|
| `alexandr init` | Write a committable `./alexandr` config folder |
|
|
60
73
|
| `alexandr destroy [--volumes] [--unlink]` | Remove containers (and optionally `/data` / the account record) |
|
|
61
74
|
| `alexandr completion <bash\|zsh\|fish>` | Print a shell completion script |
|
|
62
75
|
| `alexandr doctor` | Check Docker, ports, config |
|
|
76
|
+
| `alexandr app <cmd>` | Develop an app on this machine — see below |
|
|
63
77
|
|
|
64
78
|
Common flags: `--port`, `--domain`, `--name`, `--dir`, `--open`, `--offline`, `--yes`.
|
|
65
79
|
|
|
80
|
+
## Building an app
|
|
81
|
+
|
|
82
|
+
The laptop half of the alexandr app system. Everything the assistant can do inside a
|
|
83
|
+
workspace, you can do from a terminal, against the same engine.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
npm create alexandr-app@latest my-app # page | full-stack | tool
|
|
87
|
+
cd my-app
|
|
88
|
+
alexandr app link # sign in, pick the workspace — once
|
|
89
|
+
alexandr app dev # Vite, proxied to it — while you work
|
|
90
|
+
alexandr app deploy # build, package, install — when it is ready
|
|
91
|
+
alexandr app publish # sign it, release it — when you want to share it
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
| Command | What it does |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `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 |
|
|
97
|
+
| `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 |
|
|
98
|
+
| `alexandr app build` | Runs the project's own `@alexandr/app-build` — the exact builder the workspace runs — into `dist/` |
|
|
99
|
+
| `alexandr app deploy` | Build, package the whole folder, checksum it, and install it in the linked workspace |
|
|
100
|
+
| `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` |
|
|
101
|
+
| `alexandr app update` | Move the linked workspace onto the newest release of this app (`--app <id>` for another one) |
|
|
102
|
+
| `alexandr app rollback` | Put it back on the previous release. Your data keeps the newer layout — only additive changes are safe across a rollback |
|
|
103
|
+
| `alexandr app entitle` | Let one workspace install this private app; `--remove` takes it away |
|
|
104
|
+
|
|
105
|
+
`link` writes your account session to `~/.alexandr/auth.json` (owner-only) and this
|
|
106
|
+
project's workspace to `.alexandr/link.json` (gitignored). The workspace token lives in
|
|
107
|
+
`.alexandr/token.json`, is re-minted while `dev` runs, and is attached **by the proxy** —
|
|
108
|
+
it never reaches the browser.
|
|
109
|
+
|
|
110
|
+
`deploy` needs the `os.apps.create` permission in that workspace; `update` and `rollback`
|
|
111
|
+
need `os.apps.manage`.
|
|
112
|
+
|
|
113
|
+
`publish` mints one ed25519 signing key per machine on first use
|
|
114
|
+
(`~/.alexandr/publisher.json`, owner-only) and registers its **public** half with the
|
|
115
|
+
control plane. The private half never leaves that file — not into an argument, a log line,
|
|
116
|
+
or the upload — so a control plane can refuse a release but never forge one. A published
|
|
117
|
+
version is immutable: to publish again, bump `version` in `manifest.json`.
|
|
118
|
+
|
|
119
|
+
### Publishing from CI
|
|
120
|
+
|
|
121
|
+
A runner has no account and no browser, so it publishes **as the workspace** instead. A
|
|
122
|
+
scaffolded project (and every repo alexandr creates) carries `.github/workflows/publish.yml`:
|
|
123
|
+
push a tag `v<version>` — the same version as `manifest.json` — and the job builds, packages,
|
|
124
|
+
signs and publishes the release. Every workspace that installed the app is offered the update.
|
|
125
|
+
|
|
126
|
+
Repository secrets it reads:
|
|
127
|
+
|
|
128
|
+
| Secret | What |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `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`) |
|
|
131
|
+
| `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 |
|
|
132
|
+
| `ALEXANDR_CP_URL` | optional — the control plane, when it is not the public one |
|
|
133
|
+
|
|
134
|
+
By hand: `CI=1 alexandr app publish` (or `--non-interactive`) with those variables set. With
|
|
135
|
+
nothing to publish as, it fails immediately and names the two variables — it never waits for a
|
|
136
|
+
sign-in that cannot happen. The runtime never pulls from git: a release is published, and the
|
|
137
|
+
workspace's own update path installs it.
|
|
138
|
+
|
|
66
139
|
## Updates are reversible
|
|
67
140
|
|
|
68
141
|
`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.
|
|
4
|
-
"description": "Run the alexandr workspace runtime locally
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|
package/src/app/build.js
ADDED
|
@@ -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
|
+
}
|