nylorun 0.0.0-bootstrap → 0.1.0-beta

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +192 -0
  3. package/README.md +98 -0
  4. package/dist/baseline.d.ts +2 -0
  5. package/dist/baseline.js +10 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +78 -0
  8. package/dist/doctor.d.ts +23 -0
  9. package/dist/doctor.js +76 -0
  10. package/dist/errors.d.ts +5 -0
  11. package/dist/errors.js +9 -0
  12. package/dist/home.d.ts +6 -0
  13. package/dist/home.js +15 -0
  14. package/dist/project/link.d.ts +6 -0
  15. package/dist/project/link.js +21 -0
  16. package/dist/project/root.d.ts +7 -0
  17. package/dist/project/root.js +44 -0
  18. package/dist/stack/commands.d.ts +103 -0
  19. package/dist/stack/commands.js +478 -0
  20. package/dist/stack/compose-file.d.ts +14 -0
  21. package/dist/stack/compose-file.js +128 -0
  22. package/dist/stack/docker.d.ts +38 -0
  23. package/dist/stack/docker.js +101 -0
  24. package/dist/stack/env-file.d.ts +32 -0
  25. package/dist/stack/env-file.js +93 -0
  26. package/dist/stack/host-files.d.ts +36 -0
  27. package/dist/stack/host-files.js +93 -0
  28. package/dist/stack/images.d.ts +22 -0
  29. package/dist/stack/images.js +28 -0
  30. package/dist/stack/index.d.ts +13 -0
  31. package/dist/stack/index.js +75 -0
  32. package/dist/stack/paths.d.ts +20 -0
  33. package/dist/stack/paths.js +18 -0
  34. package/dist/stack/ports.d.ts +18 -0
  35. package/dist/stack/ports.js +45 -0
  36. package/dist/stack/prepare.d.ts +28 -0
  37. package/dist/stack/prepare.js +65 -0
  38. package/dist/stack/restate-identity.d.ts +28 -0
  39. package/dist/stack/restate-identity.js +92 -0
  40. package/dist/stack/studio-login.d.ts +14 -0
  41. package/dist/stack/studio-login.js +37 -0
  42. package/dist/stack/versions.d.ts +9 -0
  43. package/dist/stack/versions.js +28 -0
  44. package/package.json +43 -3
@@ -0,0 +1,20 @@
1
+ /** Files under the Host root that the stack commands read and write. */
2
+ export interface StackPaths {
3
+ /** The Host root (`~/.nylorun` or `NYLORUN_HOME`), bind-mounted at /nylorun. */
4
+ root: string;
5
+ /** host.json: client-facing host and published port, no secrets. */
6
+ config: string;
7
+ /** host-credentials.json: the admin key, mode 0600. */
8
+ credentials: string;
9
+ /** host-state.json: written only by a launcher-managed Runtime. */
10
+ state: string;
11
+ tenants: string;
12
+ home: string;
13
+ tmp: string;
14
+ stack: string;
15
+ compose: string;
16
+ env: string;
17
+ /** Restate's request-identity private key (Ed25519 PKCS#8 PEM), mode 0600. */
18
+ restateIdentity: string;
19
+ }
20
+ export declare function stackPaths(hostRoot: string): StackPaths;
@@ -0,0 +1,18 @@
1
+ import { join, resolve } from "node:path";
2
+ export function stackPaths(hostRoot) {
3
+ const root = resolve(hostRoot);
4
+ const stack = join(root, "stack");
5
+ return {
6
+ root,
7
+ config: join(root, "host.json"),
8
+ credentials: join(root, "host-credentials.json"),
9
+ state: join(root, "host-state.json"),
10
+ tenants: join(root, "tenants"),
11
+ home: join(root, "home"),
12
+ tmp: join(root, "tmp"),
13
+ stack,
14
+ compose: join(stack, "compose.yaml"),
15
+ env: join(stack, ".env"),
16
+ restateIdentity: join(stack, "restate-identity.pem"),
17
+ };
18
+ }
@@ -0,0 +1,18 @@
1
+ export declare const DEFAULT_PORTS: {
2
+ readonly runtime: 8787;
3
+ readonly studio: 4161;
4
+ readonly restate: 9070;
5
+ };
6
+ /** How the stack finds ports on 127.0.0.1; injectable for tests. */
7
+ export interface PortProbe {
8
+ isFree(port: number): Promise<boolean>;
9
+ /** A port the OS reports free right now. */
10
+ pickFree(): Promise<number>;
11
+ }
12
+ export declare const loopbackPorts: PortProbe;
13
+ /**
14
+ * Choose a port: the persisted one if any (kept even when busy, because the
15
+ * running stack may hold it), else the default when free, else a free one.
16
+ * Never returns a port in `taken`.
17
+ */
18
+ export declare function choosePort(probe: PortProbe, preferred: number, persisted: number | undefined, taken: ReadonlySet<number>): Promise<number>;
@@ -0,0 +1,45 @@
1
+ import { createServer } from "node:net";
2
+ export const DEFAULT_PORTS = {
3
+ runtime: 8787,
4
+ studio: 4161,
5
+ restate: 9070,
6
+ };
7
+ function listenOnce(port) {
8
+ const server = createServer();
9
+ return new Promise((resolve) => {
10
+ server.once("error", () => resolve(undefined));
11
+ server.listen({ port, host: "127.0.0.1", exclusive: true }, () => {
12
+ const address = server.address();
13
+ const bound = typeof address === "object" && address ? address.port : undefined;
14
+ server.close(() => resolve(bound));
15
+ });
16
+ });
17
+ }
18
+ export const loopbackPorts = {
19
+ async isFree(port) {
20
+ return (await listenOnce(port)) !== undefined;
21
+ },
22
+ async pickFree() {
23
+ const port = await listenOnce(0);
24
+ if (port === undefined)
25
+ throw new Error("Could not allocate a free loopback port.");
26
+ return port;
27
+ },
28
+ };
29
+ /**
30
+ * Choose a port: the persisted one if any (kept even when busy, because the
31
+ * running stack may hold it), else the default when free, else a free one.
32
+ * Never returns a port in `taken`.
33
+ */
34
+ export async function choosePort(probe, preferred, persisted, taken) {
35
+ if (persisted !== undefined && !taken.has(persisted))
36
+ return persisted;
37
+ if (!taken.has(preferred) && (await probe.isFree(preferred)))
38
+ return preferred;
39
+ for (let attempt = 0; attempt < 20; attempt += 1) {
40
+ const port = await probe.pickFree();
41
+ if (!taken.has(port))
42
+ return port;
43
+ }
44
+ throw new Error("Could not allocate a free loopback port.");
45
+ }
@@ -0,0 +1,28 @@
1
+ import { type StackEnv } from "./env-file.js";
2
+ import { type HostConfigFile } from "./host-files.js";
3
+ import type { StackImages } from "./images.js";
4
+ import type { StackPaths } from "./paths.js";
5
+ import { type PortProbe } from "./ports.js";
6
+ export interface PreparedStack {
7
+ env: StackEnv;
8
+ host: HostConfigFile;
9
+ adminKey: string;
10
+ /** True when this run chose the ports (first start). */
11
+ firstRun: boolean;
12
+ }
13
+ /** Read the persisted stack settings without writing anything. */
14
+ export declare function readStackEnv(paths: StackPaths): Promise<import("./env-file.js").PersistedStackEnv | undefined>;
15
+ /**
16
+ * Write everything `docker compose up` needs under the Host root:
17
+ * host.json, host-credentials.json (0600), stack/compose.yaml,
18
+ * stack/.env (0600) and stack/restate-identity.pem (0600). Ports and the
19
+ * Postgres password persist in .env; the identity key persists in its PEM.
20
+ */
21
+ export declare function prepareStack(input: {
22
+ paths: StackPaths;
23
+ images: StackImages;
24
+ uid: number;
25
+ gid: number;
26
+ runtimeVersion: string;
27
+ ports: PortProbe;
28
+ }): Promise<PreparedStack>;
@@ -0,0 +1,65 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmod, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { renderComposeFile } from "./compose-file.js";
4
+ import { parsePersisted, renderEnvFile } from "./env-file.js";
5
+ import { ensureHostCredentials, ensureHostLayout, writeStackHostConfig, } from "./host-files.js";
6
+ import { choosePort, DEFAULT_PORTS } from "./ports.js";
7
+ import { ensureIdentityKey } from "./restate-identity.js";
8
+ async function writeFileMode(path, text, mode) {
9
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
10
+ await writeFile(temporary, text, { mode });
11
+ await rename(temporary, path);
12
+ await chmod(path, mode);
13
+ }
14
+ async function readText(path) {
15
+ try {
16
+ return await readFile(path, "utf8");
17
+ }
18
+ catch {
19
+ return undefined;
20
+ }
21
+ }
22
+ /** Read the persisted stack settings without writing anything. */
23
+ export async function readStackEnv(paths) {
24
+ const text = await readText(paths.env);
25
+ return text === undefined ? undefined : parsePersisted(text);
26
+ }
27
+ /**
28
+ * Write everything `docker compose up` needs under the Host root:
29
+ * host.json, host-credentials.json (0600), stack/compose.yaml,
30
+ * stack/.env (0600) and stack/restate-identity.pem (0600). Ports and the
31
+ * Postgres password persist in .env; the identity key persists in its PEM.
32
+ */
33
+ export async function prepareStack(input) {
34
+ const { paths } = input;
35
+ await ensureHostLayout(paths);
36
+ const persisted = (await readStackEnv(paths)) ?? {};
37
+ const firstRun = persisted.runtimePort === undefined;
38
+ const taken = new Set();
39
+ const runtimePort = await choosePort(input.ports, DEFAULT_PORTS.runtime, persisted.runtimePort, taken);
40
+ taken.add(runtimePort);
41
+ const studioPort = await choosePort(input.ports, DEFAULT_PORTS.studio, persisted.studioPort, taken);
42
+ taken.add(studioPort);
43
+ const restatePort = await choosePort(input.ports, DEFAULT_PORTS.restate, persisted.restatePort, taken);
44
+ const identity = await ensureIdentityKey(paths.restateIdentity, writeFileMode);
45
+ const env = {
46
+ runtimePort,
47
+ studioPort,
48
+ restatePort,
49
+ postgresPassword: persisted.postgresPassword ?? randomBytes(24).toString("hex"),
50
+ restateIdentityKey: identity.publicKey,
51
+ uid: input.uid,
52
+ gid: input.gid,
53
+ hostRoot: paths.root,
54
+ runtimeImage: input.images.runtime,
55
+ studioImage: input.images.studio,
56
+ };
57
+ const { adminKey } = await ensureHostCredentials(paths);
58
+ const host = await writeStackHostConfig(paths, {
59
+ port: runtimePort,
60
+ runtimeVersion: input.runtimeVersion,
61
+ });
62
+ await writeFileMode(paths.env, renderEnvFile(env), 0o600);
63
+ await writeFileMode(paths.compose, renderComposeFile(), 0o644);
64
+ return { env, host, adminKey, firstRun };
65
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Restate request identity (Runtime Architecture §9.1, §12.3). Restate signs
3
+ * every request to the Worker endpoint with an Ed25519 private key; the
4
+ * Runtime accepts only requests that verify against the public key.
5
+ *
6
+ * - The private key is a PKCS#8 PEM at `stack/restate-identity.pem` (mode
7
+ * 0600), mounted read-only into the restate service as
8
+ * `RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE`.
9
+ * - The public key goes to the Runtime as `NYLORUN_RESTATE_IDENTITY_KEY`, in
10
+ * Restate's format: `publickeyv1_` followed by the base58btc encoding of
11
+ * the raw 32-byte public key. Restate logs the same string as the key id
12
+ * (`kid`) when it loads the PEM.
13
+ */
14
+ export declare const IDENTITY_KEY_PREFIX = "publickeyv1_";
15
+ /** Base58 with the Bitcoin alphabet; each leading zero byte becomes "1". */
16
+ export declare function base58btc(bytes: Uint8Array): string;
17
+ /** The `publickeyv1_...` string for an Ed25519 private key PEM. */
18
+ export declare function identityPublicKey(privateKeyPem: string): string;
19
+ export declare function generateIdentityPem(): string;
20
+ /**
21
+ * Read the identity PEM at `path`, creating it (mode 0600) on first start, and
22
+ * return its public key. An existing key is kept, so Restate's signatures stay
23
+ * valid across starts.
24
+ */
25
+ export declare function ensureIdentityKey(path: string, write: (path: string, text: string, mode: number) => Promise<void>): Promise<{
26
+ publicKey: string;
27
+ created: boolean;
28
+ }>;
@@ -0,0 +1,92 @@
1
+ import { createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto";
2
+ import { chmod, readFile } from "node:fs/promises";
3
+ import { CliError } from "../errors.js";
4
+ /**
5
+ * Restate request identity (Runtime Architecture §9.1, §12.3). Restate signs
6
+ * every request to the Worker endpoint with an Ed25519 private key; the
7
+ * Runtime accepts only requests that verify against the public key.
8
+ *
9
+ * - The private key is a PKCS#8 PEM at `stack/restate-identity.pem` (mode
10
+ * 0600), mounted read-only into the restate service as
11
+ * `RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE`.
12
+ * - The public key goes to the Runtime as `NYLORUN_RESTATE_IDENTITY_KEY`, in
13
+ * Restate's format: `publickeyv1_` followed by the base58btc encoding of
14
+ * the raw 32-byte public key. Restate logs the same string as the key id
15
+ * (`kid`) when it loads the PEM.
16
+ */
17
+ export const IDENTITY_KEY_PREFIX = "publickeyv1_";
18
+ const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
19
+ /** Base58 with the Bitcoin alphabet; each leading zero byte becomes "1". */
20
+ export function base58btc(bytes) {
21
+ let zeros = 0;
22
+ while (zeros < bytes.length && bytes[zeros] === 0)
23
+ zeros += 1;
24
+ // Little-endian base-58 digits of the big-endian number after the zeros.
25
+ const digits = [];
26
+ for (let index = zeros; index < bytes.length; index += 1) {
27
+ let carry = bytes[index];
28
+ for (let digit = 0; digit < digits.length; digit += 1) {
29
+ carry += digits[digit] * 256;
30
+ digits[digit] = carry % 58;
31
+ carry = Math.floor(carry / 58);
32
+ }
33
+ while (carry > 0) {
34
+ digits.push(carry % 58);
35
+ carry = Math.floor(carry / 58);
36
+ }
37
+ }
38
+ let out = "1".repeat(zeros);
39
+ for (let digit = digits.length - 1; digit >= 0; digit -= 1)
40
+ out += BASE58_ALPHABET[digits[digit]];
41
+ return out;
42
+ }
43
+ /** The `publickeyv1_...` string for an Ed25519 private key PEM. */
44
+ export function identityPublicKey(privateKeyPem) {
45
+ let jwk;
46
+ try {
47
+ const key = createPrivateKey(privateKeyPem);
48
+ if (key.asymmetricKeyType !== "ed25519")
49
+ throw new Error("not an Ed25519 key");
50
+ jwk = createPublicKey(key).export({ format: "jwk" });
51
+ }
52
+ catch (error) {
53
+ throw new CliError(`The Restate identity key is not an Ed25519 PKCS#8 PEM (${error instanceof Error ? error.message : String(error)})`, 1);
54
+ }
55
+ const raw = Buffer.from(jwk.x ?? "", "base64url");
56
+ if (raw.length !== 32)
57
+ throw new CliError("The Restate identity public key is not 32 bytes", 1);
58
+ return `${IDENTITY_KEY_PREFIX}${base58btc(raw)}`;
59
+ }
60
+ export function generateIdentityPem() {
61
+ const { privateKey } = generateKeyPairSync("ed25519");
62
+ return privateKey.export({ format: "pem", type: "pkcs8" }).toString();
63
+ }
64
+ /**
65
+ * Read the identity PEM at `path`, creating it (mode 0600) on first start, and
66
+ * return its public key. An existing key is kept, so Restate's signatures stay
67
+ * valid across starts.
68
+ */
69
+ export async function ensureIdentityKey(path, write) {
70
+ let pem;
71
+ try {
72
+ pem = await readFile(path, "utf8");
73
+ }
74
+ catch (error) {
75
+ if (error.code !== "ENOENT")
76
+ throw error;
77
+ }
78
+ if (pem !== undefined) {
79
+ let publicKey;
80
+ try {
81
+ publicKey = identityPublicKey(pem);
82
+ }
83
+ catch (error) {
84
+ throw new CliError(`${path}: ${error.message}. Delete it and run "nylorun start" to create a new one.`, 1);
85
+ }
86
+ await chmod(path, 0o600);
87
+ return { publicKey, created: false };
88
+ }
89
+ pem = generateIdentityPem();
90
+ await write(path, pem, 0o600);
91
+ return { publicKey: identityPublicKey(pem), created: true };
92
+ }
@@ -0,0 +1,14 @@
1
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
2
+ /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
3
+ export declare function studioOrigin(port: number): string;
4
+ /**
5
+ * Ask the Studio container for a single-use login token with the admin key
6
+ * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
7
+ * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
8
+ */
9
+ export declare function mintStudioLogin(input: {
10
+ fetch: FetchLike;
11
+ studioPort: number;
12
+ adminKey: string;
13
+ timeoutMs?: number;
14
+ }): Promise<string>;
@@ -0,0 +1,37 @@
1
+ import { STACK_CLIENT_HOST } from "./host-files.js";
2
+ /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
3
+ export function studioOrigin(port) {
4
+ return `http://${STACK_CLIENT_HOST}:${port}`;
5
+ }
6
+ /**
7
+ * Ask the Studio container for a single-use login token with the admin key
8
+ * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
9
+ * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
10
+ */
11
+ export async function mintStudioLogin(input) {
12
+ const origin = studioOrigin(input.studioPort);
13
+ const response = await input.fetch(`${origin}/_studio/login-tokens`, {
14
+ method: "POST",
15
+ headers: {
16
+ authorization: `Bearer ${input.adminKey}`,
17
+ "content-type": "application/json",
18
+ accept: "application/json",
19
+ },
20
+ body: "{}",
21
+ redirect: "error",
22
+ signal: AbortSignal.timeout(input.timeoutMs ?? 5000),
23
+ });
24
+ if (!response.ok)
25
+ throw new Error(`Studio refused a login token (HTTP ${response.status}).`);
26
+ const body = (await response.json());
27
+ for (const field of ["loginUrl", "url"]) {
28
+ const value = body[field];
29
+ if (typeof value === "string" && value !== "") {
30
+ // A relative URL is resolved against the published origin.
31
+ return new URL(value, origin).toString();
32
+ }
33
+ }
34
+ if (typeof body.token === "string" && body.token !== "")
35
+ return `${origin}/login?token=${encodeURIComponent(body.token)}`;
36
+ throw new Error("Studio answered without a login token.");
37
+ }
@@ -0,0 +1,9 @@
1
+ /** An image this CLI release pins in `cli/package.json` `nylorun` (D7). */
2
+ export type PinnedImage = "runtime" | "studio";
3
+ /**
4
+ * The version this CLI release pins for an image, from `cli/package.json`
5
+ * `nylorun.runtime` or `nylorun.studio`: the tag of `ghcr.io/nylorun/<name>`
6
+ * the stack runs unless `NYLORUN_RUNTIME_IMAGE` / `NYLORUN_STUDIO_IMAGE`
7
+ * overrides it. The release tooling writes both pins.
8
+ */
9
+ export declare function pinnedVersion(name: PinnedImage): string;
@@ -0,0 +1,28 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { CliError } from "../errors.js";
5
+ function cliPackageJsonPath() {
6
+ // src/stack/*.ts and dist/stack/*.js both sit two levels below cli/.
7
+ return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
8
+ }
9
+ /**
10
+ * The version this CLI release pins for an image, from `cli/package.json`
11
+ * `nylorun.runtime` or `nylorun.studio`: the tag of `ghcr.io/nylorun/<name>`
12
+ * the stack runs unless `NYLORUN_RUNTIME_IMAGE` / `NYLORUN_STUDIO_IMAGE`
13
+ * overrides it. The release tooling writes both pins.
14
+ */
15
+ export function pinnedVersion(name) {
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(readFileSync(cliPackageJsonPath(), "utf8"));
19
+ }
20
+ catch (error) {
21
+ throw new CliError(`Cannot read CLI package.json: ${error instanceof Error ? error.message : String(error)}`, 1);
22
+ }
23
+ const version = raw?.nylorun?.[name];
24
+ if (typeof version !== "string" || version.trim() === "") {
25
+ throw new CliError(`cli/package.json is missing "nylorun.${name}" (the pinned ${name} image version).`, 1);
26
+ }
27
+ return version;
28
+ }
package/package.json CHANGED
@@ -1,7 +1,47 @@
1
1
  {
2
2
  "name": "nylorun",
3
- "version": "0.0.0-bootstrap",
4
- "description": "Set up and run the local Nylorun stack. Placeholder; the first release is coming.",
3
+ "version": "0.1.0-beta",
4
+ "type": "module",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/nylorun/harness.git", "directory": "nylorun" }
6
+ "description": "Set up and run the local Nylorun stack (Runtime and Studio) with Docker Compose",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/nylorun/harness.git",
10
+ "directory": "nylorun"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE",
16
+ "CHANGELOG.md"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "provenance": true
21
+ },
22
+ "engines": {
23
+ "node": ">=24.0.0"
24
+ },
25
+ "scripts": {
26
+ "build": "npm run clean && node ../scripts/check-typescript-version.mjs && tsc -p tsconfig.json",
27
+ "clean": "node -e \"import('node:fs').then(({rmSync})=>rmSync('dist',{recursive:true,force:true}))\"",
28
+ "check": "npm run build && npm test && node scripts/check-package.mjs",
29
+ "test": "vitest run --passWithNoTests",
30
+ "prepack": "npm run build"
31
+ },
32
+ "dependencies": {
33
+ "@nylorun/core": "0.6.0-beta"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
37
+ "@types/node": "^26.5.0",
38
+ "vitest": "^5.0.1"
39
+ },
40
+ "bin": {
41
+ "nylorun": "dist/cli.js"
42
+ },
43
+ "nylorun": {
44
+ "runtime": "0.11.0-beta",
45
+ "studio": "0.10.0-beta"
46
+ }
7
47
  }