@buildepicshit/cli 0.0.2 → 0.0.3
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/package.json +3 -2
- package/src/bes.ts +124 -0
- package/src/config-loader.ts +68 -0
- package/src/daemon.ts +127 -0
- package/src/orchestrator/core/__tests__/command.test.ts +222 -0
- package/src/orchestrator/core/__tests__/token.test.ts +145 -0
- package/src/orchestrator/core/command.ts +321 -0
- package/src/orchestrator/core/compound.ts +84 -0
- package/src/orchestrator/core/health-check.ts +33 -0
- package/src/orchestrator/core/index.ts +37 -0
- package/src/orchestrator/core/token.ts +88 -0
- package/src/orchestrator/core/types.ts +99 -0
- package/src/orchestrator/index.ts +62 -0
- package/src/orchestrator/logger/logger.ts +123 -0
- package/src/orchestrator/presets/docker.ts +26 -0
- package/src/orchestrator/presets/esbuild.ts +12 -0
- package/src/orchestrator/presets/hono.ts +7 -0
- package/src/orchestrator/presets/index.ts +5 -0
- package/src/orchestrator/presets/nextjs.ts +48 -0
- package/src/orchestrator/presets/node.ts +12 -0
- package/src/orchestrator/runner/__tests__/event-bus.test.ts +97 -0
- package/src/orchestrator/runner/event-bus.ts +55 -0
- package/src/orchestrator/runner/health-runner.ts +129 -0
- package/src/orchestrator/runner/index.ts +17 -0
- package/src/orchestrator/runner/process.ts +167 -0
- package/src/orchestrator/runner/runtime-context.ts +51 -0
- package/src/orchestrator/utils/__tests__/dna.test.ts +88 -0
- package/src/orchestrator/utils/dna.ts +44 -0
- package/src/project.ts +40 -0
package/package.json
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
".": "./src/orchestrator/index.ts"
|
|
20
20
|
},
|
|
21
21
|
"files": [
|
|
22
|
-
"dist"
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
23
24
|
],
|
|
24
25
|
"name": "@buildepicshit/cli",
|
|
25
26
|
"publishConfig": {
|
|
@@ -33,5 +34,5 @@
|
|
|
33
34
|
"typecheck": "tsc --noEmit"
|
|
34
35
|
},
|
|
35
36
|
"type": "module",
|
|
36
|
-
"version": "0.0.
|
|
37
|
+
"version": "0.0.3"
|
|
37
38
|
}
|
package/src/bes.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { loadConfig } from "./config-loader.js";
|
|
3
|
+
import { createLazyLogger } from "./orchestrator/logger/logger.js";
|
|
4
|
+
import { createEventBus } from "./orchestrator/runner/event-bus.js";
|
|
5
|
+
import {
|
|
6
|
+
clearRuntimeContext,
|
|
7
|
+
getRuntimeContext,
|
|
8
|
+
setRuntimeContext,
|
|
9
|
+
} from "./orchestrator/runner/runtime-context.js";
|
|
10
|
+
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name("bes")
|
|
15
|
+
.description("Build Epic Shit — dev process manager CLI")
|
|
16
|
+
.version("0.1.0")
|
|
17
|
+
.option("--dry", "Resolve all tokens and print commands without executing");
|
|
18
|
+
|
|
19
|
+
async function main() {
|
|
20
|
+
const { root, commands } = await loadConfig();
|
|
21
|
+
|
|
22
|
+
// Register each exported function as a CLI command
|
|
23
|
+
for (const [name, fn] of Object.entries(commands)) {
|
|
24
|
+
program
|
|
25
|
+
.command(name)
|
|
26
|
+
.description(`Run ${name}() from bes.config.ts`)
|
|
27
|
+
.action(async () => {
|
|
28
|
+
const isDry = program.opts().dry ?? false;
|
|
29
|
+
const logger = createLazyLogger();
|
|
30
|
+
const eventBus = createEventBus();
|
|
31
|
+
|
|
32
|
+
setRuntimeContext({
|
|
33
|
+
dryRun: isDry,
|
|
34
|
+
eventBus,
|
|
35
|
+
inheritedDir: null,
|
|
36
|
+
inheritedEnv: [],
|
|
37
|
+
logger,
|
|
38
|
+
processes: new Map(),
|
|
39
|
+
readyPromises: new Map(),
|
|
40
|
+
root,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const cleanup = async () => {
|
|
44
|
+
try {
|
|
45
|
+
const rtx = getRuntimeContext();
|
|
46
|
+
logger.system("Shutting down...");
|
|
47
|
+
const kills = [...rtx.processes.values()]
|
|
48
|
+
.reverse()
|
|
49
|
+
.map((p) => p.kill());
|
|
50
|
+
await Promise.allSettled(kills);
|
|
51
|
+
} catch {
|
|
52
|
+
// context may already be cleared
|
|
53
|
+
}
|
|
54
|
+
process.exit(0);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
process.on("SIGINT", cleanup);
|
|
58
|
+
process.on("SIGTERM", cleanup);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
if (isDry) {
|
|
62
|
+
logger.system("Dry run — resolving all commands:\n");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await fn();
|
|
66
|
+
|
|
67
|
+
// After fn() returns, wait for long-running processes
|
|
68
|
+
const rtx = getRuntimeContext();
|
|
69
|
+
const running = [...rtx.processes.values()].filter(
|
|
70
|
+
(p) => p.status === "running" || p.status === "healthy",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
if (running.length > 0) {
|
|
74
|
+
await Promise.all(
|
|
75
|
+
running.map(
|
|
76
|
+
(p) =>
|
|
77
|
+
new Promise<void>((resolve) => {
|
|
78
|
+
const check = () => {
|
|
79
|
+
if (p.status === "completed" || p.status === "failed") {
|
|
80
|
+
resolve();
|
|
81
|
+
} else {
|
|
82
|
+
setTimeout(check, 500);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
check();
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
} catch (error) {
|
|
91
|
+
logger.system(
|
|
92
|
+
`Error: ${error instanceof Error ? error.message : String(error)}`,
|
|
93
|
+
);
|
|
94
|
+
const rtx = getRuntimeContext();
|
|
95
|
+
const kills = [...rtx.processes.values()]
|
|
96
|
+
.reverse()
|
|
97
|
+
.map((p) => p.kill());
|
|
98
|
+
await Promise.allSettled(kills);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
} finally {
|
|
101
|
+
process.removeListener("SIGINT", cleanup);
|
|
102
|
+
process.removeListener("SIGTERM", cleanup);
|
|
103
|
+
clearRuntimeContext();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// No args → list available commands
|
|
109
|
+
if (process.argv.length <= 2) {
|
|
110
|
+
console.log("Available commands:\n");
|
|
111
|
+
for (const name of Object.keys(commands)) {
|
|
112
|
+
console.log(` bes ${name}`);
|
|
113
|
+
}
|
|
114
|
+
console.log("");
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
program.parse();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
main().catch((error) => {
|
|
122
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
123
|
+
process.exit(1);
|
|
124
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { createJiti } from "jiti";
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILENAME = "bes.config.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Walk up from `startDir` to find the nearest `bes.config.ts`.
|
|
9
|
+
*/
|
|
10
|
+
function findConfigFile(startDir: string): string | null {
|
|
11
|
+
let dir = resolve(startDir);
|
|
12
|
+
|
|
13
|
+
while (true) {
|
|
14
|
+
const candidate = join(dir, CONFIG_FILENAME);
|
|
15
|
+
if (existsSync(candidate)) return candidate;
|
|
16
|
+
const parent = dirname(dir);
|
|
17
|
+
if (parent === dir) break; // reached filesystem root
|
|
18
|
+
dir = parent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ConfigResult {
|
|
25
|
+
commands: Record<string, () => Promise<void>>;
|
|
26
|
+
root: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Load `bes.config.ts` and discover all exported async functions.
|
|
31
|
+
* Each exported function becomes a CLI command.
|
|
32
|
+
*/
|
|
33
|
+
export async function loadConfig(
|
|
34
|
+
cwd: string = process.cwd(),
|
|
35
|
+
): Promise<ConfigResult> {
|
|
36
|
+
const configPath = findConfigFile(cwd);
|
|
37
|
+
if (!configPath) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Could not find ${CONFIG_FILENAME} in ${cwd} or any parent directory`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const jiti = createJiti(configPath, {
|
|
44
|
+
fsCache: false,
|
|
45
|
+
interopDefault: true,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const mod = (await jiti.import(configPath)) as Record<string, unknown>;
|
|
49
|
+
|
|
50
|
+
const commands: Record<string, () => Promise<void>> = {};
|
|
51
|
+
for (const [key, value] of Object.entries(mod)) {
|
|
52
|
+
if (key === "default") continue;
|
|
53
|
+
if (typeof value === "function") {
|
|
54
|
+
commands[key] = value as () => Promise<void>;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (Object.keys(commands).length === 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${CONFIG_FILENAME} must export at least one async function`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
commands,
|
|
66
|
+
root: dirname(configPath),
|
|
67
|
+
};
|
|
68
|
+
}
|
package/src/daemon.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FNV-1a hash producing a deterministic port in the ephemeral range 49152-65535.
|
|
7
|
+
* Avoids collisions with user script ports (3000-8999).
|
|
8
|
+
*/
|
|
9
|
+
export function deterministicServerPort(projectPath: string): number {
|
|
10
|
+
const input = `bes:server:${projectPath}`;
|
|
11
|
+
let hash = 2166136261;
|
|
12
|
+
for (let i = 0; i < input.length; i++) {
|
|
13
|
+
hash ^= input.charCodeAt(i);
|
|
14
|
+
hash = Math.imul(hash, 16777619) >>> 0;
|
|
15
|
+
}
|
|
16
|
+
return (hash % 16383) + 49152;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ServerInfo {
|
|
20
|
+
host: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
port: number;
|
|
23
|
+
url: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function besDir(): string {
|
|
27
|
+
return join(process.cwd(), ".bes");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function serverInfoPath(): string {
|
|
31
|
+
return join(besDir(), "server.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readServerInfo(): ServerInfo | null {
|
|
35
|
+
const path = serverInfoPath();
|
|
36
|
+
if (!existsSync(path)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ServerInfo;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isPidAlive(pid: number): boolean {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function healthCheck(url: string): Promise<boolean> {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${url}/health`, {
|
|
56
|
+
signal: AbortSignal.timeout(1000),
|
|
57
|
+
});
|
|
58
|
+
return res.ok;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function isServerRunning(): Promise<ServerInfo | null> {
|
|
65
|
+
const info = readServerInfo();
|
|
66
|
+
if (!info) return null;
|
|
67
|
+
if (!isPidAlive(info.pid)) return null;
|
|
68
|
+
const alive = await healthCheck(info.url);
|
|
69
|
+
return alive ? info : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function poll(url: string, maxMs: number): Promise<boolean> {
|
|
73
|
+
const start = Date.now();
|
|
74
|
+
while (Date.now() - start < maxMs) {
|
|
75
|
+
if (await healthCheck(url)) return true;
|
|
76
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function ensureServerRunning(): Promise<ServerInfo> {
|
|
82
|
+
const existing = await isServerRunning();
|
|
83
|
+
if (existing) return existing;
|
|
84
|
+
|
|
85
|
+
const port = process.env.BES_PORT
|
|
86
|
+
? Number(process.env.BES_PORT)
|
|
87
|
+
: deterministicServerPort(process.cwd());
|
|
88
|
+
const host = process.env.BES_HOST ?? "0.0.0.0";
|
|
89
|
+
const url = `http://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}`;
|
|
90
|
+
|
|
91
|
+
const child = spawn("npx", ["tsx", "apps/server/src/main.ts"], {
|
|
92
|
+
cwd: process.cwd(),
|
|
93
|
+
detached: true,
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
BES_CONFIG_DIR: process.cwd(),
|
|
97
|
+
BES_HOST: host,
|
|
98
|
+
BES_PORT: String(port),
|
|
99
|
+
},
|
|
100
|
+
stdio: "ignore",
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
child.unref();
|
|
104
|
+
|
|
105
|
+
if (!child.pid) {
|
|
106
|
+
throw new Error("Failed to spawn server process");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dir = besDir();
|
|
110
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
111
|
+
|
|
112
|
+
const info: ServerInfo = {
|
|
113
|
+
host,
|
|
114
|
+
pid: child.pid,
|
|
115
|
+
port,
|
|
116
|
+
url,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
writeFileSync(serverInfoPath(), JSON.stringify(info, null, "\t"));
|
|
120
|
+
|
|
121
|
+
const ready = await poll(url, 6000);
|
|
122
|
+
if (!ready) {
|
|
123
|
+
throw new Error("Server failed to start within 6 seconds");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return info;
|
|
127
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { cmd } from "../command.js";
|
|
3
|
+
import { health } from "../health-check.js";
|
|
4
|
+
import { compute } from "../token.js";
|
|
5
|
+
import type { ComputeContext } from "../types.js";
|
|
6
|
+
|
|
7
|
+
const ctx: ComputeContext = {
|
|
8
|
+
cwd: "/app",
|
|
9
|
+
env: { NODE_ENV: "production" },
|
|
10
|
+
root: "/",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe("cmd", () => {
|
|
14
|
+
it("builds a simple command", async () => {
|
|
15
|
+
const built = await cmd("next dev").build(ctx);
|
|
16
|
+
expect(built.command).toBe("next dev");
|
|
17
|
+
expect(built.args).toEqual([]);
|
|
18
|
+
expect(built.cwd).toBe("/app");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("adds positional args", async () => {
|
|
22
|
+
const built = await cmd("docker compose").arg("up").build(ctx);
|
|
23
|
+
expect(built.command).toBe("docker compose");
|
|
24
|
+
expect(built.args).toEqual(["up"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("adds boolean flags (no value)", async () => {
|
|
28
|
+
const built = await cmd("docker compose")
|
|
29
|
+
.arg("up")
|
|
30
|
+
.flag("detach")
|
|
31
|
+
.build(ctx);
|
|
32
|
+
expect(built.args).toEqual(["--detach", "up"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("adds flags with values", async () => {
|
|
36
|
+
const built = await cmd("next dev").flag("port", "3000").build(ctx);
|
|
37
|
+
expect(built.args).toEqual(["--port", "3000"]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("resolves compute tokens in flags", async () => {
|
|
41
|
+
const token = compute(async ({ env }) =>
|
|
42
|
+
env.NODE_ENV === "production" ? "8080" : "3000",
|
|
43
|
+
);
|
|
44
|
+
const built = await cmd("next dev").flag("port", token).build(ctx);
|
|
45
|
+
expect(built.args).toEqual(["--port", "8080"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("adds env vars via record", async () => {
|
|
49
|
+
const built = await cmd("node server.js")
|
|
50
|
+
.env({ DATABASE_URL: "postgres://localhost/db" })
|
|
51
|
+
.build(ctx);
|
|
52
|
+
expect(built.env).toEqual({ DATABASE_URL: "postgres://localhost/db" });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("merges multiple env sources", async () => {
|
|
56
|
+
const built = await cmd("node server.js")
|
|
57
|
+
.env({ A: "1" })
|
|
58
|
+
.env({ A: "overridden", B: "2" })
|
|
59
|
+
.build(ctx);
|
|
60
|
+
expect(built.env).toEqual({ A: "overridden", B: "2" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("resolves token values in env records", async () => {
|
|
64
|
+
const token = compute(async () => "resolved-value");
|
|
65
|
+
const built = await cmd("node server.js").env({ KEY: token }).build(ctx);
|
|
66
|
+
expect(built.env).toEqual({ KEY: "resolved-value" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("is immutable — each method returns a new instance", async () => {
|
|
70
|
+
const base = cmd("next");
|
|
71
|
+
const withDev = base.arg("dev");
|
|
72
|
+
const withPort = withDev.flag("port", "3000");
|
|
73
|
+
|
|
74
|
+
const built1 = await base.build(ctx);
|
|
75
|
+
const built2 = await withDev.build(ctx);
|
|
76
|
+
const built3 = await withPort.build(ctx);
|
|
77
|
+
|
|
78
|
+
expect(built1.args).toEqual([]);
|
|
79
|
+
expect(built2.args).toEqual(["dev"]);
|
|
80
|
+
expect(built3.args).toEqual(["--port", "3000", "dev"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("handles multiple flags and args in order", async () => {
|
|
84
|
+
const built = await cmd("docker compose")
|
|
85
|
+
.flag("file", "docker-compose.ci.yml")
|
|
86
|
+
.arg("up")
|
|
87
|
+
.flag("detach")
|
|
88
|
+
.build(ctx);
|
|
89
|
+
|
|
90
|
+
expect(built.args).toEqual([
|
|
91
|
+
"--file",
|
|
92
|
+
"docker-compose.ci.yml",
|
|
93
|
+
"--detach",
|
|
94
|
+
"up",
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("sets dir", () => {
|
|
99
|
+
const c = cmd("next dev").dir("apps/web");
|
|
100
|
+
expect(c.collectNames()).toEqual(["web"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("sets display name with .as()", () => {
|
|
104
|
+
const c = cmd("tsx watch src/main.ts").as("api");
|
|
105
|
+
expect(c.collectNames()).toEqual(["api"]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("derives name from base command when no dir or as", () => {
|
|
109
|
+
const c = cmd("tsx watch src/main.ts");
|
|
110
|
+
expect(c.collectNames()).toEqual(["tsx"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("derives name from dir basename", () => {
|
|
114
|
+
const c = cmd("tsx watch src/main.ts").dir("apps/api");
|
|
115
|
+
expect(c.collectNames()).toEqual(["api"]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("command identity", () => {
|
|
120
|
+
it("returns default identity from dir basename", () => {
|
|
121
|
+
const c = cmd("node server.js").dir("apps/api");
|
|
122
|
+
const id = c.identity();
|
|
123
|
+
expect(id.name).toBe("api");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("returns default identity from display name", () => {
|
|
127
|
+
const c = cmd("node server.js").as("my-service");
|
|
128
|
+
expect(c.identity().name).toBe("my-service");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("returns default identity from base command", () => {
|
|
132
|
+
const c = cmd("tsx watch src/main.ts");
|
|
133
|
+
expect(c.identity().name).toBe("tsx");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("overrides identity with .identity(suffix)", () => {
|
|
137
|
+
const c = cmd("node server.js").identity("custom").dir("apps/api");
|
|
138
|
+
expect(c.identity().name).toBe("custom");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("identity suffix propagates through builder chain", () => {
|
|
142
|
+
const c = cmd("node server.js")
|
|
143
|
+
.identity("api")
|
|
144
|
+
.dir("apps/server")
|
|
145
|
+
.env({ KEY: "value" })
|
|
146
|
+
.flag("verbose");
|
|
147
|
+
expect(c.identity().name).toBe("api");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("identity is immutable across branches", () => {
|
|
151
|
+
const base = cmd("node server.js");
|
|
152
|
+
const withId = base.identity("custom");
|
|
153
|
+
expect(base.identity().name).toBe("node");
|
|
154
|
+
expect(withId.identity().name).toBe("custom");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("identity().port() returns a token", async () => {
|
|
158
|
+
const c = cmd("node server.js").identity("api").dir("apps/api");
|
|
159
|
+
const token = c.identity().port();
|
|
160
|
+
expect(typeof token).toBe("function");
|
|
161
|
+
const port = Number.parseInt(
|
|
162
|
+
await (token as (ctx: ComputeContext) => Promise<string>)(ctx),
|
|
163
|
+
10,
|
|
164
|
+
);
|
|
165
|
+
expect(port).toBeGreaterThanOrEqual(3000);
|
|
166
|
+
expect(port).toBeLessThanOrEqual(9999);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("identity().localhostUrl() returns a token", async () => {
|
|
170
|
+
const c = cmd("node server.js").identity("api");
|
|
171
|
+
const token = c.identity().localhostUrl("/health");
|
|
172
|
+
const url = await (token as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
173
|
+
expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("cross-command identity access works", async () => {
|
|
177
|
+
const server = cmd("node server.js").identity("api").dir("apps/api");
|
|
178
|
+
const built = await cmd("next dev")
|
|
179
|
+
.env({ API_URL: server.identity().localhostUrl() })
|
|
180
|
+
.build(ctx);
|
|
181
|
+
expect(built.env.API_URL).toMatch(/^http:\/\/localhost:\d+$/);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("waitFor callback form", () => {
|
|
186
|
+
it("accepts a callback that receives the command", async () => {
|
|
187
|
+
const c = cmd("node server.js")
|
|
188
|
+
.identity("api")
|
|
189
|
+
.dir("apps/api")
|
|
190
|
+
.waitFor((self) => health.http(self.identity().url("/livez")));
|
|
191
|
+
|
|
192
|
+
const built = await c.build(ctx);
|
|
193
|
+
expect(built).toBeDefined();
|
|
194
|
+
expect(c.identity().name).toBe("api");
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("env callback form", () => {
|
|
199
|
+
it("accepts a callback that receives the command", async () => {
|
|
200
|
+
const c = cmd("node server.js")
|
|
201
|
+
.identity("api")
|
|
202
|
+
.env((self) => ({ PORT: self.identity().port() }));
|
|
203
|
+
|
|
204
|
+
const built = await c.build(ctx);
|
|
205
|
+
const port = Number.parseInt(built.env.PORT, 10);
|
|
206
|
+
expect(port).toBeGreaterThanOrEqual(3000);
|
|
207
|
+
expect(port).toBeLessThanOrEqual(9999);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("mixes callback env with record env", async () => {
|
|
211
|
+
const c = cmd("node server.js")
|
|
212
|
+
.identity("api")
|
|
213
|
+
.env({ A: "1" })
|
|
214
|
+
.env((self) => ({ PORT: self.identity().port() }))
|
|
215
|
+
.env({ B: "2" });
|
|
216
|
+
|
|
217
|
+
const built = await c.build(ctx);
|
|
218
|
+
expect(built.env.A).toBe("1");
|
|
219
|
+
expect(built.env.B).toBe("2");
|
|
220
|
+
expect(Number.parseInt(built.env.PORT, 10)).toBeGreaterThanOrEqual(3000);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { mkdir, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
compute,
|
|
7
|
+
fromEnv,
|
|
8
|
+
fromFile,
|
|
9
|
+
fromPackageJson,
|
|
10
|
+
resolveToken,
|
|
11
|
+
} from "../token.js";
|
|
12
|
+
import type { ComputeContext } from "../types.js";
|
|
13
|
+
|
|
14
|
+
const ctx: ComputeContext = {
|
|
15
|
+
cwd: "/test",
|
|
16
|
+
env: { NODE_ENV: "test", PORT: "4000" },
|
|
17
|
+
root: "/",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("resolveToken", () => {
|
|
21
|
+
it("resolves a plain string", async () => {
|
|
22
|
+
expect(await resolveToken("hello", ctx)).toBe("hello");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("resolves a compute function", async () => {
|
|
26
|
+
const token = compute(async ({ env }) => env.NODE_ENV);
|
|
27
|
+
expect(await resolveToken(token, ctx)).toBe("test");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("fromEnv", () => {
|
|
32
|
+
it("reads an env var", async () => {
|
|
33
|
+
const token = fromEnv("PORT");
|
|
34
|
+
expect(await resolveToken(token, ctx)).toBe("4000");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("uses fallback when var is missing", async () => {
|
|
38
|
+
const token = fromEnv("MISSING", "default");
|
|
39
|
+
expect(await resolveToken(token, ctx)).toBe("default");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("throws when var is missing and no fallback", async () => {
|
|
43
|
+
const token = fromEnv("MISSING");
|
|
44
|
+
await expect(resolveToken(token, ctx)).rejects.toThrow(
|
|
45
|
+
'Environment variable "MISSING" is not set',
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("fromPackageJson", () => {
|
|
51
|
+
let tempDir: string;
|
|
52
|
+
|
|
53
|
+
beforeAll(async () => {
|
|
54
|
+
tempDir = join(tmpdir(), `bes-test-${Date.now()}`);
|
|
55
|
+
await mkdir(tempDir, { recursive: true });
|
|
56
|
+
await writeFile(
|
|
57
|
+
join(tempDir, "package.json"),
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
config: { nested: { deep: "value" }, port: 3000 },
|
|
60
|
+
name: "test-pkg",
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterAll(async () => {
|
|
66
|
+
await rm(tempDir, { force: true, recursive: true });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reads a top-level field", async () => {
|
|
70
|
+
const token = fromPackageJson("name");
|
|
71
|
+
const tempCtx = { ...ctx, cwd: tempDir };
|
|
72
|
+
expect(await resolveToken(token, tempCtx)).toBe("test-pkg");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("reads a nested field via dot notation", async () => {
|
|
76
|
+
const token = fromPackageJson("config.port");
|
|
77
|
+
const tempCtx = { ...ctx, cwd: tempDir };
|
|
78
|
+
expect(await resolveToken(token, tempCtx)).toBe("3000");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("reads deeply nested fields", async () => {
|
|
82
|
+
const token = fromPackageJson("config.nested.deep");
|
|
83
|
+
const tempCtx = { ...ctx, cwd: tempDir };
|
|
84
|
+
expect(await resolveToken(token, tempCtx)).toBe("value");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("uses fallback when path is missing", async () => {
|
|
88
|
+
const token = fromPackageJson("missing.path", "fallback");
|
|
89
|
+
const tempCtx = { ...ctx, cwd: tempDir };
|
|
90
|
+
expect(await resolveToken(token, tempCtx)).toBe("fallback");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("throws when path is missing and no fallback", async () => {
|
|
94
|
+
const token = fromPackageJson("missing.path");
|
|
95
|
+
const tempCtx = { ...ctx, cwd: tempDir };
|
|
96
|
+
await expect(resolveToken(token, tempCtx)).rejects.toThrow(
|
|
97
|
+
'package.json path "missing.path" not found',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("fromFile", () => {
|
|
103
|
+
let tempDir: string;
|
|
104
|
+
|
|
105
|
+
beforeAll(async () => {
|
|
106
|
+
tempDir = join(tmpdir(), `bes-env-test-${Date.now()}`);
|
|
107
|
+
await mkdir(tempDir, { recursive: true });
|
|
108
|
+
await writeFile(
|
|
109
|
+
join(tempDir, ".env"),
|
|
110
|
+
"DB_HOST=localhost\nDB_PORT=5432\n# comment\n\nDB_NAME=mydb\n",
|
|
111
|
+
);
|
|
112
|
+
await writeFile(
|
|
113
|
+
join(tempDir, ".env.local"),
|
|
114
|
+
'DB_PORT=5433\nSECRET="quoted-value"\n',
|
|
115
|
+
);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterAll(async () => {
|
|
119
|
+
await rm(tempDir, { force: true, recursive: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("parses a .env file", async () => {
|
|
123
|
+
const source = fromFile(".env");
|
|
124
|
+
const result = await source.resolve({ ...ctx, cwd: tempDir });
|
|
125
|
+
expect(result).toEqual({
|
|
126
|
+
DB_HOST: "localhost",
|
|
127
|
+
DB_NAME: "mydb",
|
|
128
|
+
DB_PORT: "5432",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("merges multiple files (last wins)", async () => {
|
|
133
|
+
const source = fromFile(".env", ".env.local");
|
|
134
|
+
const result = await source.resolve({ ...ctx, cwd: tempDir });
|
|
135
|
+
expect(result.DB_PORT).toBe("5433");
|
|
136
|
+
expect(result.DB_HOST).toBe("localhost");
|
|
137
|
+
expect(result.SECRET).toBe("quoted-value");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("skips missing files silently", async () => {
|
|
141
|
+
const source = fromFile(".env.missing", ".env");
|
|
142
|
+
const result = await source.resolve({ ...ctx, cwd: tempDir });
|
|
143
|
+
expect(result.DB_HOST).toBe("localhost");
|
|
144
|
+
});
|
|
145
|
+
});
|