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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +192 -0
- package/README.md +98 -0
- package/dist/baseline.d.ts +2 -0
- package/dist/baseline.js +10 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +78 -0
- package/dist/doctor.d.ts +23 -0
- package/dist/doctor.js +76 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/home.d.ts +6 -0
- package/dist/home.js +15 -0
- package/dist/project/link.d.ts +6 -0
- package/dist/project/link.js +21 -0
- package/dist/project/root.d.ts +7 -0
- package/dist/project/root.js +44 -0
- package/dist/stack/commands.d.ts +103 -0
- package/dist/stack/commands.js +478 -0
- package/dist/stack/compose-file.d.ts +14 -0
- package/dist/stack/compose-file.js +128 -0
- package/dist/stack/docker.d.ts +38 -0
- package/dist/stack/docker.js +101 -0
- package/dist/stack/env-file.d.ts +32 -0
- package/dist/stack/env-file.js +93 -0
- package/dist/stack/host-files.d.ts +36 -0
- package/dist/stack/host-files.js +93 -0
- package/dist/stack/images.d.ts +22 -0
- package/dist/stack/images.js +28 -0
- package/dist/stack/index.d.ts +13 -0
- package/dist/stack/index.js +75 -0
- package/dist/stack/paths.d.ts +20 -0
- package/dist/stack/paths.js +18 -0
- package/dist/stack/ports.d.ts +18 -0
- package/dist/stack/ports.js +45 -0
- package/dist/stack/prepare.d.ts +28 -0
- package/dist/stack/prepare.js +65 -0
- package/dist/stack/restate-identity.d.ts +28 -0
- package/dist/stack/restate-identity.js +92 -0
- package/dist/stack/studio-login.d.ts +14 -0
- package/dist/stack/studio-login.js +37 -0
- package/dist/stack/versions.d.ts +9 -0
- package/dist/stack/versions.js +28 -0
- package/package.json +43 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { PINNED_IMAGES } from "./images.js";
|
|
2
|
+
/**
|
|
3
|
+
* The local stack's Compose file (Runtime Architecture §14.3), written to
|
|
4
|
+
* `<Host root>/stack/compose.yaml` by `nylorun start`. It is the same for every
|
|
5
|
+
* machine: everything that varies (ports, the Postgres password, UID/GID, the
|
|
6
|
+
* Host root, the Runtime and Studio images) comes from `stack/.env`.
|
|
7
|
+
*
|
|
8
|
+
* Tenants are Postgres schemas, executed through Restate, with their history in
|
|
9
|
+
* s2-lite; the Runtime's /ready checks all three.
|
|
10
|
+
*
|
|
11
|
+
* Restate signs requests to the Worker endpoint with the private key in
|
|
12
|
+
* `stack/restate-identity.pem`, mounted read-only; the Runtime gets the public
|
|
13
|
+
* key as NYLORUN_RESTATE_IDENTITY_KEY.
|
|
14
|
+
*/
|
|
15
|
+
export function renderComposeFile() {
|
|
16
|
+
return `# Written by \`nylorun start\`; rewritten on every start. Settings live in .env.
|
|
17
|
+
name: nylorun
|
|
18
|
+
|
|
19
|
+
services:
|
|
20
|
+
postgres: # Session Store
|
|
21
|
+
image: ${PINNED_IMAGES.postgres}
|
|
22
|
+
environment:
|
|
23
|
+
POSTGRES_USER: nylorun
|
|
24
|
+
POSTGRES_PASSWORD: \${NYLORUN_POSTGRES_PASSWORD:?run nylorun start}
|
|
25
|
+
POSTGRES_DB: nylorun
|
|
26
|
+
volumes:
|
|
27
|
+
- postgres:/var/lib/postgresql/data
|
|
28
|
+
healthcheck:
|
|
29
|
+
test: ["CMD-SHELL", "pg_isready -U nylorun -d nylorun"]
|
|
30
|
+
interval: 2s
|
|
31
|
+
timeout: 5s
|
|
32
|
+
retries: 30
|
|
33
|
+
restart: unless-stopped
|
|
34
|
+
|
|
35
|
+
restate: # Durable Session Execution
|
|
36
|
+
image: ${PINNED_IMAGES.restate}
|
|
37
|
+
command: ["--node-name=restate-1"] # stable name, so data is found on restart
|
|
38
|
+
environment:
|
|
39
|
+
RESTATE_WORKER__INVOKER__REQUEST_IDENTITY_PRIVATE_KEY_PEM_FILE: /run/nylorun/restate-identity.pem
|
|
40
|
+
volumes:
|
|
41
|
+
- restate:/restate-data
|
|
42
|
+
- \${NYLORUN_HOST_ROOT:?run nylorun start}/stack/restate-identity.pem:/run/nylorun/restate-identity.pem:ro
|
|
43
|
+
ports:
|
|
44
|
+
- "127.0.0.1:\${NYLORUN_RESTATE_PORT:?run nylorun start}:9070" # Restate UI and admin, for debugging
|
|
45
|
+
healthcheck:
|
|
46
|
+
test: ["CMD", "curl", "-fsS", "http://localhost:9070/health"]
|
|
47
|
+
interval: 2s
|
|
48
|
+
timeout: 5s
|
|
49
|
+
retries: 30
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
|
|
52
|
+
s2: # Durable Streams; not published, only the Runtime reaches it
|
|
53
|
+
image: ${PINNED_IMAGES.s2}
|
|
54
|
+
command: ["lite", "--local-root", "/home/nonroot/data"] # local disk; listens on port 80
|
|
55
|
+
# The image runs as uid 65532. Docker fills a new volume with the image's
|
|
56
|
+
# /home/nonroot, owned by that user; a volume at a path the image lacks
|
|
57
|
+
# (e.g. /data) is root-owned and s2-lite cannot write it.
|
|
58
|
+
volumes:
|
|
59
|
+
- s2:/home/nonroot
|
|
60
|
+
# The s2 image has no shell or HTTP client, so it has no health check;
|
|
61
|
+
# the Runtime's /ready covers it.
|
|
62
|
+
restart: unless-stopped
|
|
63
|
+
|
|
64
|
+
runtime:
|
|
65
|
+
image: \${NYLORUN_RUNTIME_IMAGE:?run nylorun start}
|
|
66
|
+
command: ["--role", "all"]
|
|
67
|
+
user: "\${NYLORUN_UID:?run nylorun start}:\${NYLORUN_GID:?run nylorun start}"
|
|
68
|
+
depends_on:
|
|
69
|
+
postgres: { condition: service_healthy }
|
|
70
|
+
restate: { condition: service_healthy }
|
|
71
|
+
s2: { condition: service_started }
|
|
72
|
+
environment:
|
|
73
|
+
NYLORUN_HOME: /nylorun
|
|
74
|
+
NYLORUN_LISTEN_HOST: 0.0.0.0
|
|
75
|
+
NYLORUN_LISTEN_PORT: "4000"
|
|
76
|
+
# Host headers the Runtime accepts: the stack network name, and the
|
|
77
|
+
# published port as clients on this machine address it.
|
|
78
|
+
NYLORUN_ALLOWED_HOSTS: runtime:4000,localhost:\${NYLORUN_PORT},127.0.0.1:\${NYLORUN_PORT}
|
|
79
|
+
NYLORUN_PUBLIC_URL: http://localhost:\${NYLORUN_PORT} # reported by /v1/admin/status
|
|
80
|
+
NYLORUN_DATABASE_URL: postgres://nylorun:\${NYLORUN_POSTGRES_PASSWORD}@postgres:5432/nylorun
|
|
81
|
+
NYLORUN_RESTATE_INGRESS_URL: http://restate:8080
|
|
82
|
+
NYLORUN_RESTATE_ADMIN_URL: http://restate:9070
|
|
83
|
+
NYLORUN_WORKER_URL: http://runtime:9080 # registered with Restate; not published
|
|
84
|
+
NYLORUN_RESTATE_IDENTITY_KEY: \${NYLORUN_RESTATE_IDENTITY_KEY:?run nylorun start}
|
|
85
|
+
NYLORUN_S2_ENDPOINT: http://s2:80
|
|
86
|
+
NYLORUN_S2_TOKEN: ignored # s2-lite has no access tokens yet
|
|
87
|
+
NYLORUN_WORKSPACE_STORE_URL: file:///workspaces
|
|
88
|
+
volumes:
|
|
89
|
+
- \${NYLORUN_HOST_ROOT:?run nylorun start}:/nylorun # Host root
|
|
90
|
+
- workspaces:/workspaces
|
|
91
|
+
ports:
|
|
92
|
+
- "127.0.0.1:\${NYLORUN_PORT:?run nylorun start}:4000" # Tenant API, Admin API, SSE
|
|
93
|
+
healthcheck:
|
|
94
|
+
test: ["CMD", "node", "-e", "fetch('http://localhost:4000/ready').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
95
|
+
interval: 2s
|
|
96
|
+
timeout: 5s
|
|
97
|
+
retries: 60
|
|
98
|
+
restart: unless-stopped
|
|
99
|
+
|
|
100
|
+
studio: # dashboard + trusted proxy
|
|
101
|
+
image: \${NYLORUN_STUDIO_IMAGE:?run nylorun start}
|
|
102
|
+
user: "\${NYLORUN_UID}:\${NYLORUN_GID}" # reads the 0600 admin key file
|
|
103
|
+
depends_on:
|
|
104
|
+
runtime: { condition: service_healthy }
|
|
105
|
+
environment:
|
|
106
|
+
NYLORUN_RUNTIME_URL: http://runtime:4000
|
|
107
|
+
NYLORUN_ADMIN_KEY_FILE: /run/nylorun/host-credentials.json
|
|
108
|
+
PORT: "3000"
|
|
109
|
+
# Studio's Host check accepts localhost/127.0.0.1 on the published port.
|
|
110
|
+
NYLORUN_STUDIO_PUBLIC_PORT: \${NYLORUN_STUDIO_PORT}
|
|
111
|
+
volumes:
|
|
112
|
+
- \${NYLORUN_HOST_ROOT}/host-credentials.json:/run/nylorun/host-credentials.json:ro
|
|
113
|
+
ports:
|
|
114
|
+
- "127.0.0.1:\${NYLORUN_STUDIO_PORT:?run nylorun start}:3000" # opened as http://localhost:<port>
|
|
115
|
+
healthcheck: # /healthz needs no session; Host studio:3000 is accepted
|
|
116
|
+
test: ["CMD", "node", "-e", "require('http').get({port:3000,path:'/healthz',headers:{host:'studio:3000'}},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
|
|
117
|
+
interval: 2s
|
|
118
|
+
timeout: 5s
|
|
119
|
+
retries: 30
|
|
120
|
+
restart: unless-stopped
|
|
121
|
+
|
|
122
|
+
volumes:
|
|
123
|
+
postgres: {}
|
|
124
|
+
restate: {}
|
|
125
|
+
s2: {}
|
|
126
|
+
workspaces: {}
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface DockerResult {
|
|
2
|
+
code: number;
|
|
3
|
+
stdout: string;
|
|
4
|
+
stderr: string;
|
|
5
|
+
/** The docker binary was not found. */
|
|
6
|
+
missing?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/** Runs `docker`; replaced by a fake in tests. */
|
|
9
|
+
export interface DockerRunner {
|
|
10
|
+
/** Run and capture output. */
|
|
11
|
+
run(args: readonly string[]): Promise<DockerResult>;
|
|
12
|
+
/** Run with the terminal attached (progress, logs); resolves to the exit code. */
|
|
13
|
+
stream(args: readonly string[]): Promise<number>;
|
|
14
|
+
}
|
|
15
|
+
export declare function spawnDocker(env: Readonly<Record<string, string | undefined>>, binary?: string): DockerRunner;
|
|
16
|
+
export type Check = {
|
|
17
|
+
ok: true;
|
|
18
|
+
version: string;
|
|
19
|
+
} | {
|
|
20
|
+
ok: false;
|
|
21
|
+
problem: string;
|
|
22
|
+
};
|
|
23
|
+
export interface DockerChecks {
|
|
24
|
+
docker: Check;
|
|
25
|
+
/** Absent when Docker itself failed (Compose was not asked). */
|
|
26
|
+
compose?: Check;
|
|
27
|
+
}
|
|
28
|
+
/** Check the Docker engine and Compose v2; never throws. */
|
|
29
|
+
export declare function checkDocker(docker: DockerRunner): Promise<DockerChecks>;
|
|
30
|
+
/** Fail with a clear message when Docker or Compose v2 is unavailable. */
|
|
31
|
+
export declare function dockerPreflight(docker: DockerRunner): Promise<void>;
|
|
32
|
+
export interface ComposeService {
|
|
33
|
+
service: string;
|
|
34
|
+
state: string;
|
|
35
|
+
health: string;
|
|
36
|
+
}
|
|
37
|
+
/** Parse `docker compose ps --format json` (a JSON array, or one object per line). */
|
|
38
|
+
export declare function parseComposePs(stdout: string): ComposeService[];
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { CliError } from "../errors.js";
|
|
3
|
+
export function spawnDocker(env, binary = "docker") {
|
|
4
|
+
const childEnv = Object.fromEntries(Object.entries(env).filter((entry) => entry[1] !== undefined));
|
|
5
|
+
return {
|
|
6
|
+
run(args) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
const child = spawn(binary, [...args], {
|
|
11
|
+
env: childEnv,
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
});
|
|
14
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
15
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
16
|
+
child.once("error", (error) => resolve({
|
|
17
|
+
code: 127,
|
|
18
|
+
stdout,
|
|
19
|
+
stderr: error.message,
|
|
20
|
+
...(error.code === "ENOENT" ? { missing: true } : {}),
|
|
21
|
+
}));
|
|
22
|
+
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
stream(args) {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
// Compose writes progress to stderr and logs to stdout.
|
|
28
|
+
const child = spawn(binary, [...args], {
|
|
29
|
+
env: childEnv,
|
|
30
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
31
|
+
});
|
|
32
|
+
child.once("error", () => resolve(127));
|
|
33
|
+
child.once("close", (code) => resolve(code ?? 1));
|
|
34
|
+
});
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function firstLine(text) {
|
|
39
|
+
return text.trim().split(/\r?\n/)[0] ?? "";
|
|
40
|
+
}
|
|
41
|
+
/** Check the Docker engine and Compose v2; never throws. */
|
|
42
|
+
export async function checkDocker(docker) {
|
|
43
|
+
const server = await docker.run(["version", "--format", "{{.Server.Version}}"]);
|
|
44
|
+
if (server.missing)
|
|
45
|
+
return {
|
|
46
|
+
docker: {
|
|
47
|
+
ok: false,
|
|
48
|
+
problem: "Docker is required for the Nylorun stack, and the docker command was not found. Install Docker Desktop, OrbStack, Colima or another Docker engine with Compose v2, then retry.",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
if (server.code !== 0)
|
|
52
|
+
return {
|
|
53
|
+
docker: {
|
|
54
|
+
ok: false,
|
|
55
|
+
problem: `Docker is installed but its engine is not reachable (${firstLine(server.stderr) || `exit ${server.code}`}). Start Docker Desktop, OrbStack, Colima or your Docker engine, then retry.`,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
const engine = { ok: true, version: server.stdout.trim() };
|
|
59
|
+
const compose = await docker.run(["compose", "version", "--short"]);
|
|
60
|
+
if (compose.code !== 0 || !/^v?[2-9]\./.test(compose.stdout.trim()))
|
|
61
|
+
return {
|
|
62
|
+
docker: engine,
|
|
63
|
+
compose: {
|
|
64
|
+
ok: false,
|
|
65
|
+
problem: `Docker Compose v2 is required (the "docker compose" plugin); ${compose.code === 0 ? `found ${compose.stdout.trim()}` : firstLine(compose.stderr) || "it is not installed"}. Install or update Docker Compose, then retry.`,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
return { docker: engine, compose: { ok: true, version: compose.stdout.trim() } };
|
|
69
|
+
}
|
|
70
|
+
/** Fail with a clear message when Docker or Compose v2 is unavailable. */
|
|
71
|
+
export async function dockerPreflight(docker) {
|
|
72
|
+
const checks = await checkDocker(docker);
|
|
73
|
+
if (!checks.docker.ok)
|
|
74
|
+
throw new CliError(checks.docker.problem, 1);
|
|
75
|
+
if (checks.compose && !checks.compose.ok)
|
|
76
|
+
throw new CliError(checks.compose.problem, 1);
|
|
77
|
+
}
|
|
78
|
+
/** Parse `docker compose ps --format json` (a JSON array, or one object per line). */
|
|
79
|
+
export function parseComposePs(stdout) {
|
|
80
|
+
const text = stdout.trim();
|
|
81
|
+
if (text === "")
|
|
82
|
+
return [];
|
|
83
|
+
let rows;
|
|
84
|
+
if (text.startsWith("[")) {
|
|
85
|
+
rows = JSON.parse(text);
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
rows = text
|
|
89
|
+
.split(/\r?\n/)
|
|
90
|
+
.filter((line) => line.trim() !== "")
|
|
91
|
+
.map((line) => JSON.parse(line));
|
|
92
|
+
}
|
|
93
|
+
return rows.map((row) => {
|
|
94
|
+
const record = row;
|
|
95
|
+
return {
|
|
96
|
+
service: String(record.Service ?? record.Name ?? ""),
|
|
97
|
+
state: String(record.State ?? ""),
|
|
98
|
+
health: String(record.Health ?? ""),
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings in `stack/.env` (mode 0600). Ports and the password persist; the
|
|
3
|
+
* Restate identity public key is derived from `restate-identity.pem`.
|
|
4
|
+
*/
|
|
5
|
+
export interface StackEnv {
|
|
6
|
+
/** Published Runtime port (loopback). */
|
|
7
|
+
runtimePort: number;
|
|
8
|
+
/** Published Studio port (loopback). */
|
|
9
|
+
studioPort: number;
|
|
10
|
+
/** Published Restate UI and admin port (loopback). */
|
|
11
|
+
restatePort: number;
|
|
12
|
+
postgresPassword: string;
|
|
13
|
+
/** `publickeyv1_...` of `stack/restate-identity.pem`. */
|
|
14
|
+
restateIdentityKey: string;
|
|
15
|
+
uid: number;
|
|
16
|
+
gid: number;
|
|
17
|
+
/** Absolute Host root on this machine, bind-mounted at /nylorun. */
|
|
18
|
+
hostRoot: string;
|
|
19
|
+
runtimeImage: string;
|
|
20
|
+
studioImage: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function renderEnvFile(env: StackEnv): string;
|
|
23
|
+
/** Parse KEY=value lines; comments and blank lines are skipped. */
|
|
24
|
+
export declare function parseEnvLines(text: string): Map<string, string>;
|
|
25
|
+
/** The settings that persist across starts, from an existing .env. */
|
|
26
|
+
export interface PersistedStackEnv {
|
|
27
|
+
runtimePort?: number;
|
|
28
|
+
studioPort?: number;
|
|
29
|
+
restatePort?: number;
|
|
30
|
+
postgresPassword?: string;
|
|
31
|
+
}
|
|
32
|
+
export declare function parsePersisted(text: string): PersistedStackEnv;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CliError } from "../errors.js";
|
|
2
|
+
const KEYS = {
|
|
3
|
+
runtimePort: "NYLORUN_PORT",
|
|
4
|
+
studioPort: "NYLORUN_STUDIO_PORT",
|
|
5
|
+
restatePort: "NYLORUN_RESTATE_PORT",
|
|
6
|
+
postgresPassword: "NYLORUN_POSTGRES_PASSWORD",
|
|
7
|
+
restateIdentityKey: "NYLORUN_RESTATE_IDENTITY_KEY",
|
|
8
|
+
uid: "NYLORUN_UID",
|
|
9
|
+
gid: "NYLORUN_GID",
|
|
10
|
+
hostRoot: "NYLORUN_HOST_ROOT",
|
|
11
|
+
runtimeImage: "NYLORUN_RUNTIME_IMAGE",
|
|
12
|
+
studioImage: "NYLORUN_STUDIO_IMAGE",
|
|
13
|
+
};
|
|
14
|
+
/** Compose .env values: single quotes keep a value literal (no interpolation). */
|
|
15
|
+
function quote(key, value) {
|
|
16
|
+
if (/[\r\n']/.test(value))
|
|
17
|
+
throw new CliError(`${key} cannot contain a newline or single quote: ${value}`, 1);
|
|
18
|
+
return /^[A-Za-z0-9_./:@-]*$/.test(value) ? value : `'${value}'`;
|
|
19
|
+
}
|
|
20
|
+
function unquote(value) {
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (trimmed.length >= 2 &&
|
|
23
|
+
((trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
|
24
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"'))))
|
|
25
|
+
return trimmed.slice(1, -1);
|
|
26
|
+
return trimmed;
|
|
27
|
+
}
|
|
28
|
+
export function renderEnvFile(env) {
|
|
29
|
+
const line = (field) => `${KEYS[field]}=${quote(KEYS[field], String(env[field]))}`;
|
|
30
|
+
return [
|
|
31
|
+
"# Written by `nylorun start`. Mode 0600: holds the Postgres password.",
|
|
32
|
+
"# Ports and the password are kept across starts; images, UID/GID and the",
|
|
33
|
+
"# Host root are refreshed on every start.",
|
|
34
|
+
"",
|
|
35
|
+
"# Published on 127.0.0.1; clients use http://localhost:<port>.",
|
|
36
|
+
line("runtimePort"),
|
|
37
|
+
line("studioPort"),
|
|
38
|
+
line("restatePort"),
|
|
39
|
+
"",
|
|
40
|
+
line("postgresPassword"),
|
|
41
|
+
"",
|
|
42
|
+
"# Restate signs requests to the Runtime's Worker endpoint with the private",
|
|
43
|
+
"# key in restate-identity.pem; the Runtime accepts only this public key.",
|
|
44
|
+
line("restateIdentityKey"),
|
|
45
|
+
"",
|
|
46
|
+
"# The Runtime and Studio containers run as this user, so files they write",
|
|
47
|
+
"# into the Host root belong to you.",
|
|
48
|
+
line("uid"),
|
|
49
|
+
line("gid"),
|
|
50
|
+
line("hostRoot"),
|
|
51
|
+
"",
|
|
52
|
+
line("runtimeImage"),
|
|
53
|
+
line("studioImage"),
|
|
54
|
+
"",
|
|
55
|
+
].join("\n");
|
|
56
|
+
}
|
|
57
|
+
/** Parse KEY=value lines; comments and blank lines are skipped. */
|
|
58
|
+
export function parseEnvLines(text) {
|
|
59
|
+
const values = new Map();
|
|
60
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
61
|
+
const line = raw.trim();
|
|
62
|
+
if (line === "" || line.startsWith("#"))
|
|
63
|
+
continue;
|
|
64
|
+
const index = line.indexOf("=");
|
|
65
|
+
if (index <= 0)
|
|
66
|
+
continue;
|
|
67
|
+
values.set(line.slice(0, index).trim(), unquote(line.slice(index + 1)));
|
|
68
|
+
}
|
|
69
|
+
return values;
|
|
70
|
+
}
|
|
71
|
+
function port(value) {
|
|
72
|
+
if (value === undefined || !/^\d+$/.test(value))
|
|
73
|
+
return undefined;
|
|
74
|
+
const n = Number(value);
|
|
75
|
+
return n >= 1 && n <= 65535 ? n : undefined;
|
|
76
|
+
}
|
|
77
|
+
export function parsePersisted(text) {
|
|
78
|
+
const values = parseEnvLines(text);
|
|
79
|
+
const out = {};
|
|
80
|
+
const runtimePort = port(values.get(KEYS.runtimePort));
|
|
81
|
+
if (runtimePort)
|
|
82
|
+
out.runtimePort = runtimePort;
|
|
83
|
+
const studioPort = port(values.get(KEYS.studioPort));
|
|
84
|
+
if (studioPort)
|
|
85
|
+
out.studioPort = studioPort;
|
|
86
|
+
const restatePort = port(values.get(KEYS.restatePort));
|
|
87
|
+
if (restatePort)
|
|
88
|
+
out.restatePort = restatePort;
|
|
89
|
+
const password = values.get(KEYS.postgresPassword);
|
|
90
|
+
if (password && /^[A-Za-z0-9]{16,}$/.test(password))
|
|
91
|
+
out.postgresPassword = password;
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StackPaths } from "./paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* host.json and host-credentials.json (format 1), which `@nylorun/admin`,
|
|
4
|
+
* Studio and the Runtime container all read.
|
|
5
|
+
*
|
|
6
|
+
* For the stack, host.json names the client-facing address: `localhost` and
|
|
7
|
+
* the published port. The container binds 0.0.0.0:4000 on its own.
|
|
8
|
+
*/
|
|
9
|
+
/** Host name clients use for the stack. Studio's cookie is set on this name too. */
|
|
10
|
+
export declare const STACK_CLIENT_HOST = "localhost";
|
|
11
|
+
export interface HostConfigFile {
|
|
12
|
+
format: 1;
|
|
13
|
+
hostId: string;
|
|
14
|
+
host: string;
|
|
15
|
+
port: number;
|
|
16
|
+
runtimeVersion?: string;
|
|
17
|
+
[field: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
export declare function newHostId(): string;
|
|
20
|
+
/** The Host root layout, mode 0700. */
|
|
21
|
+
export declare function ensureHostLayout(paths: StackPaths): Promise<void>;
|
|
22
|
+
export declare function readHostConfig(paths: StackPaths): Promise<Record<string, unknown> | undefined>;
|
|
23
|
+
/**
|
|
24
|
+
* Write host.json with the published port. Keeps the hostId and unknown fields
|
|
25
|
+
* of an existing file; refuses a newer format.
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeStackHostConfig(paths: StackPaths, input: {
|
|
28
|
+
port: number;
|
|
29
|
+
runtimeVersion: string;
|
|
30
|
+
}): Promise<HostConfigFile>;
|
|
31
|
+
export declare function readAdminKey(paths: StackPaths): Promise<string | undefined>;
|
|
32
|
+
/** Create host-credentials.json (mode 0600) if missing; always leave it 0600. */
|
|
33
|
+
export declare function ensureHostCredentials(paths: StackPaths): Promise<{
|
|
34
|
+
adminKey: string;
|
|
35
|
+
created: boolean;
|
|
36
|
+
}>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { newTenantId } from "@nylorun/core/compatibility";
|
|
4
|
+
import { CliError } from "../errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* host.json and host-credentials.json (format 1), which `@nylorun/admin`,
|
|
7
|
+
* Studio and the Runtime container all read.
|
|
8
|
+
*
|
|
9
|
+
* For the stack, host.json names the client-facing address: `localhost` and
|
|
10
|
+
* the published port. The container binds 0.0.0.0:4000 on its own.
|
|
11
|
+
*/
|
|
12
|
+
/** Host name clients use for the stack. Studio's cookie is set on this name too. */
|
|
13
|
+
export const STACK_CLIENT_HOST = "localhost";
|
|
14
|
+
const KNOWN_FORMAT = 1;
|
|
15
|
+
const ADMIN_KEY_PATTERN = /^[0-9a-f]{64}$/;
|
|
16
|
+
export function newHostId() {
|
|
17
|
+
return `host_${newTenantId().slice(3)}`;
|
|
18
|
+
}
|
|
19
|
+
async function writeAtomic(path, text) {
|
|
20
|
+
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
21
|
+
await writeFile(temporary, text, { mode: 0o600 });
|
|
22
|
+
await rename(temporary, path);
|
|
23
|
+
await chmod(path, 0o600);
|
|
24
|
+
}
|
|
25
|
+
/** The Host root layout, mode 0700. */
|
|
26
|
+
export async function ensureHostLayout(paths) {
|
|
27
|
+
for (const dir of [paths.root, paths.home, paths.tmp, paths.tenants, paths.stack])
|
|
28
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
29
|
+
}
|
|
30
|
+
export async function readHostConfig(paths) {
|
|
31
|
+
let text;
|
|
32
|
+
try {
|
|
33
|
+
text = await readFile(paths.config, "utf8");
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(text);
|
|
40
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
41
|
+
? value
|
|
42
|
+
: undefined;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write host.json with the published port. Keeps the hostId and unknown fields
|
|
50
|
+
* of an existing file; refuses a newer format.
|
|
51
|
+
*/
|
|
52
|
+
export async function writeStackHostConfig(paths, input) {
|
|
53
|
+
const existing = (await readHostConfig(paths)) ?? {};
|
|
54
|
+
const format = existing.format;
|
|
55
|
+
if (format !== undefined &&
|
|
56
|
+
(typeof format !== "number" || !Number.isInteger(format) || format > KNOWN_FORMAT))
|
|
57
|
+
throw new CliError(`host.json format ${String(format)} at ${paths.config} is newer than this CLI supports (${KNOWN_FORMAT}). Upgrade nylorun.`, 1);
|
|
58
|
+
const hostId = typeof existing.hostId === "string" && /^host_[0-9a-hjkmnp-tv-z]{26}$/.test(existing.hostId)
|
|
59
|
+
? existing.hostId
|
|
60
|
+
: newHostId();
|
|
61
|
+
const config = {
|
|
62
|
+
...existing,
|
|
63
|
+
format: 1,
|
|
64
|
+
hostId,
|
|
65
|
+
host: STACK_CLIENT_HOST,
|
|
66
|
+
port: input.port,
|
|
67
|
+
runtimeVersion: input.runtimeVersion,
|
|
68
|
+
};
|
|
69
|
+
await writeAtomic(paths.config, `${JSON.stringify(config, null, 2)}\n`);
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
72
|
+
export async function readAdminKey(paths) {
|
|
73
|
+
try {
|
|
74
|
+
const value = JSON.parse(await readFile(paths.credentials, "utf8"));
|
|
75
|
+
return typeof value?.adminKey === "string" && ADMIN_KEY_PATTERN.test(value.adminKey)
|
|
76
|
+
? value.adminKey
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Create host-credentials.json (mode 0600) if missing; always leave it 0600. */
|
|
84
|
+
export async function ensureHostCredentials(paths) {
|
|
85
|
+
const existing = await readAdminKey(paths);
|
|
86
|
+
if (existing) {
|
|
87
|
+
await chmod(paths.credentials, 0o600);
|
|
88
|
+
return { adminKey: existing, created: false };
|
|
89
|
+
}
|
|
90
|
+
const adminKey = randomBytes(32).toString("hex");
|
|
91
|
+
await writeAtomic(paths.credentials, `${JSON.stringify({ adminKey })}\n`);
|
|
92
|
+
return { adminKey, created: true };
|
|
93
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Images the local stack runs. The Runtime and Studio tags are pinned by this
|
|
3
|
+
* CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
|
|
4
|
+
* (local builds, CI images). Postgres, Restate and s2-lite are the official
|
|
5
|
+
* images, pinned here.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PINNED_IMAGES: {
|
|
8
|
+
readonly postgres: "postgres:17.11";
|
|
9
|
+
readonly restate: "docker.restate.dev/restatedev/restate:1.7.12";
|
|
10
|
+
readonly s2: "ghcr.io/s2-streamstore/s2:0.43.0";
|
|
11
|
+
};
|
|
12
|
+
export interface StackImages {
|
|
13
|
+
runtime: string;
|
|
14
|
+
studio: string;
|
|
15
|
+
postgres: string;
|
|
16
|
+
restate: string;
|
|
17
|
+
s2: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function stackImages(env: Readonly<Record<string, string | undefined>>, versions?: {
|
|
20
|
+
runtime: string;
|
|
21
|
+
studio: string;
|
|
22
|
+
}): StackImages;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { pinnedVersion } from "./versions.js";
|
|
2
|
+
/**
|
|
3
|
+
* Images the local stack runs. The Runtime and Studio tags are pinned by this
|
|
4
|
+
* CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
|
|
5
|
+
* (local builds, CI images). Postgres, Restate and s2-lite are the official
|
|
6
|
+
* images, pinned here.
|
|
7
|
+
*/
|
|
8
|
+
export const PINNED_IMAGES = {
|
|
9
|
+
postgres: "postgres:17.11",
|
|
10
|
+
restate: "docker.restate.dev/restatedev/restate:1.7.12",
|
|
11
|
+
s2: "ghcr.io/s2-streamstore/s2:0.43.0",
|
|
12
|
+
};
|
|
13
|
+
export function stackImages(env, versions = {
|
|
14
|
+
runtime: pinnedVersion("runtime"),
|
|
15
|
+
studio: pinnedVersion("studio"),
|
|
16
|
+
}) {
|
|
17
|
+
const override = (name) => {
|
|
18
|
+
const value = env[name]?.trim();
|
|
19
|
+
return value ? value : undefined;
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
runtime: override("NYLORUN_RUNTIME_IMAGE") ??
|
|
23
|
+
`ghcr.io/nylorun/runtime:${versions.runtime}`,
|
|
24
|
+
studio: override("NYLORUN_STUDIO_IMAGE") ??
|
|
25
|
+
`ghcr.io/nylorun/studio:${versions.studio}`,
|
|
26
|
+
...PINNED_IMAGES,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type StackDeps } from "./commands.js";
|
|
2
|
+
export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
|
|
3
|
+
export type { StackDeps, StackEndpoints, StackStatus } from "./commands.js";
|
|
4
|
+
export { checkDocker } from "./docker.js";
|
|
5
|
+
export type { Check, DockerChecks } from "./docker.js";
|
|
6
|
+
/** Real dependencies: docker on PATH, global fetch, the terminal. */
|
|
7
|
+
export declare function defaultStackDeps(env: Readonly<Record<string, string | undefined>>): StackDeps;
|
|
8
|
+
/** Entry for `nylorun start|stop|status|logs|reset|studio`. */
|
|
9
|
+
export declare function stackCommand(name: string, args: readonly string[], env: Readonly<Record<string, string | undefined>>): Promise<number>;
|
|
10
|
+
/** `nylorun studio`, landing on `next` (a Tenant page) when given. */
|
|
11
|
+
export declare function studioCommand(args: readonly string[], env: Readonly<Record<string, string | undefined>>, options?: {
|
|
12
|
+
next?: string;
|
|
13
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { runStackCommand, runStudioCommand } from "./commands.js";
|
|
4
|
+
import { spawnDocker } from "./docker.js";
|
|
5
|
+
import { pinnedVersion } from "./versions.js";
|
|
6
|
+
import { loopbackPorts } from "./ports.js";
|
|
7
|
+
export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
|
|
8
|
+
export { checkDocker } from "./docker.js";
|
|
9
|
+
function browserCommand(env) {
|
|
10
|
+
if (process.platform === "darwin")
|
|
11
|
+
return "open";
|
|
12
|
+
if (env.WSL_DISTRO_NAME)
|
|
13
|
+
return "wslview";
|
|
14
|
+
return "xdg-open";
|
|
15
|
+
}
|
|
16
|
+
/** Real dependencies: docker on PATH, global fetch, the terminal. */
|
|
17
|
+
export function defaultStackDeps(env) {
|
|
18
|
+
const interactive = Boolean(process.stdin.isTTY && process.stderr.isTTY);
|
|
19
|
+
return {
|
|
20
|
+
env,
|
|
21
|
+
docker: spawnDocker(env),
|
|
22
|
+
fetch: (input, init) => fetch(input, init),
|
|
23
|
+
ports: loopbackPorts,
|
|
24
|
+
uid: typeof process.getuid === "function" ? process.getuid() : 1000,
|
|
25
|
+
gid: typeof process.getgid === "function" ? process.getgid() : 1000,
|
|
26
|
+
runtimeVersion: pinnedVersion("runtime"),
|
|
27
|
+
studioVersion: pinnedVersion("studio"),
|
|
28
|
+
out: (line) => console.log(line),
|
|
29
|
+
err: (line) => console.error(line),
|
|
30
|
+
...(interactive
|
|
31
|
+
? {
|
|
32
|
+
confirm: async (question) => {
|
|
33
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
34
|
+
try {
|
|
35
|
+
return /^y(es)?$/i.test((await rl.question(question)).trim());
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
rl.close();
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
: {}),
|
|
43
|
+
openBrowser: async (url) => {
|
|
44
|
+
const command = browserCommand(env);
|
|
45
|
+
await new Promise((resolve) => {
|
|
46
|
+
const child = spawn(command, [url], { detached: true, stdio: "ignore" });
|
|
47
|
+
child.once("error", () => {
|
|
48
|
+
console.error(`Could not start ${command} to open a browser; open the Studio URL above.`);
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
child.once("spawn", () => {
|
|
52
|
+
child.unref();
|
|
53
|
+
resolve();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
pidAlive: (pid) => {
|
|
58
|
+
try {
|
|
59
|
+
process.kill(pid, 0);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
return error.code === "EPERM";
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** Entry for `nylorun start|stop|status|logs|reset|studio`. */
|
|
69
|
+
export async function stackCommand(name, args, env) {
|
|
70
|
+
return await runStackCommand(name, args, defaultStackDeps(env));
|
|
71
|
+
}
|
|
72
|
+
/** `nylorun studio`, landing on `next` (a Tenant page) when given. */
|
|
73
|
+
export async function studioCommand(args, env, options = {}) {
|
|
74
|
+
return await runStudioCommand(args, defaultStackDeps(env), options);
|
|
75
|
+
}
|