@buildepicshit/cli 0.0.7 → 0.0.9
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/dist/bes.js +11 -1
- package/dist/bes.js.map +3 -3
- package/package.json +1 -1
- package/src/config-loader.ts +7 -1
- package/src/orchestrator/core/__tests__/command.test.ts +45 -35
- package/src/orchestrator/core/command.ts +28 -11
- package/src/orchestrator/core/types.ts +1 -0
- package/src/orchestrator/presets/docker.ts +4 -0
- package/src/orchestrator/presets/nextjs.ts +9 -3
- package/src/orchestrator/runner/health-runner.ts +5 -3
- package/src/orchestrator/utils/__tests__/dna.test.ts +68 -72
- package/src/orchestrator/utils/config-context.ts +14 -0
- package/src/orchestrator/utils/dna.ts +24 -20
- package/src/orchestrator/utils/port.ts +51 -0
package/package.json
CHANGED
package/src/config-loader.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { createJiti } from "jiti";
|
|
4
|
+
import { setConfigRoot } from "./orchestrator/utils/config-context.js";
|
|
4
5
|
|
|
5
6
|
const CONFIG_FILENAME = "bes.config.ts";
|
|
6
7
|
|
|
@@ -40,6 +41,11 @@ export async function loadConfig(
|
|
|
40
41
|
);
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
const root = dirname(configPath);
|
|
45
|
+
|
|
46
|
+
// Set root BEFORE evaluating config so identity() can compute ports eagerly
|
|
47
|
+
setConfigRoot(root);
|
|
48
|
+
|
|
43
49
|
const jiti = createJiti(configPath, {
|
|
44
50
|
fsCache: false,
|
|
45
51
|
interopDefault: true,
|
|
@@ -63,6 +69,6 @@ export async function loadConfig(
|
|
|
63
69
|
|
|
64
70
|
return {
|
|
65
71
|
commands,
|
|
66
|
-
root
|
|
72
|
+
root,
|
|
67
73
|
};
|
|
68
74
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { cmd } from "../command.js";
|
|
3
3
|
import { health } from "../health-check.js";
|
|
4
4
|
import { compute } from "../token.js";
|
|
5
5
|
import type { ComputeContext } from "../types.js";
|
|
6
|
+
import { setConfigRoot } from "../../utils/config-context.js";
|
|
6
7
|
|
|
7
8
|
const ctx: ComputeContext = {
|
|
8
9
|
cwd: "/app",
|
|
@@ -10,6 +11,10 @@ const ctx: ComputeContext = {
|
|
|
10
11
|
root: "/",
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
setConfigRoot("/app");
|
|
16
|
+
});
|
|
17
|
+
|
|
13
18
|
describe("cmd", () => {
|
|
14
19
|
it("builds a simple command", async () => {
|
|
15
20
|
const built = await cmd("next dev").build(ctx);
|
|
@@ -172,30 +177,24 @@ describe("command identity", () => {
|
|
|
172
177
|
);
|
|
173
178
|
});
|
|
174
179
|
|
|
175
|
-
it("identity proxy still exposes .port() and .url()",
|
|
180
|
+
it("identity proxy still exposes .port() and .url()", () => {
|
|
176
181
|
const c = cmd("node server.js").withIdentity("api");
|
|
177
182
|
expect(c.identity.name).toBe("api");
|
|
178
|
-
expect(typeof c.identity.port()).toBe("
|
|
179
|
-
expect(typeof c.identity.url("/health")).toBe("
|
|
180
|
-
expect(typeof c.identity.localhostUrl()).toBe("
|
|
183
|
+
expect(typeof c.identity.port()).toBe("string");
|
|
184
|
+
expect(typeof c.identity.url("/health")).toBe("string");
|
|
185
|
+
expect(typeof c.identity.localhostUrl()).toBe("string");
|
|
181
186
|
});
|
|
182
187
|
|
|
183
|
-
it("identity.port() returns a
|
|
188
|
+
it("identity.port() returns a string port number", () => {
|
|
184
189
|
const c = cmd("node server.js").withIdentity("api").dir("apps/api");
|
|
185
|
-
const
|
|
186
|
-
expect(typeof token).toBe("function");
|
|
187
|
-
const port = Number.parseInt(
|
|
188
|
-
await (token as (ctx: ComputeContext) => Promise<string>)(ctx),
|
|
189
|
-
10,
|
|
190
|
-
);
|
|
190
|
+
const port = Number.parseInt(c.identity.port(), 10);
|
|
191
191
|
expect(port).toBeGreaterThanOrEqual(3000);
|
|
192
192
|
expect(port).toBeLessThanOrEqual(9999);
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
-
it("identity.localhostUrl() returns a
|
|
195
|
+
it("identity.localhostUrl() returns a URL string", () => {
|
|
196
196
|
const c = cmd("node server.js").withIdentity("api");
|
|
197
|
-
const
|
|
198
|
-
const url = await (token as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
197
|
+
const url = c.identity.localhostUrl("/health");
|
|
199
198
|
expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
200
199
|
});
|
|
201
200
|
|
|
@@ -207,36 +206,22 @@ describe("command identity", () => {
|
|
|
207
206
|
expect(built.env.API_URL).toMatch(/^http:\/\/localhost:\d+$/);
|
|
208
207
|
});
|
|
209
208
|
|
|
210
|
-
it("identity.port(name) resolves a named sub-identity",
|
|
209
|
+
it("identity.port(name) resolves a named sub-identity", () => {
|
|
211
210
|
const c = cmd("docker compose").withIdentity("infra");
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
const dbPort = await (
|
|
215
|
-
dbPortToken as (ctx: ComputeContext) => Promise<string>
|
|
216
|
-
)(ctx);
|
|
217
|
-
const infraPort = await (
|
|
218
|
-
infraPortToken as (ctx: ComputeContext) => Promise<string>
|
|
219
|
-
)(ctx);
|
|
211
|
+
const dbPort = c.identity.port("database");
|
|
212
|
+
const infraPort = c.identity.port();
|
|
220
213
|
// Named sub-identity should produce a different port
|
|
221
214
|
expect(dbPort).not.toBe(infraPort);
|
|
222
215
|
expect(Number.parseInt(dbPort, 10)).toBeGreaterThanOrEqual(3000);
|
|
223
216
|
});
|
|
224
217
|
|
|
225
|
-
it("identity.localhostUrl with portIdentity option",
|
|
218
|
+
it("identity.localhostUrl with portIdentity option", () => {
|
|
226
219
|
const c = cmd("docker compose").withIdentity("infra");
|
|
227
|
-
const
|
|
228
|
-
portIdentity: "minio",
|
|
229
|
-
});
|
|
230
|
-
const url = await (urlToken as (ctx: ComputeContext) => Promise<string>)(
|
|
231
|
-
ctx,
|
|
232
|
-
);
|
|
220
|
+
const url = c.identity.localhostUrl("/health", { portIdentity: "minio" });
|
|
233
221
|
expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
234
222
|
|
|
235
223
|
// Port in the URL should match minio identity, not infra
|
|
236
|
-
const
|
|
237
|
-
const minioPort = await (
|
|
238
|
-
minioPortToken as (ctx: ComputeContext) => Promise<string>
|
|
239
|
-
)(ctx);
|
|
224
|
+
const minioPort = c.identity.port("minio");
|
|
240
225
|
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
241
226
|
});
|
|
242
227
|
});
|
|
@@ -326,3 +311,28 @@ describe("logs", () => {
|
|
|
326
311
|
expect(built2).toBeDefined();
|
|
327
312
|
});
|
|
328
313
|
});
|
|
314
|
+
|
|
315
|
+
describe("ensurePort", () => {
|
|
316
|
+
it("returns a new command instance (immutability)", () => {
|
|
317
|
+
const base = cmd("node server.js").withIdentity("api");
|
|
318
|
+
const withEnsure = base.ensurePort();
|
|
319
|
+
expect(withEnsure).not.toBe(base);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("propagates through builder chain", async () => {
|
|
323
|
+
const c = cmd("node server.js")
|
|
324
|
+
.withIdentity("api")
|
|
325
|
+
.ensurePort()
|
|
326
|
+
.dir("apps/api")
|
|
327
|
+
.env({ KEY: "value" })
|
|
328
|
+
.flag("verbose");
|
|
329
|
+
const built = await c.build(ctx);
|
|
330
|
+
expect(built).toBeDefined();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("builds without error when ensurePort is set", async () => {
|
|
334
|
+
const c = cmd("node server.js").withIdentity("api").ensurePort();
|
|
335
|
+
const built = await c.build(ctx);
|
|
336
|
+
expect(built.command).toBe("node server.js");
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getRuntimeContext,
|
|
7
7
|
} from "../runner/runtime-context.js";
|
|
8
8
|
import { createIdentity, type Identity } from "../utils/dna.js";
|
|
9
|
+
import { killProcessOnPort } from "../utils/port.js";
|
|
9
10
|
import { resolveToken } from "./token.js";
|
|
10
11
|
import {
|
|
11
12
|
type BuiltCommand,
|
|
@@ -26,6 +27,7 @@ interface CommandState {
|
|
|
26
27
|
readonly dependencies: readonly Runnable[];
|
|
27
28
|
readonly dirPath: string | null;
|
|
28
29
|
readonly displayName: string | null;
|
|
30
|
+
readonly ensurePort: boolean;
|
|
29
31
|
readonly envSources: readonly EnvSource[];
|
|
30
32
|
readonly flags: readonly { name: string; value?: Token }[];
|
|
31
33
|
readonly healthChecks: readonly HealthCheck[];
|
|
@@ -69,6 +71,10 @@ class Command implements ICommand {
|
|
|
69
71
|
return new Command({ ...this.state, identitySuffix: suffix });
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
ensurePort(): ICommand {
|
|
75
|
+
return new Command({ ...this.state, ensurePort: true });
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
logs(options: { silent?: boolean }): ICommand {
|
|
73
79
|
return new Command({
|
|
74
80
|
...this.state,
|
|
@@ -91,13 +97,6 @@ class Command implements ICommand {
|
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
env(source: EnvSource): ICommand {
|
|
94
|
-
if (isEnvCallback(source)) {
|
|
95
|
-
const resolved = source(this);
|
|
96
|
-
return new Command({
|
|
97
|
-
...this.state,
|
|
98
|
-
envSources: [...this.state.envSources, resolved],
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
100
|
return new Command({
|
|
102
101
|
...this.state,
|
|
103
102
|
envSources: [...this.state.envSources, source],
|
|
@@ -157,7 +156,7 @@ class Command implements ICommand {
|
|
|
157
156
|
// Resolve env: inherited sources first, then own sources (own wins)
|
|
158
157
|
const env: Record<string, string> = {};
|
|
159
158
|
for (const source of this.state.envSources) {
|
|
160
|
-
await mergeEnvSource(env, source, ctx);
|
|
159
|
+
await mergeEnvSource(env, source, ctx, this);
|
|
161
160
|
}
|
|
162
161
|
|
|
163
162
|
return {
|
|
@@ -194,7 +193,7 @@ class Command implements ICommand {
|
|
|
194
193
|
const allEnvSources = [...rtx.inheritedEnv, ...this.state.envSources];
|
|
195
194
|
|
|
196
195
|
for (const source of allEnvSources) {
|
|
197
|
-
await mergeEnvSource(computeCtx.env, source, computeCtx);
|
|
196
|
+
await mergeEnvSource(computeCtx.env, source, computeCtx, this);
|
|
198
197
|
}
|
|
199
198
|
|
|
200
199
|
const built = await this.build(computeCtx);
|
|
@@ -218,11 +217,26 @@ class Command implements ICommand {
|
|
|
218
217
|
);
|
|
219
218
|
rtx.logger.system(` dependsOn: ${depNames.join(", ")}`);
|
|
220
219
|
}
|
|
220
|
+
if (this.state.ensurePort) {
|
|
221
|
+
rtx.logger.system(` ensurePort: ${this.identity.port()}`);
|
|
222
|
+
}
|
|
221
223
|
rtx.logger.system("");
|
|
222
224
|
deferred.resolve();
|
|
223
225
|
return;
|
|
224
226
|
}
|
|
225
227
|
|
|
228
|
+
if (this.state.ensurePort) {
|
|
229
|
+
const port = Number.parseInt(this.identity.port(), 10);
|
|
230
|
+
const killed = await killProcessOnPort(port);
|
|
231
|
+
if (killed) {
|
|
232
|
+
rtx.logger.output(
|
|
233
|
+
name,
|
|
234
|
+
`Killed process ${killed} on port ${port}`,
|
|
235
|
+
"stderr",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
226
240
|
const fullCmd = [built.command, ...built.args].join(" ");
|
|
227
241
|
rtx.logger.starting(name, name);
|
|
228
242
|
rtx.logger.system(` ${fullCmd}`);
|
|
@@ -314,10 +328,12 @@ async function mergeEnvSource(
|
|
|
314
328
|
target: Record<string, string>,
|
|
315
329
|
source: EnvSource,
|
|
316
330
|
ctx: ComputeContext,
|
|
331
|
+
command?: ICommand,
|
|
317
332
|
): Promise<void> {
|
|
318
333
|
if (isEnvCallback(source)) {
|
|
319
|
-
|
|
320
|
-
|
|
334
|
+
if (!command) return;
|
|
335
|
+
const resolved = source(command);
|
|
336
|
+
await mergeEnvSource(target, resolved, ctx);
|
|
321
337
|
return;
|
|
322
338
|
}
|
|
323
339
|
if (isLazyEnvSource(source)) {
|
|
@@ -343,6 +359,7 @@ export function cmd(base: string): ICommand {
|
|
|
343
359
|
dependencies: [],
|
|
344
360
|
dirPath: null,
|
|
345
361
|
displayName: null,
|
|
362
|
+
ensurePort: false,
|
|
346
363
|
envSources: [],
|
|
347
364
|
flags: [],
|
|
348
365
|
healthChecks: [],
|
|
@@ -54,6 +54,7 @@ export interface ICommand {
|
|
|
54
54
|
collectNames(): string[];
|
|
55
55
|
dependsOn(...deps: Runnable[]): ICommand;
|
|
56
56
|
dir(path: string): ICommand;
|
|
57
|
+
ensurePort(): ICommand;
|
|
57
58
|
env(source: EnvSource): ICommand;
|
|
58
59
|
flag(name: string, value?: Token): ICommand;
|
|
59
60
|
readonly identity: Identity;
|
|
@@ -59,6 +59,10 @@ class DockerCompose implements IDockerCompose {
|
|
|
59
59
|
return new DockerCompose(this.inner.withIdentity(suffix), this.dcState);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
ensurePort(): IDockerCompose {
|
|
63
|
+
return new DockerCompose(this.inner.ensurePort(), this.dcState);
|
|
64
|
+
}
|
|
65
|
+
|
|
62
66
|
logs(options: { silent?: boolean }): IDockerCompose {
|
|
63
67
|
return new DockerCompose(this.inner.logs(options), this.dcState);
|
|
64
68
|
}
|
|
@@ -37,6 +37,10 @@ class Nextjs implements INextjs {
|
|
|
37
37
|
return new Nextjs(this.inner.withIdentity(suffix));
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
ensurePort(): INextjs {
|
|
41
|
+
return new Nextjs(this.inner.ensurePort());
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
logs(options: { silent?: boolean }): INextjs {
|
|
41
45
|
return new Nextjs(this.inner.logs(options));
|
|
42
46
|
}
|
|
@@ -85,9 +89,11 @@ class Nextjs implements INextjs {
|
|
|
85
89
|
export function nextjs(): INextjs {
|
|
86
90
|
// Base: `next` with PORT from identity. Mode (.arg("dev"/"start"/"build"))
|
|
87
91
|
// and health checks are added by the consumer.
|
|
88
|
-
const inner = cmd("next")
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
const inner = cmd("next")
|
|
93
|
+
.ensurePort()
|
|
94
|
+
.env((self) => ({
|
|
95
|
+
PORT: self.identity.port(),
|
|
96
|
+
}));
|
|
91
97
|
return new Nextjs(inner);
|
|
92
98
|
}
|
|
93
99
|
|
|
@@ -27,10 +27,12 @@ export async function waitForHealthy(
|
|
|
27
27
|
let retries = 0;
|
|
28
28
|
|
|
29
29
|
while (retries < maxRetries && Date.now() - startTime < timeout) {
|
|
30
|
-
// Bail if process died
|
|
31
|
-
if (proc.status === "failed"
|
|
30
|
+
// Bail if process died (but not if it completed cleanly — e.g. docker compose --detach)
|
|
31
|
+
if (proc.status === "failed") {
|
|
32
|
+
const tail = proc.outputLines.slice(-20).join("\n");
|
|
33
|
+
const output = tail ? `\n\nProcess output:\n${tail}` : "";
|
|
32
34
|
throw new Error(
|
|
33
|
-
`Process "${proc.serviceName}"
|
|
35
|
+
`Process "${proc.serviceName}" failed before becoming healthy${output}`,
|
|
34
36
|
);
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -1,133 +1,129 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { ComputeContext } from "../../core/types.js";
|
|
3
2
|
import { identity } from "../dna.js";
|
|
4
3
|
|
|
5
|
-
const
|
|
6
|
-
cwd: "/projects/myapp",
|
|
7
|
-
env: {},
|
|
8
|
-
root: "/projects/myapp",
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
async function resolveToken(
|
|
12
|
-
token: ReturnType<ReturnType<typeof identity>["port"]>,
|
|
13
|
-
context: ComputeContext,
|
|
14
|
-
): Promise<string> {
|
|
15
|
-
return typeof token === "function" ? token(context) : token;
|
|
16
|
-
}
|
|
4
|
+
const root = "/projects/myapp";
|
|
17
5
|
|
|
18
6
|
describe("DNA", () => {
|
|
19
|
-
it("port() returns a number in range 3000–9999",
|
|
20
|
-
const result =
|
|
7
|
+
it("port() returns a number in range 3000–9999", () => {
|
|
8
|
+
const result = identity("hono", root).port();
|
|
21
9
|
const port = Number.parseInt(result, 10);
|
|
22
10
|
expect(port).toBeGreaterThanOrEqual(3000);
|
|
23
11
|
expect(port).toBeLessThanOrEqual(9999);
|
|
24
12
|
});
|
|
25
13
|
|
|
26
|
-
it("port() is deterministic for same
|
|
27
|
-
const r1 =
|
|
28
|
-
const r2 =
|
|
14
|
+
it("port() is deterministic for same root + suffix", () => {
|
|
15
|
+
const r1 = identity("hono", root).port();
|
|
16
|
+
const r2 = identity("hono", root).port();
|
|
29
17
|
expect(r1).toBe(r2);
|
|
30
18
|
});
|
|
31
19
|
|
|
32
|
-
it("port() differs for different suffixes",
|
|
33
|
-
const r1 =
|
|
34
|
-
const r2 =
|
|
20
|
+
it("port() differs for different suffixes", () => {
|
|
21
|
+
const r1 = identity("hono", root).port();
|
|
22
|
+
const r2 = identity("next", root).port();
|
|
35
23
|
expect(r1).not.toBe(r2);
|
|
36
24
|
});
|
|
37
25
|
|
|
38
|
-
it("port() differs for different
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
const r2 = await resolveToken(identity("hono").port(), ctx2);
|
|
26
|
+
it("port() differs for different roots", () => {
|
|
27
|
+
const r1 = identity("hono", root).port();
|
|
28
|
+
const r2 = identity("hono", "/other/project").port();
|
|
42
29
|
expect(r1).not.toBe(r2);
|
|
43
30
|
});
|
|
44
31
|
|
|
45
|
-
it("url() returns http://localhost:{port}",
|
|
46
|
-
const result =
|
|
32
|
+
it("url() returns http://localhost:{port}", () => {
|
|
33
|
+
const result = identity("hono", root).url();
|
|
47
34
|
expect(result).toMatch(/^http:\/\/localhost:\d+$/);
|
|
48
35
|
});
|
|
49
36
|
|
|
50
|
-
it("url(path) appends the path",
|
|
51
|
-
const result =
|
|
37
|
+
it("url(path) appends the path", () => {
|
|
38
|
+
const result = identity("hono", root).url("/health");
|
|
52
39
|
expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
53
40
|
});
|
|
54
41
|
|
|
55
|
-
it("url() port matches port()",
|
|
56
|
-
const
|
|
57
|
-
const
|
|
42
|
+
it("url() port matches port()", () => {
|
|
43
|
+
const id = identity("hono", root);
|
|
44
|
+
const port = id.port();
|
|
45
|
+
const url = id.url("/api");
|
|
58
46
|
expect(url).toBe(`http://localhost:${port}/api`);
|
|
59
47
|
});
|
|
60
48
|
|
|
61
49
|
it("has a name property matching the suffix", () => {
|
|
62
|
-
expect(identity("server").name).toBe("server");
|
|
50
|
+
expect(identity("server", root).name).toBe("server");
|
|
63
51
|
});
|
|
64
52
|
|
|
65
53
|
it("has an empty name when no suffix is provided", () => {
|
|
66
|
-
expect(identity().name).toBe("");
|
|
54
|
+
expect(identity(undefined, root).name).toBe("");
|
|
67
55
|
});
|
|
68
56
|
|
|
69
|
-
it("localhostUrl() returns http://localhost:{port}",
|
|
70
|
-
const result =
|
|
57
|
+
it("localhostUrl() returns http://localhost:{port}", () => {
|
|
58
|
+
const result = identity("hono", root).localhostUrl();
|
|
71
59
|
expect(result).toMatch(/^http:\/\/localhost:\d+$/);
|
|
72
60
|
});
|
|
73
61
|
|
|
74
|
-
it("localhostUrl(path) appends the path",
|
|
75
|
-
const result =
|
|
76
|
-
identity("hono").localhostUrl("/health"),
|
|
77
|
-
ctx,
|
|
78
|
-
);
|
|
62
|
+
it("localhostUrl(path) appends the path", () => {
|
|
63
|
+
const result = identity("hono", root).localhostUrl("/health");
|
|
79
64
|
expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
80
65
|
});
|
|
81
66
|
|
|
82
|
-
it("localhostUrl() matches url() output",
|
|
83
|
-
const id = identity("hono");
|
|
84
|
-
const fromUrl =
|
|
85
|
-
const fromLocalhostUrl =
|
|
67
|
+
it("localhostUrl() matches url() output", () => {
|
|
68
|
+
const id = identity("hono", root);
|
|
69
|
+
const fromUrl = id.url("/api");
|
|
70
|
+
const fromLocalhostUrl = id.localhostUrl("/api");
|
|
86
71
|
expect(fromLocalhostUrl).toBe(fromUrl);
|
|
87
72
|
});
|
|
88
73
|
|
|
89
|
-
it("port(identityName) computes port for a different identity",
|
|
90
|
-
const id = identity("infra");
|
|
91
|
-
const ownPort =
|
|
92
|
-
const dbPort =
|
|
74
|
+
it("port(identityName) computes port for a different identity", () => {
|
|
75
|
+
const id = identity("infra", root);
|
|
76
|
+
const ownPort = id.port();
|
|
77
|
+
const dbPort = id.port("database");
|
|
93
78
|
expect(ownPort).not.toBe(dbPort);
|
|
94
79
|
const port = Number.parseInt(dbPort, 10);
|
|
95
80
|
expect(port).toBeGreaterThanOrEqual(3000);
|
|
96
81
|
expect(port).toBeLessThanOrEqual(9999);
|
|
97
82
|
});
|
|
98
83
|
|
|
99
|
-
it("port(identityName) is deterministic",
|
|
100
|
-
const id = identity("infra");
|
|
101
|
-
const r1 =
|
|
102
|
-
const r2 =
|
|
84
|
+
it("port(identityName) is deterministic", () => {
|
|
85
|
+
const id = identity("infra", root);
|
|
86
|
+
const r1 = id.port("database");
|
|
87
|
+
const r2 = id.port("database");
|
|
103
88
|
expect(r1).toBe(r2);
|
|
104
89
|
});
|
|
105
90
|
|
|
106
|
-
it("port(identityName) matches standalone identity port",
|
|
107
|
-
const infra = identity("infra");
|
|
108
|
-
const db = identity("database");
|
|
109
|
-
const fromSub =
|
|
110
|
-
const fromStandalone =
|
|
91
|
+
it("port(identityName) matches standalone identity port", () => {
|
|
92
|
+
const infra = identity("infra", root);
|
|
93
|
+
const db = identity("database", root);
|
|
94
|
+
const fromSub = infra.port("database");
|
|
95
|
+
const fromStandalone = db.port();
|
|
111
96
|
expect(fromSub).toBe(fromStandalone);
|
|
112
97
|
});
|
|
113
98
|
|
|
114
|
-
it("url with portIdentity option uses different port",
|
|
115
|
-
const id = identity("infra");
|
|
116
|
-
const minioPort =
|
|
117
|
-
const url =
|
|
118
|
-
id.url("/health", { portIdentity: "minio" }),
|
|
119
|
-
ctx,
|
|
120
|
-
);
|
|
99
|
+
it("url with portIdentity option uses different port", () => {
|
|
100
|
+
const id = identity("infra", root);
|
|
101
|
+
const minioPort = id.port("minio");
|
|
102
|
+
const url = id.url("/health", { portIdentity: "minio" });
|
|
121
103
|
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
122
104
|
});
|
|
123
105
|
|
|
124
|
-
it("localhostUrl with portIdentity option uses different port",
|
|
125
|
-
const id = identity("infra");
|
|
126
|
-
const minioPort =
|
|
127
|
-
const url =
|
|
128
|
-
id.localhostUrl("/health", { portIdentity: "minio" }),
|
|
129
|
-
ctx,
|
|
130
|
-
);
|
|
106
|
+
it("localhostUrl with portIdentity option uses different port", () => {
|
|
107
|
+
const id = identity("infra", root);
|
|
108
|
+
const minioPort = id.port("minio");
|
|
109
|
+
const url = id.localhostUrl("/health", { portIdentity: "minio" });
|
|
131
110
|
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
132
111
|
});
|
|
112
|
+
|
|
113
|
+
it("returns plain strings (not functions)", () => {
|
|
114
|
+
const id = identity("server", root);
|
|
115
|
+
expect(typeof id.port()).toBe("string");
|
|
116
|
+
expect(typeof id.url()).toBe("string");
|
|
117
|
+
expect(typeof id.localhostUrl()).toBe("string");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("works in template literals", () => {
|
|
121
|
+
const id = identity("database", root);
|
|
122
|
+
const url = `postgresql://user:pass@localhost:${id.port()}/mydb`;
|
|
123
|
+
expect(url).toMatch(/^postgresql:\/\/user:pass@localhost:\d+\/mydb$/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("throws when no root is available", () => {
|
|
127
|
+
expect(() => identity("test")).toThrow("no root available");
|
|
128
|
+
});
|
|
133
129
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared config context using globalThis so it survives across module
|
|
3
|
+
* instances (jiti creates separate module caches from Node's native one).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const KEY = "__bes_config_root__";
|
|
7
|
+
|
|
8
|
+
export function setConfigRoot(root: string): void {
|
|
9
|
+
(globalThis as Record<string, unknown>)[KEY] = root;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getConfigRoot(): string | null {
|
|
13
|
+
return ((globalThis as Record<string, unknown>)[KEY] as string) ?? null;
|
|
14
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import
|
|
2
|
+
import { getConfigRoot } from "./config-context.js";
|
|
3
3
|
|
|
4
4
|
// Well-known/reserved ports in the 3000-9999 range to avoid
|
|
5
5
|
const RESERVED_PORTS = new Set([
|
|
@@ -57,37 +57,41 @@ function computePort(
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export interface Identity {
|
|
60
|
-
localhostUrl: (path?: string, options?: { portIdentity?: string }) =>
|
|
60
|
+
localhostUrl: (path?: string, options?: { portIdentity?: string }) => string;
|
|
61
61
|
readonly name: string;
|
|
62
|
-
port: (identityName?: string) =>
|
|
63
|
-
url: (path?: string, options?: { portIdentity?: string }) =>
|
|
62
|
+
port: (identityName?: string) => string;
|
|
63
|
+
url: (path?: string, options?: { portIdentity?: string }) => string;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
export function createIdentity(suffix?: string): Identity {
|
|
66
|
+
export function createIdentity(suffix?: string, root?: string): Identity {
|
|
67
67
|
const name = suffix || "";
|
|
68
|
+
const resolvedRoot = root ?? getConfigRoot();
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
if (!resolvedRoot) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
"Cannot create identity: no root available. " +
|
|
73
|
+
"Ensure identity() is called during config evaluation or pass root explicitly.",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const urlFn = (
|
|
70
78
|
pathValue?: string,
|
|
71
79
|
options?: { portIdentity?: string },
|
|
72
|
-
):
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
return pathValue ? `${base}${pathValue}` : base;
|
|
78
|
-
};
|
|
80
|
+
): string => {
|
|
81
|
+
const portName = options?.portIdentity ?? name;
|
|
82
|
+
const port = computePort(resolvedRoot, portName);
|
|
83
|
+
const base = `http://localhost:${port}`;
|
|
84
|
+
return pathValue ? `${base}${pathValue}` : base;
|
|
79
85
|
};
|
|
80
86
|
|
|
81
87
|
return {
|
|
82
|
-
localhostUrl:
|
|
88
|
+
localhostUrl: urlFn,
|
|
83
89
|
name,
|
|
84
|
-
port: (identityName?: string):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
return computePort(cwd, portName).toString();
|
|
88
|
-
};
|
|
90
|
+
port: (identityName?: string): string => {
|
|
91
|
+
const portName = identityName ?? name;
|
|
92
|
+
return computePort(resolvedRoot, portName).toString();
|
|
89
93
|
},
|
|
90
|
-
url:
|
|
94
|
+
url: urlFn,
|
|
91
95
|
};
|
|
92
96
|
}
|
|
93
97
|
|