permaura 1.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 ADDED
@@ -0,0 +1,43 @@
1
+ # permaura
2
+
3
+ Run the [Permaura](https://permaura.com) gateway with one command. **Give your AI agents permission, not keys.**
4
+
5
+ ```bash
6
+ # Install + run, then paste the URL/token into the console:
7
+ npx -y permaura start
8
+
9
+ # Or connect it to your account (grab the pairing code from permaura.com → Connect a gateway):
10
+ npx -y permaura start --pair PGW-XXXXXXXXXX
11
+
12
+ # Drive the gateway you just started:
13
+ npx -y permaura status
14
+ npx -y permaura grant --agent my-agent --resource db1 --allow uptime
15
+ npx -y permaura stop
16
+
17
+ # Just fetch the latest binary and exit, or print versions:
18
+ npx -y permaura upgrade
19
+ npx -y permaura --version
20
+ ```
21
+
22
+ `npx -y permaura` on its own prints the help and starts nothing. Starting a gateway is always `start`.
23
+
24
+ It downloads the prebuilt binary (to `~/.permaura/bin/permaura`), starts it on `http://127.0.0.1:8787`, and, with `--pair`, enrolls it against your permaura.com account so your console goes live automatically. No copy/paste of URLs or tokens.
25
+
26
+ This package is a launcher, not a second CLI: every command other than `start` and `upgrade` is handed to the binary untouched, so `npx -y permaura <anything>` is the same program you get from `curl -fsSL https://permaura.com/install | sh`.
27
+
28
+ Every run checks the release manifest and swaps in a newer binary when one ships (the manifest's Ed25519 signature is verified against a pinned release key, the download against the SHA-256 in it, and the swap is atomic so a running gateway is never disturbed). Pass `--no-upgrade` (or set `PERMAURA_NO_UPGRADE=1`) to pin the cached binary.
29
+
30
+ Press **Ctrl-C** to stop a gateway running in the foreground.
31
+
32
+ ### Environment overrides
33
+
34
+ | Var | Default | Purpose |
35
+ |---|---|---|
36
+ | `PERMAURA_ADDR` | `127.0.0.1:8787` | address the gateway listens on |
37
+ | `PERMAURA_API` | `https://permaura.com` | account/pairing endpoint |
38
+ | `PERMAURA_MANIFEST_URL` | dist `manifests/gateway.json` | release manifest to check for upgrades |
39
+ | `PERMAURA_NO_UPGRADE` | (unset) | set to `1` to skip the auto-upgrade check |
40
+
41
+ macOS, Linux and Windows, on arm64 and x64.
42
+
43
+ > The gateway is a single self-contained binary. Source and releases: https://github.com/pixelitemedia/permaura-dist
package/bin/cli.mjs ADDED
@@ -0,0 +1,591 @@
1
+ #!/usr/bin/env node
2
+ // permaura — the dead-easy way to run the Permaura gateway.
3
+ // npx -y permaura start # install-or-upgrade, run, print URL + token
4
+ // npx -y permaura start --pair PGW-XXXX # …and connect it to your permaura.com account
5
+ // npx -y permaura status # …or any other permaura command, against a running one
6
+ // npx -y permaura upgrade # just fetch the latest binary and exit
7
+ // npx -y permaura # print this and start nothing
8
+ //
9
+ // Zero dependencies: it reads the release manifest, downloads the prebuilt binary from the public
10
+ // dist release, verifies its SHA-256, and runs it. Every run checks the manifest and swaps in a
11
+ // newer binary when one ships (skip with --no-upgrade / PERMAURA_NO_UPGRADE=1).
12
+ //
13
+ // This is a launcher, not a second CLI. Everything but the install/upgrade half is handed to the
14
+ // binary verbatim, so there is exactly one implementation of every command — including `--pair`,
15
+ // which the binary performs itself once it is up.
16
+ import os from "node:os";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import https from "node:https";
20
+ import http from "node:http";
21
+ import crypto from "node:crypto";
22
+ import { spawn, spawnSync } from "node:child_process";
23
+
24
+ import {
25
+ platformKey,
26
+ binaryFilename,
27
+ compareVersions,
28
+ sweepOldBinaries,
29
+ swapBinaryIntoPlace,
30
+ verifyDetachedSignature,
31
+ } from "./lib.mjs";
32
+
33
+ // The single source of truth for "what's the latest gateway": a static JSON in the public dist repo.
34
+ // Overridable for staging/testing via PERMAURA_MANIFEST_URL.
35
+ const MANIFEST_URL =
36
+ process.env.PERMAURA_MANIFEST_URL ||
37
+ "https://raw.githubusercontent.com/pixelitemedia/permaura-dist/main/manifests/gateway.json";
38
+ const ADDR = process.env.PERMAURA_ADDR || "127.0.0.1:8787";
39
+ // The same `~/.permaura` the gateway itself resolves (PERMAURA_HOME override kept, D6).
40
+ const DIR = process.env.PERMAURA_HOME || path.join(os.homedir(), ".permaura");
41
+ const BIN = path.join(DIR, "bin", binaryFilename(platformKey()));
42
+ // Where a clean-bootstrapping gateway drops the per-install operator bearer it just minted (0600).
43
+ // There is deliberately no constant fallback: a predictable token would be a public operator
44
+ // credential on the very gateway the user is about to put real secrets into.
45
+ const TOKEN_FILE = path.join(DIR, "operator.token");
46
+
47
+ const C = {
48
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
49
+ cyan: (s) => `\x1b[36m${s}\x1b[0m`,
50
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
51
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
52
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
53
+ };
54
+ const ok = (s) => console.log(`${C.green("✓")} ${s}`);
55
+ const step = (s) => console.log(`${C.cyan("→")} ${s}`);
56
+ const warn = (s) => console.log(`${C.yellow("!")} ${s}`);
57
+
58
+ /**
59
+ * The binary's own subcommands. The launcher keeps the list so a typo costs nothing: a mistyped
60
+ * command is rejected here, before anything downloads a binary or binds a port. It is the same list
61
+ * the binary's clap definition carries, and the two drift only when a command is added — at which
62
+ * point the worst case is that the new one has to be run through the installed binary directly.
63
+ */
64
+ const KNOWN = new Set([
65
+ "start",
66
+ "stop",
67
+ "upgrade",
68
+ "init",
69
+ "status",
70
+ "agent",
71
+ "grant",
72
+ "audit",
73
+ "pending",
74
+ "approve",
75
+ "deny",
76
+ "vault",
77
+ "policy",
78
+ "connection",
79
+ "approver",
80
+ "help",
81
+ ]);
82
+
83
+ /** The two global flags that take a value, so their value is never mistaken for the subcommand. */
84
+ const VALUE_FLAGS = new Set(["--url", "--token"]);
85
+
86
+ const USAGE = `permaura — the dead-easy way to run the Permaura gateway.
87
+
88
+ Usage:
89
+ npx -y permaura start install-or-upgrade, run, print URL + token
90
+ npx -y permaura start --pair PGW-XXXX …and connect it to your permaura.com account
91
+ npx -y permaura upgrade just fetch the latest binary and exit
92
+ npx -y permaura <command> [args…] run any other permaura command against a gateway
93
+ npx -y permaura --version print the launcher + installed-binary versions
94
+ npx -y permaura --help print this
95
+
96
+ Commands (${[...KNOWN].sort().join(", ")}) are the binary's own — run
97
+ \`npx -y permaura help <command>\` for one of them.
98
+
99
+ Launcher options:
100
+ --no-upgrade run the cached binary without checking for a newer one
101
+ (same as PERMAURA_NO_UPGRADE=1)
102
+ --version, -v print versions and exit
103
+ --help, -h print this and exit
104
+
105
+ Environment:
106
+ PERMAURA_HOME where the binary, store and operator token live (default ~/.permaura)
107
+ PERMAURA_ADDR address the gateway binds (default 127.0.0.1:8787)
108
+ PERMAURA_API account API base (default https://permaura.com)
109
+ PERMAURA_NO_UPGRADE set to 1 to skip the upgrade check
110
+ `;
111
+
112
+ /**
113
+ * Work out what this invocation is: which subcommand (if any), and which of the launcher's own flags
114
+ * were given before it.
115
+ *
116
+ * NO arguments prints help and starts nothing. A multi-command tool that boots a long-running server
117
+ * when invoked bare is a surprise, and this is the first command most people type. An unrecognised
118
+ * subcommand is an ERROR for the same reason it always was — `--help`, and every typo, used to
119
+ * silently boot a gateway and bind a port.
120
+ *
121
+ * Everything else is forwarded to the binary untouched (minus the launcher-only flags), so the
122
+ * binary's own parser owns global flags, per-command flags and `--help` after a subcommand.
123
+ */
124
+ function parseArgs(argv) {
125
+ let cmd = null;
126
+ let noUpgrade = process.env.PERMAURA_NO_UPGRADE === "1";
127
+ let showVersion = false;
128
+ let showHelp = false;
129
+ const forward = [];
130
+
131
+ for (let i = 0; i < argv.length; i++) {
132
+ const a = argv[i];
133
+ if (VALUE_FLAGS.has(a)) {
134
+ forward.push(a);
135
+ if (i + 1 < argv.length) forward.push(argv[++i]);
136
+ continue;
137
+ }
138
+ if (a === "--no-upgrade") {
139
+ // Launcher-only: the binary has never heard of it, so it must not be forwarded.
140
+ noUpgrade = true;
141
+ continue;
142
+ }
143
+ if (!cmd && (a === "--version" || a === "-v")) {
144
+ showVersion = true;
145
+ continue;
146
+ }
147
+ if (!cmd && (a === "--help" || a === "-h")) {
148
+ showHelp = true;
149
+ continue;
150
+ }
151
+ if (!cmd && !a.startsWith("-")) cmd = a;
152
+ forward.push(a);
153
+ }
154
+ return { cmd, forward, noUpgrade, showVersion, showHelp };
155
+ }
156
+
157
+ /** This package's own version, read from the sibling package.json. */
158
+ function cliVersion() {
159
+ try {
160
+ const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"));
161
+ return pkg.version || "0.0.0";
162
+ } catch {
163
+ return "0.0.0";
164
+ }
165
+ }
166
+
167
+ /** The installed binary's version (`permaura-gateway 0.1.1` → `0.1.1`), or null if absent/unreadable. */
168
+ function installedVersion() {
169
+ if (!fs.existsSync(BIN)) return null;
170
+ try {
171
+ const out = spawnSync(BIN, ["--version"], { encoding: "utf8", timeout: 5000 });
172
+ const m = /(\d+\.\d+\.\d+[^\s]*)/.exec(out.stdout || "");
173
+ return m ? m[1] : null;
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /** https normally; plain http honoured so a PERMAURA_MANIFEST_URL override can point at a local test server (the release smoke does). */
180
+ function requester(u) {
181
+ return u.startsWith("http://") ? http : https;
182
+ }
183
+
184
+ function httpGet(url, { json = false } = {}) {
185
+ return new Promise((resolve, reject) => {
186
+ const go = (u, redirects = 0) => {
187
+ requester(u)
188
+ .get(u, { headers: { "User-Agent": "permaura-gateway-cli" } }, (res) => {
189
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
190
+ res.resume();
191
+ if (redirects > 10) return reject(new Error("too many redirects"));
192
+ return go(res.headers.location, redirects + 1);
193
+ }
194
+ if (res.statusCode !== 200) {
195
+ res.resume();
196
+ return reject(new Error(`HTTP ${res.statusCode} for ${u}`));
197
+ }
198
+ const chunks = [];
199
+ res.on("data", (d) => chunks.push(d));
200
+ res.on("end", () => {
201
+ const buf = Buffer.concat(chunks);
202
+ if (json) {
203
+ try {
204
+ resolve(JSON.parse(buf.toString("utf8")));
205
+ } catch (e) {
206
+ reject(new Error(`bad manifest JSON: ${e.message}`));
207
+ }
208
+ } else {
209
+ resolve(buf);
210
+ }
211
+ });
212
+ })
213
+ .on("error", reject);
214
+ };
215
+ go(url);
216
+ });
217
+ }
218
+
219
+ function download(url, dest) {
220
+ return new Promise((resolve, reject) => {
221
+ const go = (u, redirects = 0) => {
222
+ requester(u)
223
+ .get(u, { headers: { "User-Agent": "permaura-gateway-cli" } }, (res) => {
224
+ if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
225
+ res.resume();
226
+ if (redirects > 10) return reject(new Error("too many redirects"));
227
+ return go(res.headers.location, redirects + 1);
228
+ }
229
+ if (res.statusCode !== 200) {
230
+ res.resume();
231
+ return reject(new Error(`download failed: HTTP ${res.statusCode}`));
232
+ }
233
+ const file = fs.createWriteStream(dest);
234
+ res.pipe(file);
235
+ file.on("finish", () => file.close(() => resolve()));
236
+ file.on("error", (e) => reject(e));
237
+ })
238
+ .on("error", reject);
239
+ };
240
+ go(url);
241
+ });
242
+ }
243
+
244
+ function sha256File(p) {
245
+ return crypto.createHash("sha256").update(fs.readFileSync(p)).digest("hex");
246
+ }
247
+
248
+ /**
249
+ * The pinned release signing key (Ed25519, SPKI PEM) — `pmrel-2026a`, generated 2026-08-03.
250
+ *
251
+ * Why this matters: `asset.sha256` comes from the SAME manifest as `asset.url`. That is integrity
252
+ * against a corrupted download, not authenticity — anyone who can change the manifest changes both
253
+ * numbers and the checksum cheerfully confirms their binary. Only a signature made by a key we pinned
254
+ * out-of-band says "Permaura built this".
255
+ *
256
+ * `PERMAURA_RELEASE_PUBKEY` overrides this, which is how the tests drive a throwaway key. An override
257
+ * cannot weaken anything: any non-empty key still demands a valid signature.
258
+ */
259
+ const PINNED_RELEASE_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
260
+ MCowBQYDK2VwAyEAKqPdId97cJ1iXDNMq767UseY9eMKe7jBSOKNzjQp76Q=
261
+ -----END PUBLIC KEY-----`;
262
+
263
+ const RELEASE_PUBKEY_PEM = (process.env.PERMAURA_RELEASE_PUBKEY ?? "").trim() || PINNED_RELEASE_PUBKEY_PEM;
264
+
265
+ /** With a key configured, a valid signature is REQUIRED. Without one, we cannot verify anything yet. */
266
+ const REQUIRE_SIGNATURE = RELEASE_PUBKEY_PEM.length > 0;
267
+
268
+ /**
269
+ * Verify a detached Ed25519 signature over the EXACT manifest bytes.
270
+ *
271
+ * Detached-over-raw-bytes on purpose: signing a field *inside* the JSON would mean canonicalising it
272
+ * first, and every canonicalisation bug is a signature bypass. The bytes we verify are the bytes we
273
+ * parse.
274
+ *
275
+ * Returns true when verified, false when no key is configured (nothing to verify against), and throws
276
+ * when a key IS configured and the signature is missing or bad — that is an attack, not a hiccup.
277
+ */
278
+ async function verifyManifestSignature(rawManifest, sigUrl) {
279
+ if (!REQUIRE_SIGNATURE) return false;
280
+
281
+ let sigB64;
282
+ try {
283
+ sigB64 = (await httpGet(sigUrl)).toString("utf8").trim();
284
+ } catch (e) {
285
+ throw new Error(
286
+ `the release manifest is not signed (${e.message}). Refusing to install: a signature is required once a release key is pinned.`,
287
+ );
288
+ }
289
+
290
+ // The crypto itself lives in lib.mjs so it is testable without a network; it throws on every failure.
291
+ return verifyDetachedSignature(rawManifest, sigB64, RELEASE_PUBKEY_PEM);
292
+ }
293
+
294
+ /**
295
+ * Ensure `BIN` is present and (unless suppressed) up to date with the manifest. Returns the version
296
+ * now installed. `force` re-downloads even when the versions match (the explicit `upgrade` command).
297
+ *
298
+ * Failure modes never leave the user worse off: if the manifest can't be fetched but a binary is
299
+ * already cached, we keep running the cached one; only a first install with no network is fatal.
300
+ */
301
+ async function ensureBinary({ noUpgrade, force }) {
302
+ const key = platformKey();
303
+ if (!key) {
304
+ throw new Error(
305
+ `unsupported platform ${process.platform}/${process.arch} — prebuilt gateways cover macOS, Linux and Windows on arm64/x64. https://permaura.com/docs`,
306
+ );
307
+ }
308
+ fs.mkdirSync(path.join(DIR, "bin"), { recursive: true });
309
+
310
+ // Sweep binaries parked by a previous rename-aside upgrade (D9 cleanup-on-start; no-op when no
311
+ // live binary exists — a lone .old may be the only working copy after a failed swap).
312
+ sweepOldBinaries(BIN);
313
+
314
+ const have = installedVersion();
315
+ const haveBinary = fs.existsSync(BIN);
316
+
317
+ let manifest;
318
+ let manifestVerified = false;
319
+ try {
320
+ // Fetch the RAW bytes so the signature covers exactly what we parse (see verifyManifestSignature).
321
+ const rawManifest = await httpGet(MANIFEST_URL);
322
+ manifestVerified = await verifyManifestSignature(rawManifest, `${MANIFEST_URL}.sig`);
323
+ try {
324
+ manifest = JSON.parse(rawManifest.toString("utf8"));
325
+ } catch (e) {
326
+ throw new Error(`bad manifest JSON: ${e.message}`);
327
+ }
328
+ } catch (e) {
329
+ // A signature failure is never "the network was flaky" — it must not fall back to a cached binary
330
+ // and carry on as though nothing happened.
331
+ if (REQUIRE_SIGNATURE && /signature|signed|release key/i.test(e.message)) throw e;
332
+ if (haveBinary && !force) {
333
+ warn(`Couldn't check for updates (${e.message}); using the cached gateway${have ? ` ${have}` : ""}.`);
334
+ return have;
335
+ }
336
+ throw new Error(`could not fetch the release manifest: ${e.message}`);
337
+ }
338
+ if (!REQUIRE_SIGNATURE) {
339
+ warn(
340
+ "This build cannot verify that the gateway binary came from Permaura — no release key is pinned yet. The download is checksummed, but a checksum from an unsigned manifest only proves the file arrived intact, not who made it.",
341
+ );
342
+ }
343
+
344
+ const asset = manifest?.assets?.[key];
345
+ if (!asset?.url) {
346
+ if (haveBinary && !force) {
347
+ warn(`Manifest has no ${key} build; using the cached gateway${have ? ` ${have}` : ""}.`);
348
+ return have;
349
+ }
350
+ throw new Error(`the manifest has no build for ${key}`);
351
+ }
352
+
353
+ const latest = manifest.version;
354
+ const needsUpdate = force || !haveBinary || !have || compareVersions(have, latest) < 0;
355
+ if (!needsUpdate) {
356
+ ok(noUpgrade ? `Using cached gateway ${have} (upgrade check skipped).` : `Gateway ${have} is up to date.`);
357
+ return have;
358
+ }
359
+ if (noUpgrade && haveBinary) {
360
+ warn(`A newer gateway (${latest}) is available; skipping because --no-upgrade.`);
361
+ return have;
362
+ }
363
+
364
+ step(
365
+ haveBinary
366
+ ? `Updating the gateway ${have ?? "?"} → ${latest} (${key})…`
367
+ : `Downloading the Permaura gateway ${latest} (${key})…`,
368
+ );
369
+ const tmp = `${BIN}.download`;
370
+ await download(asset.url, tmp);
371
+
372
+ // Fail closed on a missing checksum. Warning and installing anyway made the one check we DO have
373
+ // optional at the attacker's discretion: strip the sha256 field and the guard politely stands aside.
374
+ if (!asset.sha256) {
375
+ fs.rmSync(tmp, { force: true });
376
+ throw new Error(
377
+ `the manifest has no sha256 for ${key}, so the download cannot be checked. Refusing to install an unverifiable binary.`,
378
+ );
379
+ }
380
+ const got = sha256File(tmp);
381
+ if (got.toLowerCase() !== String(asset.sha256).toLowerCase()) {
382
+ fs.rmSync(tmp, { force: true });
383
+ throw new Error(`checksum mismatch for the downloaded binary (expected ${asset.sha256}, got ${got})`);
384
+ }
385
+
386
+ fs.chmodSync(tmp, 0o755);
387
+ // Rename-aside swap (the rustup pattern, D9): park the current binary at `.old`, move the verified
388
+ // download into place, restore the parked copy if that fails — a runnable binary exists at BIN on
389
+ // every exit path. A gateway already running the old inode keeps working on every platform; the
390
+ // sweep above collects parked files on a later run.
391
+ swapBinaryIntoPlace(BIN, tmp);
392
+ // Quarantine is macOS's own "you didn't make this" flag, so stripping it from an artifact we could
393
+ // not authenticate throws away the last check standing. Once a release key is pinned this only runs
394
+ // for a signature-verified binary; until then it must still run, because the gateway binary is not
395
+ // notarized and Gatekeeper would otherwise refuse to execute it at all — removing the strip today
396
+ // would simply break every macOS install. Un-gating it is part of the signing work, not separate
397
+ // from it (see HANDBACK, § H-04).
398
+ if (process.platform === "darwin" && (manifestVerified || !REQUIRE_SIGNATURE)) {
399
+ try {
400
+ spawnSync("xattr", ["-d", "com.apple.quarantine", BIN], { stdio: "ignore" });
401
+ } catch {
402
+ /* not fatal */
403
+ }
404
+ }
405
+ ok(`Installed gateway ${latest} to ${BIN}`);
406
+ return latest;
407
+ }
408
+
409
+ /**
410
+ * The operator bearer for this install: an explicit PERMAURA_TOKEN, else the one the gateway minted
411
+ * into `operator.token` on clean bootstrap. Returns null when neither exists — a gateway initialised
412
+ * by an older build has a bearer we can't read, and inventing a shared one is exactly the hole this
413
+ * closes. Callers degrade to manual pairing instead.
414
+ */
415
+ function readOperatorToken() {
416
+ if (process.env.PERMAURA_TOKEN) return process.env.PERMAURA_TOKEN;
417
+ try {
418
+ return fs.readFileSync(TOKEN_FILE, "utf8").trim() || null;
419
+ } catch {
420
+ return null;
421
+ }
422
+ }
423
+
424
+ function healthz(addr) {
425
+ return new Promise((resolve) => {
426
+ const req = http.get(`http://${addr}/healthz`, (res) => {
427
+ res.resume();
428
+ resolve(res.statusCode === 200);
429
+ });
430
+ req.on("error", () => resolve(false));
431
+ req.setTimeout(1500, () => {
432
+ req.destroy();
433
+ resolve(false);
434
+ });
435
+ });
436
+ }
437
+
438
+ async function waitHealthz(addr, tries = 20) {
439
+ for (let i = 0; i < tries; i++) {
440
+ if (await healthz(addr)) return true;
441
+ await new Promise((r) => setTimeout(r, 1000));
442
+ }
443
+ return false;
444
+ }
445
+
446
+ /**
447
+ * Best-effort graceful stop: `POST /v1/shutdown` (operator-authed, loopback-only) asks the gateway to
448
+ * drain in-flight requests and exit on its own. Resolves true on 202. Falls through to the caller's
449
+ * hard kill on any failure — e.g. a clean (non-seeded) gateway where the demo token isn't valid.
450
+ */
451
+ function postShutdown(addr, token) {
452
+ return new Promise((resolve) => {
453
+ const [host, port] = addr.split(":");
454
+ const req = http.request(
455
+ {
456
+ method: "POST",
457
+ host,
458
+ port: port || 80,
459
+ path: "/v1/shutdown",
460
+ headers: { Authorization: `Bearer ${token}` },
461
+ timeout: 1500,
462
+ },
463
+ (res) => {
464
+ res.resume();
465
+ resolve(res.statusCode === 202);
466
+ },
467
+ );
468
+ req.on("error", () => resolve(false));
469
+ req.on("timeout", () => {
470
+ req.destroy();
471
+ resolve(false);
472
+ });
473
+ req.end();
474
+ });
475
+ }
476
+
477
+ async function main() {
478
+ const { cmd, forward, noUpgrade, showVersion, showHelp } = parseArgs(process.argv.slice(2));
479
+
480
+ if (showHelp) {
481
+ console.log(USAGE);
482
+ return;
483
+ }
484
+
485
+ if (showVersion) {
486
+ console.log(`permaura (launcher) ${cliVersion()}`);
487
+ const have = installedVersion();
488
+ console.log(`permaura (binary) ${have ?? "(not installed)"}`);
489
+ return;
490
+ }
491
+
492
+ if (!cmd) {
493
+ if (forward.length) {
494
+ // Arguments but no command — a mistyped flag. It must not fall through to the bare-invocation
495
+ // help and exit 0, and it certainly must not start anything.
496
+ console.error(`${C.red("✗")} unknown argument${forward.length > 1 ? "s" : ""}: ${forward.join(", ")}\n`);
497
+ console.error(USAGE);
498
+ process.exitCode = 2;
499
+ return;
500
+ }
501
+ // Bare `permaura`: say what it does, start nothing.
502
+ console.log(USAGE);
503
+ return;
504
+ }
505
+
506
+ if (!KNOWN.has(cmd)) {
507
+ // Exit before anything binds a port or downloads a binary — a mistyped command should cost
508
+ // nothing.
509
+ console.error(`${C.red("✗")} unknown command: ${cmd}\n`);
510
+ console.error(USAGE);
511
+ process.exitCode = 2;
512
+ return;
513
+ }
514
+
515
+ if (cmd === "upgrade") {
516
+ const v = await ensureBinary({ noUpgrade: false, force: true });
517
+ ok(`permaura is at ${v}.`);
518
+ return;
519
+ }
520
+
521
+ await ensureBinary({ noUpgrade, force: false });
522
+
523
+ // Everything that is not `start` is a short-lived command against a gateway: hand it straight to
524
+ // the binary, share this terminal, and exit with whatever it exits with.
525
+ if (cmd !== "start") {
526
+ const child = spawn(BIN, forward, { stdio: "inherit" });
527
+ child.on("exit", (code, signal) => process.exit(signal ? 1 : (code ?? 0)));
528
+ child.on("error", (e) => {
529
+ console.error(`${C.red("✗")} could not run ${BIN}: ${e.message}`);
530
+ process.exit(1);
531
+ });
532
+ return;
533
+ }
534
+
535
+ step(`Starting gateway at http://${ADDR}…`);
536
+ // PERMAURA_ADDR is the launcher's default bind; an explicit `--addr` in `forward` still wins,
537
+ // because clap ranks a flag above its env fallback.
538
+ const child = spawn(BIN, forward, {
539
+ env: { ...process.env, PERMAURA_ADDR: ADDR },
540
+ stdio: ["ignore", "pipe", "pipe"],
541
+ });
542
+ child.stdout.on("data", (d) => process.stdout.write(C.dim(d.toString())));
543
+ child.stderr.on("data", (d) => process.stderr.write(C.dim(d.toString())));
544
+ child.on("exit", (code) => {
545
+ console.log(`gateway exited (${code ?? 0})`);
546
+ process.exit(code ?? 0);
547
+ });
548
+
549
+ if (!(await waitHealthz(ADDR))) {
550
+ console.error(`${C.red("✗")} the gateway did not become healthy at http://${ADDR}`);
551
+ child.kill();
552
+ process.exit(1);
553
+ }
554
+ ok(`Gateway running at http://${ADDR}`);
555
+
556
+ const token = readOperatorToken();
557
+ const pairing = forward.some((a) => a === "--pair" || a.startsWith("--pair="));
558
+ if (!token) {
559
+ // The gateway is up and healthy — only adding it to an account needs the bearer. Say so plainly
560
+ // rather than guessing at a token.
561
+ warn(
562
+ `This gateway was initialised by an earlier build, so its operator token isn't readable here.\n Re-pair from Console → Settings → Gateways, or re-run with PERMAURA_TOKEN=<token> set.`,
563
+ );
564
+ } else if (!pairing) {
565
+ // With --pair the binary reports the enrolment itself, success or failure, and prints these two
566
+ // lines when it fails. Printing them here as well would be the same instruction twice.
567
+ console.log(
568
+ `\nAdd it in the console → Settings → Gateways:\n URL: http://${ADDR}\n Token: ${token}`,
569
+ );
570
+ }
571
+
572
+ console.log(C.dim("\nGateway is running. Press Ctrl-C to stop."));
573
+ process.on("SIGINT", async () => {
574
+ // Graceful first: /v1/shutdown lets the gateway drain and exit cleanly. This matters most on
575
+ // Windows, where child.kill() is an abrupt TerminateProcess; on Unix the foreground Ctrl-C
576
+ // already reached the child too, so this is belt-and-braces. The child's exit handler ends the
577
+ // process; the unref'd timer hard-kills if draining wedges. Without a readable token the
578
+ // authenticated drain isn't available, so fall straight through to the signal.
579
+ const accepted = token ? await postShutdown(ADDR, token) : false;
580
+ if (!accepted) child.kill("SIGINT");
581
+ setTimeout(() => {
582
+ child.kill();
583
+ process.exit(0);
584
+ }, 5000).unref();
585
+ });
586
+ }
587
+
588
+ main().catch((e) => {
589
+ console.error(C.red("✗"), e.message);
590
+ process.exit(1);
591
+ });
package/bin/lib.mjs ADDED
@@ -0,0 +1,187 @@
1
+ // Helpers shared by cli.mjs and the test suite. The naming/version helpers are pure; the
2
+ // swap/sweep helpers take an injectable fs so their failure paths are table-testable under
3
+ // `node --test` (a rename that fails mid-swap can't be provoked with the real fs).
4
+
5
+ import crypto from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ /**
10
+ * The manifest asset key for a platform/arch pair: darwin|linux|win32 × arm64|amd64.
11
+ * Node reports x64 where the manifest says amd64 (the release matrix keeps the Go-style
12
+ * `amd64` convention). Returns null for anything we don't ship.
13
+ */
14
+ export function platformKey(platform = process.platform, arch = process.arch) {
15
+ const os = { darwin: "darwin", linux: "linux", win32: "win32" }[platform];
16
+ const cpu = { arm64: "arm64", x64: "amd64" }[arch];
17
+ return os && cpu ? `${os}-${cpu}` : null;
18
+ }
19
+
20
+ /** The installed binary's filename for a manifest key: `.exe` on Windows, bare elsewhere. */
21
+ export function binaryFilename(key) {
22
+ return key && key.startsWith("win32-") ? "permaura.exe" : "permaura";
23
+ }
24
+
25
+ /**
26
+ * The release asset name for a manifest key (what CI stages and the manifest URLs point at):
27
+ * `permaura-<key>`, plus `.exe` for Windows so the download is double-click-safe.
28
+ */
29
+ export function assetName(key) {
30
+ const suffix = key && key.startsWith("win32-") ? ".exe" : "";
31
+ return `permaura-${key}${suffix}`;
32
+ }
33
+
34
+ /**
35
+ * Sweep binaries parked by a previous rename-aside upgrade (D9 cleanup-on-start). Only sweeps when
36
+ * a live binary exists at `binPath`: after a failed swap the newest `.old` may be the only working
37
+ * copy on disk, and it must survive until a later successful swap replaces it. Best-effort — on
38
+ * Windows a still-running old gateway keeps its file locked and is collected on a later run.
39
+ */
40
+ export function sweepOldBinaries(binPath, fsOps = fs) {
41
+ if (!fsOps.existsSync(binPath)) return;
42
+ const dir = path.dirname(binPath);
43
+ const base = path.basename(binPath);
44
+ for (const f of fsOps.readdirSync(dir)) {
45
+ if (f.startsWith(`${base}.old`)) {
46
+ try {
47
+ fsOps.rmSync(path.join(dir, f), { force: true });
48
+ } catch {
49
+ /* locked by a running process — leave it for next time */
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Rename-aside swap (D9, the rustup pattern): park the current binary at `.old` (Windows can't
57
+ * overwrite a running executable but CAN rename it), then move the verified download into place.
58
+ * If that final rename fails, the parked binary is restored — there is a runnable binary at
59
+ * `binPath` on every exit path, success or throw.
60
+ */
61
+ export function swapBinaryIntoPlace(binPath, tmpPath, fsOps = fs) {
62
+ const oldPath = `${binPath}.old`;
63
+ let parkedAt = null;
64
+ if (fsOps.existsSync(binPath)) {
65
+ try {
66
+ fsOps.rmSync(oldPath, { force: true });
67
+ fsOps.renameSync(binPath, oldPath);
68
+ parkedAt = oldPath;
69
+ } catch {
70
+ // `.old` itself is a locked running binary: park under a unique name instead.
71
+ const unique = `${binPath}.old-${Date.now()}`;
72
+ fsOps.renameSync(binPath, unique);
73
+ parkedAt = unique;
74
+ }
75
+ }
76
+ try {
77
+ fsOps.renameSync(tmpPath, binPath);
78
+ } catch (e) {
79
+ if (parkedAt) {
80
+ try {
81
+ fsOps.renameSync(parkedAt, binPath);
82
+ } catch {
83
+ /* restore failed — surface the original swap error below */
84
+ }
85
+ }
86
+ throw e;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Compare versions by semver precedence. `<0` if a<b, `0` equal, `>0` if a>b.
92
+ *
93
+ * The pre-release suffix is part of the order, not noise to be discarded. It used to be dropped, so
94
+ * `1.2.5-dev.1` compared EQUAL to the released `1.2.5` and the upgrade was never offered — a
95
+ * dev build of a version permanently masked that version's real release, on exactly the machines
96
+ * most likely to be running one.
97
+ */
98
+ export function compareVersions(a, b) {
99
+ const split = (v) => {
100
+ const s = String(v ?? "0").replace(/^v/, "");
101
+ const i = s.indexOf("-");
102
+ return i === -1 ? [s, ""] : [s.slice(0, i), s.slice(i + 1)];
103
+ };
104
+ const [coreA, preA] = split(a);
105
+ const [coreB, preB] = split(b);
106
+ const nums = (c) => c.split(".").map((n) => parseInt(n, 10) || 0);
107
+ const pa = nums(coreA);
108
+ const pb = nums(coreB);
109
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
110
+ const d = (pa[i] || 0) - (pb[i] || 0);
111
+ if (d !== 0) return d < 0 ? -1 : 1;
112
+ }
113
+ return comparePreRelease(preA, preB);
114
+ }
115
+
116
+ /**
117
+ * Semver §11.4 precedence for the pre-release part, over equal numeric cores.
118
+ *
119
+ * A version WITH a pre-release ranks below the same version without one; between two of them,
120
+ * identifiers are compared dot-by-dot, numerically when both are numeric, and a numeric identifier
121
+ * ranks below an alphanumeric one. A shorter run of identifiers loses when it is a prefix of the
122
+ * longer, which is what makes `1.0.0-rc` older than `1.0.0-rc.1`.
123
+ */
124
+ function comparePreRelease(a, b) {
125
+ if (a === b) return 0;
126
+ if (!a) return 1;
127
+ if (!b) return -1;
128
+ const ia = a.split(".");
129
+ const ib = b.split(".");
130
+ for (let i = 0; i < Math.max(ia.length, ib.length); i++) {
131
+ const x = ia[i];
132
+ const y = ib[i];
133
+ if (x === undefined) return -1;
134
+ if (y === undefined) return 1;
135
+ const xNum = /^\d+$/.test(x);
136
+ const yNum = /^\d+$/.test(y);
137
+ if (xNum && yNum) {
138
+ const d = parseInt(x, 10) - parseInt(y, 10);
139
+ if (d !== 0) return d < 0 ? -1 : 1;
140
+ } else if (xNum !== yNum) {
141
+ return xNum ? -1 : 1;
142
+ } else if (x !== y) {
143
+ return x < y ? -1 : 1;
144
+ }
145
+ }
146
+ return 0;
147
+ }
148
+
149
+ /**
150
+ * Verify a detached Ed25519 signature over the EXACT bytes of a release manifest.
151
+ *
152
+ * Detached-over-raw-bytes on purpose: signing a field *inside* the JSON would mean canonicalising the
153
+ * document first, and canonicalisation bugs are signature bypasses. The bytes verified here are the
154
+ * bytes the caller parses.
155
+ *
156
+ * Throws on every failure rather than returning false, so a caller cannot accidentally treat an
157
+ * unverified manifest as verified by ignoring a return value.
158
+ */
159
+ export function verifyDetachedSignature(rawBytes, sigB64, publicKeyPem) {
160
+ let key;
161
+ try {
162
+ key = crypto.createPublicKey(publicKeyPem);
163
+ } catch (e) {
164
+ throw new Error(`the pinned release key is not a valid public key: ${e.message}`);
165
+ }
166
+ if (key.asymmetricKeyType !== "ed25519") {
167
+ // Guards against a pinned key of the wrong type quietly selecting a different algorithm.
168
+ throw new Error(`the pinned release key must be ed25519, got ${key.asymmetricKeyType}`);
169
+ }
170
+ let sig;
171
+ try {
172
+ sig = Buffer.from(String(sigB64).trim(), "base64");
173
+ } catch {
174
+ throw new Error("the manifest signature is not valid base64");
175
+ }
176
+ // Ed25519 signatures are exactly 64 bytes; a wrong length is malformed, not merely invalid.
177
+ if (sig.length !== 64) {
178
+ throw new Error(`the manifest signature is malformed (expected 64 bytes, got ${sig.length})`);
179
+ }
180
+ // Ed25519 verifies in one shot: algorithm null, no pre-hashing.
181
+ if (!crypto.verify(null, Buffer.from(rawBytes), key, sig)) {
182
+ throw new Error(
183
+ "the release manifest's signature did not verify against the pinned Permaura release key",
184
+ );
185
+ }
186
+ return true;
187
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "permaura",
3
+ "version": "1.3.1",
4
+ "description": "Run the Permaura gateway with one command. Give your AI agents permission, not keys. `npx -y permaura start --pair <code>` installs the gateway, starts it, and connects it to your permaura.com account.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "bin": {
8
+ "permaura": "bin/cli.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "homepage": "https://permaura.com",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/pixelitemedia/permaura-dist.git"
21
+ },
22
+ "keywords": [
23
+ "permaura",
24
+ "ai",
25
+ "agents",
26
+ "mcp",
27
+ "gateway",
28
+ "security",
29
+ "capabilities",
30
+ "permissions"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "test": "node --test"
37
+ }
38
+ }