nylorun 0.4.0-beta → 0.6.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 +39 -0
- package/README.md +207 -105
- package/dist/cli.js +24 -22
- package/dist/doctor.d.ts +10 -8
- package/dist/doctor.js +54 -37
- package/dist/home.d.ts +1 -1
- package/dist/home.js +1 -1
- package/dist/project/derived-key.d.ts +2 -2
- package/dist/project/derived-key.js +2 -2
- package/dist/project/link.d.ts +7 -7
- package/dist/project/link.js +3 -3
- package/dist/stack/commands.d.ts +23 -15
- package/dist/stack/commands.js +332 -214
- package/dist/stack/compose-file.d.ts +11 -8
- package/dist/stack/compose-file.js +54 -21
- package/dist/stack/docker.d.ts +10 -0
- package/dist/stack/docker.js +64 -1
- package/dist/stack/env-file.d.ts +12 -2
- package/dist/stack/env-file.js +13 -3
- package/dist/stack/host-files.d.ts +4 -4
- package/dist/stack/host-files.js +3 -3
- package/dist/stack/images.d.ts +4 -2
- package/dist/stack/images.js +3 -2
- package/dist/stack/index.d.ts +4 -3
- package/dist/stack/index.js +10 -4
- package/dist/stack/paths.d.ts +3 -3
- package/dist/stack/paths.js +1 -1
- package/dist/stack/ports.d.ts +4 -2
- package/dist/stack/ports.js +3 -1
- package/dist/stack/prepare.d.ts +7 -3
- package/dist/stack/prepare.js +5 -3
- package/dist/stack/proxy.d.ts +78 -0
- package/dist/stack/proxy.js +224 -0
- package/dist/stack/stacks.d.ts +37 -34
- package/dist/stack/stacks.js +91 -57
- package/dist/stack/studio-login.d.ts +4 -3
- package/dist/stack/studio-login.js +5 -4
- package/dist/stack/versions.d.ts +1 -1
- package/dist/stack/versions.js +1 -1
- package/dist/telemetry.d.ts +31 -0
- package/dist/telemetry.js +79 -0
- package/package.json +5 -5
package/dist/stack/prepare.js
CHANGED
|
@@ -21,7 +21,7 @@ async function readText(path) {
|
|
|
21
21
|
return undefined;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
/** Read the persisted
|
|
24
|
+
/** Read the persisted Compose settings without writing anything. */
|
|
25
25
|
export async function readStackEnv(paths) {
|
|
26
26
|
const text = await readText(paths.env);
|
|
27
27
|
return text === undefined ? undefined : parsePersisted(text);
|
|
@@ -76,7 +76,9 @@ export async function prepareStack(input) {
|
|
|
76
76
|
runtimeImage: input.images.runtime,
|
|
77
77
|
studioImage: input.images.studio,
|
|
78
78
|
studioFrameAncestors,
|
|
79
|
-
|
|
79
|
+
studioAnalyticsId: input.studioAnalyticsId ?? "",
|
|
80
|
+
studioPublicOrigins: input.studioPublicOrigin ?? "",
|
|
81
|
+
tenantName: input.name,
|
|
80
82
|
derivedPrincipals: (input.derivedPrincipals?.trim()
|
|
81
83
|
? parseDerivedPrincipals(input.derivedPrincipals, "NYLORUN_DERIVED_PRINCIPALS")
|
|
82
84
|
: (persisted.derivedPrincipals ?? ["project"])).join(","),
|
|
@@ -89,6 +91,6 @@ export async function prepareStack(input) {
|
|
|
89
91
|
runtimeVersion: input.runtimeVersion,
|
|
90
92
|
});
|
|
91
93
|
await writeFileMode(paths.env, renderEnvFile(env), 0o600);
|
|
92
|
-
await writeFileMode(paths.compose, renderComposeFile(input.project), 0o644);
|
|
94
|
+
await writeFileMode(paths.compose, renderComposeFile(input.project, input.name), 0o644);
|
|
93
95
|
return { env, host, adminKey, firstRun };
|
|
94
96
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { DockerRunner } from "./docker.js";
|
|
2
|
+
import { type PortProbe } from "./ports.js";
|
|
3
|
+
/**
|
|
4
|
+
* The Studio proxy: one small Caddy container per machine that gives each local Tenant's
|
|
5
|
+
* Studio the address `http://<name>.localhost:<port>`. Browsers keep cookies per host, not
|
|
6
|
+
* per port, so separate hosts keep the Studios' sessions apart. It routes browsers to Studio
|
|
7
|
+
* only (programs keep the Runtime's `http://localhost:<port>`: Node's resolver need not
|
|
8
|
+
* resolve `*.localhost`) and holds no Tenant data: its files are `~/.nylorun/proxy/`
|
|
9
|
+
* (`compose.yaml`, `Caddyfile`, `.env`), and Caddy's own state is tmpfs.
|
|
10
|
+
*
|
|
11
|
+
* The proxy joins each Tenant's network (named after its Compose project) and reaches Studio
|
|
12
|
+
* by container name, `<project>-studio:3000`. Docker cannot remove a network the proxy is
|
|
13
|
+
* attached to, so `reset` and `delete` disconnect it first.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PROXY_PROJECT = "nylorun-proxy";
|
|
16
|
+
export declare const PROXY_CONTAINER = "nylorun-proxy";
|
|
17
|
+
export interface ProxyPaths {
|
|
18
|
+
dir: string;
|
|
19
|
+
compose: string;
|
|
20
|
+
caddyfile: string;
|
|
21
|
+
/** The port, and whether Docker took the `::1` binding. */
|
|
22
|
+
env: string;
|
|
23
|
+
}
|
|
24
|
+
export declare function proxyPaths(base: string): ProxyPaths;
|
|
25
|
+
/** `NYLORUN_PROXY_DISABLED=1`: no proxy; Studio is `http://localhost:<port>`. */
|
|
26
|
+
export declare function proxyDisabled(env: Readonly<Record<string, string | undefined>>): boolean;
|
|
27
|
+
export declare function proxyOrigin(name: string, port: number): string;
|
|
28
|
+
export interface ProxySettings {
|
|
29
|
+
port: number;
|
|
30
|
+
/** Also published on `[::1]` (false once Docker refused it). */
|
|
31
|
+
ipv6: boolean;
|
|
32
|
+
}
|
|
33
|
+
export declare function readProxySettings(base: string): Promise<ProxySettings | undefined>;
|
|
34
|
+
/**
|
|
35
|
+
* The proxy's settings. On first use its port is 4160 when free, else a free one outside
|
|
36
|
+
* `taken`; it is kept after that.
|
|
37
|
+
*/
|
|
38
|
+
export declare function ensureProxySettings(base: string, ports: PortProbe, taken: ReadonlySet<number>): Promise<ProxySettings>;
|
|
39
|
+
/** A Tenant the proxy routes to: its name and Compose project (also its network's name). */
|
|
40
|
+
export interface ProxyRoute {
|
|
41
|
+
name: string;
|
|
42
|
+
project: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* One site per Tenant. Site addresses carry no port: Caddy listens on 80 in the container,
|
|
46
|
+
* and its host match ignores the published port in the browser's Host. The browser's Host
|
|
47
|
+
* (`<name>.localhost:<port>`) reaches Studio unchanged, which Studio checks; a Tenant that is
|
|
48
|
+
* not running answers 502, and an unknown host 404.
|
|
49
|
+
*/
|
|
50
|
+
export declare function renderCaddyfile(routes: readonly ProxyRoute[]): string;
|
|
51
|
+
export declare function renderProxyCompose(ipv6: boolean): string;
|
|
52
|
+
/** Detach the proxy from a Tenant's network, so Compose can remove it; never fails. */
|
|
53
|
+
export declare function disconnectProxy(docker: DockerRunner, network: string): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Write the routes, bring the proxy up (Compose recreates it when its file changed), attach
|
|
56
|
+
* it to every routed Tenant's network and reload Caddy. Never throws: a problem is warned
|
|
57
|
+
* once and the caller shows Studio's own port. When Docker refuses `[::1]` (IPv6 off), the
|
|
58
|
+
* proxy listens on 127.0.0.1 only, and says so once.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startProxy(input: {
|
|
61
|
+
docker: DockerRunner;
|
|
62
|
+
base: string;
|
|
63
|
+
settings: ProxySettings;
|
|
64
|
+
routes: readonly ProxyRoute[];
|
|
65
|
+
err(line: string): void;
|
|
66
|
+
pollMs?: number;
|
|
67
|
+
}): Promise<boolean>;
|
|
68
|
+
/** Rewrite the routes of an existing proxy and reload it (`delete`); never fails. */
|
|
69
|
+
export declare function refreshProxyRoutes(docker: DockerRunner, base: string, routes: readonly ProxyRoute[]): Promise<void>;
|
|
70
|
+
/** Stop the proxy (`stop --all`); true when it stopped. */
|
|
71
|
+
export declare function stopProxy(docker: DockerRunner): Promise<boolean>;
|
|
72
|
+
export interface ProxyStatus {
|
|
73
|
+
state: "running" | "stopped" | "absent" | "disabled";
|
|
74
|
+
port?: number;
|
|
75
|
+
ipv6?: boolean;
|
|
76
|
+
}
|
|
77
|
+
/** The proxy as `nylorun doctor` reports it. */
|
|
78
|
+
export declare function readProxyStatus(docker: DockerRunner, base: string, env: Readonly<Record<string, string | undefined>>): Promise<ProxyStatus>;
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseEnvLines } from "./env-file.js";
|
|
6
|
+
import { PINNED_IMAGES } from "./images.js";
|
|
7
|
+
import { choosePort, DEFAULT_PORTS } from "./ports.js";
|
|
8
|
+
/**
|
|
9
|
+
* The Studio proxy: one small Caddy container per machine that gives each local Tenant's
|
|
10
|
+
* Studio the address `http://<name>.localhost:<port>`. Browsers keep cookies per host, not
|
|
11
|
+
* per port, so separate hosts keep the Studios' sessions apart. It routes browsers to Studio
|
|
12
|
+
* only (programs keep the Runtime's `http://localhost:<port>`: Node's resolver need not
|
|
13
|
+
* resolve `*.localhost`) and holds no Tenant data: its files are `~/.nylorun/proxy/`
|
|
14
|
+
* (`compose.yaml`, `Caddyfile`, `.env`), and Caddy's own state is tmpfs.
|
|
15
|
+
*
|
|
16
|
+
* The proxy joins each Tenant's network (named after its Compose project) and reaches Studio
|
|
17
|
+
* by container name, `<project>-studio:3000`. Docker cannot remove a network the proxy is
|
|
18
|
+
* attached to, so `reset` and `delete` disconnect it first.
|
|
19
|
+
*/
|
|
20
|
+
export const PROXY_PROJECT = "nylorun-proxy";
|
|
21
|
+
export const PROXY_CONTAINER = "nylorun-proxy";
|
|
22
|
+
export function proxyPaths(base) {
|
|
23
|
+
const dir = join(base, "proxy");
|
|
24
|
+
return {
|
|
25
|
+
dir,
|
|
26
|
+
compose: join(dir, "compose.yaml"),
|
|
27
|
+
caddyfile: join(dir, "Caddyfile"),
|
|
28
|
+
env: join(dir, ".env"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** `NYLORUN_PROXY_DISABLED=1`: no proxy; Studio is `http://localhost:<port>`. */
|
|
32
|
+
export function proxyDisabled(env) {
|
|
33
|
+
return /^(1|true)$/i.test(env.NYLORUN_PROXY_DISABLED?.trim() ?? "");
|
|
34
|
+
}
|
|
35
|
+
export function proxyOrigin(name, port) {
|
|
36
|
+
return `http://${name}.localhost:${port}`;
|
|
37
|
+
}
|
|
38
|
+
export async function readProxySettings(base) {
|
|
39
|
+
let text;
|
|
40
|
+
try {
|
|
41
|
+
text = await readFile(proxyPaths(base).env, "utf8");
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const values = parseEnvLines(text);
|
|
47
|
+
const port = Number(values.get("NYLORUN_PROXY_PORT"));
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
49
|
+
return undefined;
|
|
50
|
+
return { port, ipv6: values.get("NYLORUN_PROXY_IPV6") !== "false" };
|
|
51
|
+
}
|
|
52
|
+
async function writeAtomic(path, text) {
|
|
53
|
+
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
54
|
+
await writeFile(temporary, text, { mode: 0o644 });
|
|
55
|
+
await rename(temporary, path);
|
|
56
|
+
}
|
|
57
|
+
async function writeProxySettings(base, settings) {
|
|
58
|
+
const paths = proxyPaths(base);
|
|
59
|
+
await mkdir(paths.dir, { recursive: true, mode: 0o755 });
|
|
60
|
+
await writeAtomic(paths.env, [
|
|
61
|
+
"# Written by `nylorun start`: the Studio proxy's port, kept from its first start,",
|
|
62
|
+
"# and whether it is also published on [::1].",
|
|
63
|
+
`NYLORUN_PROXY_PORT=${settings.port}`,
|
|
64
|
+
`NYLORUN_PROXY_IPV6=${settings.ipv6}`,
|
|
65
|
+
"",
|
|
66
|
+
].join("\n"));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The proxy's settings. On first use its port is 4160 when free, else a free one outside
|
|
70
|
+
* `taken`; it is kept after that.
|
|
71
|
+
*/
|
|
72
|
+
export async function ensureProxySettings(base, ports, taken) {
|
|
73
|
+
const existing = await readProxySettings(base);
|
|
74
|
+
if (existing)
|
|
75
|
+
return existing;
|
|
76
|
+
const settings = { port: await choosePort(ports, DEFAULT_PORTS.proxy, undefined, taken), ipv6: true };
|
|
77
|
+
await writeProxySettings(base, settings);
|
|
78
|
+
return settings;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* One site per Tenant. Site addresses carry no port: Caddy listens on 80 in the container,
|
|
82
|
+
* and its host match ignores the published port in the browser's Host. The browser's Host
|
|
83
|
+
* (`<name>.localhost:<port>`) reaches Studio unchanged, which Studio checks; a Tenant that is
|
|
84
|
+
* not running answers 502, and an unknown host 404.
|
|
85
|
+
*/
|
|
86
|
+
export function renderCaddyfile(routes) {
|
|
87
|
+
const sites = routes.map(({ name, project }) => `http://${name}.localhost {
|
|
88
|
+
reverse_proxy ${project}-studio:3000
|
|
89
|
+
handle_errors {
|
|
90
|
+
respond \`Tenant ${name} is not running. Run "nylorun start --tenant ${name}".\` 502
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
`);
|
|
94
|
+
return [
|
|
95
|
+
`# Written by \`nylorun start\`: a site per Tenant under ~/.nylorun/tenants. Rewritten on every start.
|
|
96
|
+
{
|
|
97
|
+
auto_https off
|
|
98
|
+
admin localhost:2019 # inside the container only; \`caddy reload\` uses it
|
|
99
|
+
}
|
|
100
|
+
`,
|
|
101
|
+
...sites,
|
|
102
|
+
`:80 {
|
|
103
|
+
respond \`No Tenant at {host}. "nylorun ls" lists the Tenants on this machine.\` 404
|
|
104
|
+
}
|
|
105
|
+
`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
export function renderProxyCompose(ipv6) {
|
|
109
|
+
return `# Written by \`nylorun start\`; rewritten on every start. The port lives in .env.
|
|
110
|
+
name: ${PROXY_PROJECT}
|
|
111
|
+
|
|
112
|
+
services:
|
|
113
|
+
proxy: # http://<tenant>.localhost:<port>: every local Tenant's Studio
|
|
114
|
+
image: ${PINNED_IMAGES.proxy}
|
|
115
|
+
container_name: ${PROXY_CONTAINER}
|
|
116
|
+
labels:
|
|
117
|
+
dev.nylorun.proxy: "true"
|
|
118
|
+
volumes:
|
|
119
|
+
- .:/etc/caddy:ro # this directory: the Caddyfile, reloaded with \`caddy reload\`
|
|
120
|
+
tmpfs: [/config, /data] # Caddy's own state; nothing to keep
|
|
121
|
+
ports:
|
|
122
|
+
- "127.0.0.1:\${NYLORUN_PROXY_PORT:?run nylorun start}:80"${ipv6
|
|
123
|
+
? `
|
|
124
|
+
- "[::1]:\${NYLORUN_PROXY_PORT:?run nylorun start}:80" # macOS resolves <name>.localhost to ::1 only`
|
|
125
|
+
: ""}
|
|
126
|
+
restart: unless-stopped
|
|
127
|
+
|
|
128
|
+
networks:
|
|
129
|
+
default:
|
|
130
|
+
name: ${PROXY_PROJECT}
|
|
131
|
+
labels:
|
|
132
|
+
dev.nylorun.proxy: "true"
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
function proxyCompose(paths, ...args) {
|
|
136
|
+
return ["compose", "--project-name", PROXY_PROJECT, "--file", paths.compose, "--env-file", paths.env, ...args];
|
|
137
|
+
}
|
|
138
|
+
function lastLine(text) {
|
|
139
|
+
return text.trim().split(/\r?\n/).at(-1) ?? "";
|
|
140
|
+
}
|
|
141
|
+
/** Attach the proxy to a Tenant's network; "already attached" and "no such network" are fine. */
|
|
142
|
+
async function connectProxy(docker, network) {
|
|
143
|
+
const result = await docker.run(["network", "connect", network, PROXY_CONTAINER]);
|
|
144
|
+
if (result.code === 0 || /already exists|not found|no such network/i.test(result.stderr))
|
|
145
|
+
return undefined;
|
|
146
|
+
return lastLine(result.stderr) || `exit ${result.code}`;
|
|
147
|
+
}
|
|
148
|
+
/** Detach the proxy from a Tenant's network, so Compose can remove it; never fails. */
|
|
149
|
+
export async function disconnectProxy(docker, network) {
|
|
150
|
+
await docker.run(["network", "disconnect", "--force", network, PROXY_CONTAINER]);
|
|
151
|
+
}
|
|
152
|
+
async function reloadProxy(docker) {
|
|
153
|
+
return await docker.run(["exec", PROXY_CONTAINER, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Write the routes, bring the proxy up (Compose recreates it when its file changed), attach
|
|
157
|
+
* it to every routed Tenant's network and reload Caddy. Never throws: a problem is warned
|
|
158
|
+
* once and the caller shows Studio's own port. When Docker refuses `[::1]` (IPv6 off), the
|
|
159
|
+
* proxy listens on 127.0.0.1 only, and says so once.
|
|
160
|
+
*/
|
|
161
|
+
export async function startProxy(input) {
|
|
162
|
+
const { docker, base, err } = input;
|
|
163
|
+
const paths = proxyPaths(base);
|
|
164
|
+
const warn = (problem) => err(`Warning: the Studio proxy is not available (${problem}); Studio is on its own port. See "nylorun doctor".`);
|
|
165
|
+
try {
|
|
166
|
+
await writeAtomic(paths.caddyfile, renderCaddyfile(input.routes));
|
|
167
|
+
await writeAtomic(paths.compose, renderProxyCompose(input.settings.ipv6));
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
warn(error instanceof Error ? error.message : String(error));
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
let up = await docker.run(proxyCompose(paths, "up", "--detach"));
|
|
174
|
+
if (up.code !== 0 && input.settings.ipv6) {
|
|
175
|
+
await writeAtomic(paths.compose, renderProxyCompose(false));
|
|
176
|
+
const ipv4 = await docker.run(proxyCompose(paths, "up", "--detach"));
|
|
177
|
+
if (ipv4.code === 0) {
|
|
178
|
+
await writeProxySettings(base, { ...input.settings, ipv6: false });
|
|
179
|
+
err(`Docker refused the Studio proxy on [::1]:${input.settings.port} (${lastLine(up.stderr)}); it listens on 127.0.0.1 only, so a browser that resolves *.localhost to ::1 alone does not reach it.`);
|
|
180
|
+
}
|
|
181
|
+
up = ipv4;
|
|
182
|
+
}
|
|
183
|
+
if (up.code !== 0) {
|
|
184
|
+
warn(`docker compose up: ${lastLine(up.stderr) || `exit ${up.code}`}`);
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
for (const route of input.routes) {
|
|
188
|
+
const problem = await connectProxy(docker, route.project);
|
|
189
|
+
if (problem)
|
|
190
|
+
err(`Warning: the Studio proxy could not join ${route.project} (${problem}).`);
|
|
191
|
+
}
|
|
192
|
+
// A container Compose just created may not take the reload yet.
|
|
193
|
+
for (let attempt = 1;; attempt += 1) {
|
|
194
|
+
const reload = await reloadProxy(docker);
|
|
195
|
+
if (reload.code === 0)
|
|
196
|
+
return true;
|
|
197
|
+
if (attempt === 10) {
|
|
198
|
+
warn(`caddy reload: ${lastLine(reload.stderr) || `exit ${reload.code}`}`);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, input.pollMs ?? 500));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Rewrite the routes of an existing proxy and reload it (`delete`); never fails. */
|
|
205
|
+
export async function refreshProxyRoutes(docker, base, routes) {
|
|
206
|
+
const paths = proxyPaths(base);
|
|
207
|
+
if (!existsSync(paths.caddyfile))
|
|
208
|
+
return;
|
|
209
|
+
await writeAtomic(paths.caddyfile, renderCaddyfile(routes));
|
|
210
|
+
await reloadProxy(docker);
|
|
211
|
+
}
|
|
212
|
+
/** Stop the proxy (`stop --all`); true when it stopped. */
|
|
213
|
+
export async function stopProxy(docker) {
|
|
214
|
+
return (await docker.run(["compose", "--project-name", PROXY_PROJECT, "stop"])).code === 0;
|
|
215
|
+
}
|
|
216
|
+
/** The proxy as `nylorun doctor` reports it. */
|
|
217
|
+
export async function readProxyStatus(docker, base, env) {
|
|
218
|
+
if (proxyDisabled(env))
|
|
219
|
+
return { state: "disabled" };
|
|
220
|
+
const settings = await readProxySettings(base);
|
|
221
|
+
const inspect = await docker.run(["inspect", "--format", "{{.State.Status}}", PROXY_CONTAINER]);
|
|
222
|
+
const state = inspect.code !== 0 ? "absent" : inspect.stdout.trim() === "running" ? "running" : "stopped";
|
|
223
|
+
return { state, ...(settings ? { port: settings.port, ipv6: settings.ipv6 } : {}) };
|
|
224
|
+
}
|
package/dist/stack/stacks.d.ts
CHANGED
|
@@ -1,47 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The machine's local
|
|
3
|
-
* Host root `~/.nylorun/
|
|
4
|
-
* `~/.nylorun` itself held the single stack of older releases (the legacy stack).
|
|
2
|
+
* The machine's local Tenants (tenancy.md §6): one installation per Tenant, each with its own
|
|
3
|
+
* Host root `~/.nylorun/tenants/<name>/`, Compose project `nylorun-<name>`, ports and volumes.
|
|
5
4
|
*/
|
|
6
|
-
/**
|
|
7
|
-
export declare const
|
|
8
|
-
/**
|
|
5
|
+
/** The Tenant a command acts on outside a project when nothing names one. */
|
|
6
|
+
export declare const DEFAULT_TENANT = "default";
|
|
7
|
+
/** A Tenant name: also part of its Compose project, so Compose's rules apply. */
|
|
8
|
+
export declare const TENANT_NAME: RegExp;
|
|
9
|
+
/** `~/.nylorun`: the Tenants and the machine's settings. Tests pass another directory. */
|
|
9
10
|
export declare function defaultNylorunRoot(): string;
|
|
10
|
-
export declare function
|
|
11
|
-
export declare function
|
|
12
|
-
export declare function
|
|
13
|
-
/** A directory name as a
|
|
14
|
-
export declare function
|
|
15
|
-
/** `
|
|
16
|
-
export interface
|
|
11
|
+
export declare function tenantsDir(base: string): string;
|
|
12
|
+
export declare function tenantRoot(base: string, name: string): string;
|
|
13
|
+
export declare function assertTenantName(name: string, source: string): string;
|
|
14
|
+
/** A directory name as a Tenant name: lowercased, other characters as `-`, trimmed. */
|
|
15
|
+
export declare function sanitizeTenantName(raw: string): string;
|
|
16
|
+
/** `tenant.json` in a Host root: the Tenant's name and the project it was created for. */
|
|
17
|
+
export interface TenantRecord {
|
|
17
18
|
format: 1;
|
|
18
19
|
name: string;
|
|
19
|
-
/** The project directory `nylorun start` created the
|
|
20
|
+
/** The project directory `nylorun start` created the Tenant in; absent outside a project. */
|
|
20
21
|
project?: string;
|
|
21
22
|
}
|
|
22
|
-
export declare function
|
|
23
|
-
export declare function
|
|
24
|
-
export declare function
|
|
25
|
-
export interface
|
|
23
|
+
export declare function tenantRecordPath(root: string): string;
|
|
24
|
+
export declare function readTenantRecord(root: string): Promise<TenantRecord | undefined>;
|
|
25
|
+
export declare function writeTenantRecord(root: string, record: Omit<TenantRecord, "format">): Promise<void>;
|
|
26
|
+
export interface TenantEntry {
|
|
26
27
|
name: string;
|
|
27
28
|
root: string;
|
|
28
|
-
record
|
|
29
|
+
record: TenantRecord;
|
|
29
30
|
}
|
|
30
|
-
/** The stacks under `<base>/stacks`, by name. */
|
|
31
|
-
export declare function listStacks(base: string): Promise<StackEntry[]>;
|
|
32
31
|
/**
|
|
33
|
-
* The
|
|
34
|
-
*
|
|
32
|
+
* The Tenants under `<base>/tenants`, by name: directories with a `tenant.json` (releases
|
|
33
|
+
* before 0.4 kept other directories there).
|
|
34
|
+
*/
|
|
35
|
+
export declare function listTenants(base: string): Promise<TenantEntry[]>;
|
|
36
|
+
/**
|
|
37
|
+
* The Tenant name for a project without a link: the directory's name, or the first `-2`,
|
|
38
|
+
* `-3`, … suffix that no Tenant of another project directory holds. A Tenant this project
|
|
35
39
|
* directory created keeps its name.
|
|
36
40
|
*/
|
|
37
|
-
export declare function
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
export declare const LEGACY_ENTRIES: readonly ["host.json", "host-credentials.json", "host-state.json", "stack", "tenants", "home", "tmp", "trash"];
|
|
41
|
+
export declare function chooseTenantName(base: string, projectDir: string): Promise<string>;
|
|
42
|
+
/**
|
|
43
|
+
* Move the Host roots of nylorun 0.4 (`~/.nylorun/stacks/<name>/`) to `tenants/<name>/`, when
|
|
44
|
+
* that is free: `stack.json` becomes `tenant.json`, and `NYLORUN_STACK_NAME` becomes
|
|
45
|
+
* `NYLORUN_TENANT_NAME` in `docker/.env` (and `docker/compose.yaml`, until the next start
|
|
46
|
+
* rewrites it). `stacks/` goes when empty. They move rather than start fresh because Compose
|
|
47
|
+
* project names do not change: a fresh Host root under the same name would reuse the old
|
|
48
|
+
* volumes with new keys.
|
|
49
|
+
*/
|
|
50
|
+
export declare function moveStackRoots(base: string, err: (line: string) => void): Promise<void>;
|
package/dist/stack/stacks.js
CHANGED
|
@@ -1,47 +1,52 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { readdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readdir, readFile, rename, rm, rmdir, writeFile } from "node:fs/promises";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
6
6
|
import { CliError } from "../errors.js";
|
|
7
7
|
/**
|
|
8
|
-
* The machine's local
|
|
9
|
-
* Host root `~/.nylorun/
|
|
10
|
-
* `~/.nylorun` itself held the single stack of older releases (the legacy stack).
|
|
8
|
+
* The machine's local Tenants (tenancy.md §6): one installation per Tenant, each with its own
|
|
9
|
+
* Host root `~/.nylorun/tenants/<name>/`, Compose project `nylorun-<name>`, ports and volumes.
|
|
11
10
|
*/
|
|
12
|
-
/**
|
|
13
|
-
export const
|
|
14
|
-
/**
|
|
11
|
+
/** The Tenant a command acts on outside a project when nothing names one. */
|
|
12
|
+
export const DEFAULT_TENANT = "default";
|
|
13
|
+
/** A Tenant name: also part of its Compose project, so Compose's rules apply. */
|
|
14
|
+
export const TENANT_NAME = /^[a-z0-9][a-z0-9_-]*$/;
|
|
15
|
+
/** `~/.nylorun`: the Tenants and the machine's settings. Tests pass another directory. */
|
|
15
16
|
export function defaultNylorunRoot() {
|
|
16
17
|
return resolve(join(homedir(), ".nylorun"));
|
|
17
18
|
}
|
|
18
|
-
export function
|
|
19
|
-
return join(base, "
|
|
19
|
+
export function tenantsDir(base) {
|
|
20
|
+
return join(base, "tenants");
|
|
20
21
|
}
|
|
21
|
-
export function
|
|
22
|
-
return join(
|
|
22
|
+
export function tenantRoot(base, name) {
|
|
23
|
+
return join(tenantsDir(base), name);
|
|
23
24
|
}
|
|
24
|
-
export function
|
|
25
|
-
|
|
25
|
+
export function assertTenantName(name, source) {
|
|
26
|
+
// A Tenant id, such as a NYLORUN_TENANT kept from an older release.
|
|
27
|
+
if (/^tn_/i.test(name))
|
|
28
|
+
throw new CliError(`${source} must be a Tenant's name, not a Tenant id: ${name}. "nylorun ls" lists the Tenants on this machine.`, 2);
|
|
29
|
+
if (!TENANT_NAME.test(name))
|
|
26
30
|
throw new CliError(`${source} must be lowercase letters, digits, "-" or "_", starting with a letter or digit: ${name}`, 2);
|
|
27
31
|
return name;
|
|
28
32
|
}
|
|
29
|
-
/** A directory name as a
|
|
30
|
-
export function
|
|
33
|
+
/** A directory name as a Tenant name: lowercased, other characters as `-`, trimmed. */
|
|
34
|
+
export function sanitizeTenantName(raw) {
|
|
31
35
|
const name = raw
|
|
32
36
|
.toLowerCase()
|
|
33
37
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
34
38
|
.replace(/-{2,}/g, "-")
|
|
35
|
-
.replace(/^[-_]+|[-_]+$/g, "")
|
|
36
|
-
|
|
39
|
+
.replace(/^[-_]+|[-_]+$/g, "")
|
|
40
|
+
.replace(/^tn_/, "tn-");
|
|
41
|
+
return name === "" ? "tenant" : name;
|
|
37
42
|
}
|
|
38
|
-
export function
|
|
39
|
-
return join(root, "
|
|
43
|
+
export function tenantRecordPath(root) {
|
|
44
|
+
return join(root, "tenant.json");
|
|
40
45
|
}
|
|
41
|
-
export async function
|
|
46
|
+
export async function readTenantRecord(root) {
|
|
42
47
|
try {
|
|
43
|
-
const value = JSON.parse(await readFile(
|
|
44
|
-
if (typeof value?.name !== "string" || !
|
|
48
|
+
const value = JSON.parse(await readFile(tenantRecordPath(root), "utf8"));
|
|
49
|
+
if (typeof value?.name !== "string" || !TENANT_NAME.test(value.name))
|
|
45
50
|
return undefined;
|
|
46
51
|
return {
|
|
47
52
|
format: 1,
|
|
@@ -53,64 +58,93 @@ export async function readStackRecord(root) {
|
|
|
53
58
|
return undefined;
|
|
54
59
|
}
|
|
55
60
|
}
|
|
56
|
-
export async function
|
|
57
|
-
const path =
|
|
61
|
+
export async function writeTenantRecord(root, record) {
|
|
62
|
+
const path = tenantRecordPath(root);
|
|
58
63
|
const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
|
|
59
64
|
const body = { format: 1, name: record.name, ...(record.project ? { project: record.project } : {}) };
|
|
60
65
|
await writeFile(temporary, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 });
|
|
61
66
|
await rename(temporary, path);
|
|
62
67
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
68
|
+
/**
|
|
69
|
+
* The Tenants under `<base>/tenants`, by name: directories with a `tenant.json` (releases
|
|
70
|
+
* before 0.4 kept other directories there).
|
|
71
|
+
*/
|
|
72
|
+
export async function listTenants(base) {
|
|
65
73
|
let entries;
|
|
66
74
|
try {
|
|
67
|
-
entries = await readdir(
|
|
75
|
+
entries = await readdir(tenantsDir(base), { withFileTypes: true });
|
|
68
76
|
}
|
|
69
77
|
catch {
|
|
70
78
|
return [];
|
|
71
79
|
}
|
|
72
|
-
const
|
|
80
|
+
const tenants = [];
|
|
73
81
|
for (const entry of entries) {
|
|
74
|
-
if (!entry.isDirectory() || !
|
|
82
|
+
if (!entry.isDirectory() || !TENANT_NAME.test(entry.name))
|
|
75
83
|
continue;
|
|
76
|
-
const root =
|
|
77
|
-
const record = await
|
|
78
|
-
|
|
84
|
+
const root = tenantRoot(base, entry.name);
|
|
85
|
+
const record = await readTenantRecord(root);
|
|
86
|
+
if (record)
|
|
87
|
+
tenants.push({ name: entry.name, root, record });
|
|
79
88
|
}
|
|
80
|
-
return
|
|
89
|
+
return tenants.sort((a, b) => a.name.localeCompare(b.name));
|
|
81
90
|
}
|
|
82
91
|
/**
|
|
83
|
-
* The
|
|
84
|
-
* `-3`, … suffix that no
|
|
92
|
+
* The Tenant name for a project without a link: the directory's name, or the first `-2`,
|
|
93
|
+
* `-3`, … suffix that no Tenant of another project directory holds. A Tenant this project
|
|
85
94
|
* directory created keeps its name.
|
|
86
95
|
*/
|
|
87
|
-
export async function
|
|
88
|
-
const name =
|
|
89
|
-
const
|
|
96
|
+
export async function chooseTenantName(base, projectDir) {
|
|
97
|
+
const name = sanitizeTenantName(basename(projectDir));
|
|
98
|
+
const tenants = await listTenants(base);
|
|
90
99
|
const candidate = new RegExp(`^${name}(?:-[0-9]+)?$`);
|
|
91
|
-
const own =
|
|
100
|
+
const own = tenants.find((tenant) => candidate.test(tenant.name) && tenant.record.project === projectDir);
|
|
92
101
|
if (own)
|
|
93
102
|
return own.name;
|
|
94
103
|
for (let suffix = 1;; suffix += 1) {
|
|
95
104
|
const next = suffix === 1 ? name : `${name}-${suffix}`;
|
|
96
|
-
if (!existsSync(
|
|
105
|
+
if (!existsSync(tenantRoot(base, next)))
|
|
97
106
|
return next;
|
|
98
107
|
}
|
|
99
108
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Move the Host roots of nylorun 0.4 (`~/.nylorun/stacks/<name>/`) to `tenants/<name>/`, when
|
|
111
|
+
* that is free: `stack.json` becomes `tenant.json`, and `NYLORUN_STACK_NAME` becomes
|
|
112
|
+
* `NYLORUN_TENANT_NAME` in `docker/.env` (and `docker/compose.yaml`, until the next start
|
|
113
|
+
* rewrites it). `stacks/` goes when empty. They move rather than start fresh because Compose
|
|
114
|
+
* project names do not change: a fresh Host root under the same name would reuse the old
|
|
115
|
+
* volumes with new keys.
|
|
116
|
+
*/
|
|
117
|
+
export async function moveStackRoots(base, err) {
|
|
118
|
+
const stacks = join(base, "stacks");
|
|
119
|
+
let names;
|
|
120
|
+
try {
|
|
121
|
+
names = (await readdir(stacks, { withFileTypes: true }))
|
|
122
|
+
.filter((entry) => entry.isDirectory() && TENANT_NAME.test(entry.name))
|
|
123
|
+
.map((entry) => entry.name);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const moved = [];
|
|
129
|
+
for (const name of names.sort()) {
|
|
130
|
+
const root = tenantRoot(base, name);
|
|
131
|
+
if (existsSync(root))
|
|
132
|
+
continue;
|
|
133
|
+
await mkdir(tenantsDir(base), { recursive: true, mode: 0o700 });
|
|
134
|
+
await rename(join(stacks, name), root);
|
|
135
|
+
await rename(join(root, "stack.json"), tenantRecordPath(root)).catch(() => { });
|
|
136
|
+
for (const file of [join(root, "docker", ".env"), join(root, "docker", "compose.yaml")]) {
|
|
137
|
+
const text = await readFile(file, "utf8").catch(() => undefined);
|
|
138
|
+
if (text?.includes("NYLORUN_STACK_NAME"))
|
|
139
|
+
await writeFile(file, text.replaceAll("NYLORUN_STACK_NAME", "NYLORUN_TENANT_NAME"));
|
|
140
|
+
}
|
|
141
|
+
moved.push(name);
|
|
142
|
+
}
|
|
143
|
+
// A marker file of 0.4.
|
|
144
|
+
await rm(join(stacks, ".legacy-noted"), { force: true });
|
|
145
|
+
await rmdir(stacks).catch(() => { });
|
|
146
|
+
if (moved.length) {
|
|
147
|
+
const tilde = (path) => path.startsWith(homedir() + sep) ? `~${path.slice(homedir().length)}` : path;
|
|
148
|
+
err(`Moved Tenants ${moved.join(", ")} from ${tilde(stacks)} to ${tilde(tenantsDir(base))}.`);
|
|
149
|
+
}
|
|
105
150
|
}
|
|
106
|
-
/** What `nylorun legacy delete` removes from `~/.nylorun`: the old layout only, never `stacks/`. */
|
|
107
|
-
export const LEGACY_ENTRIES = [
|
|
108
|
-
"host.json",
|
|
109
|
-
"host-credentials.json",
|
|
110
|
-
"host-state.json",
|
|
111
|
-
"stack",
|
|
112
|
-
"tenants",
|
|
113
|
-
"home",
|
|
114
|
-
"tmp",
|
|
115
|
-
"trash",
|
|
116
|
-
];
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
-
/** Studio's origin
|
|
2
|
+
/** Studio's published origin: always `localhost` (the cookie's host). */
|
|
3
3
|
export declare function studioOrigin(port: number): string;
|
|
4
4
|
/**
|
|
5
5
|
* Ask the Studio container for a single-use login token with the admin key
|
|
6
|
-
* (`POST /_studio/login-tokens`, Studio §6)
|
|
6
|
+
* (`POST /_studio/login-tokens`, Studio §6) on `origin` (its published port, or
|
|
7
|
+
* the proxy's `http://<name>.localhost:<port>`) and return the URL to open there.
|
|
7
8
|
* Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
|
|
8
9
|
*/
|
|
9
10
|
export declare function mintStudioLogin(input: {
|
|
10
11
|
fetch: FetchLike;
|
|
11
|
-
|
|
12
|
+
origin: string;
|
|
12
13
|
adminKey: string;
|
|
13
14
|
timeoutMs?: number;
|
|
14
15
|
}): Promise<string>;
|