@buildepicshit/cli 0.0.1 → 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.
@@ -0,0 +1,167 @@
1
+ import { join } from "node:path";
2
+ import { execa, type ResultPromise } from "execa";
3
+ import type { BuiltCommand, ProcessStatus } from "../core/types.js";
4
+ import { getRuntimeContext } from "./runtime-context.js";
5
+
6
+ const KILL_TIMEOUT_MS = 5000;
7
+
8
+ export interface OrchestratorProcess {
9
+ readonly id: string;
10
+ kill(): Promise<void>;
11
+ onOutput(listener: (line: string, stream: "stderr" | "stdout") => void): void;
12
+ readonly outputLines: string[];
13
+ readonly serviceName: string;
14
+ setStatus(status: ProcessStatus): void;
15
+ readonly status: ProcessStatus;
16
+ waitFor(condition: "completed" | "healthy" | "started"): Promise<void>;
17
+ }
18
+
19
+ export function spawnProcess(
20
+ id: string,
21
+ serviceName: string,
22
+ built: BuiltCommand,
23
+ ): OrchestratorProcess {
24
+ let currentStatus: ProcessStatus = "starting";
25
+ const outputLines: string[] = [];
26
+ const outputListeners: Array<
27
+ (line: string, stream: "stderr" | "stdout") => void
28
+ > = [];
29
+ const waiters = new Map<string, Array<() => void>>();
30
+
31
+ function setStatus(s: ProcessStatus) {
32
+ currentStatus = s;
33
+ const callbacks = waiters.get(s);
34
+ if (callbacks) {
35
+ for (const cb of callbacks) cb();
36
+ waiters.delete(s);
37
+ }
38
+ }
39
+
40
+ const fullCommand = [built.command, ...built.args].join(" ");
41
+
42
+ // Prepend node_modules/.bin from both the process cwd and the workspace root
43
+ // to PATH. This mirrors `pnpm exec` behavior: local binaries (tsx, next, etc.)
44
+ // resolve from the pnpm store with correct module resolution context.
45
+ const rtx = getRuntimeContext();
46
+ const localBin = join(built.cwd, "node_modules", ".bin");
47
+ const rootBin = join(rtx.root, "node_modules", ".bin");
48
+ const currentPath = process.env.PATH ?? "";
49
+ const enhancedPath = `${localBin}:${rootBin}:${currentPath}`;
50
+
51
+ const child: ResultPromise = execa({
52
+ cwd: built.cwd,
53
+ env: { ...process.env, ...built.env, FORCE_COLOR: "1", PATH: enhancedPath },
54
+ reject: false,
55
+ shell: true,
56
+ stderr: "pipe",
57
+ stdout: "pipe",
58
+ })`${fullCommand}`;
59
+
60
+ setStatus("running");
61
+
62
+ child.stdout?.on("data", (data: Buffer) => {
63
+ const text = data.toString();
64
+ for (const line of text.split("\n").filter(Boolean)) {
65
+ outputLines.push(line);
66
+ for (const listener of outputListeners) {
67
+ listener(line, "stdout");
68
+ }
69
+ }
70
+ });
71
+
72
+ child.stderr?.on("data", (data: Buffer) => {
73
+ const text = data.toString();
74
+ for (const line of text.split("\n").filter(Boolean)) {
75
+ outputLines.push(line);
76
+ for (const listener of outputListeners) {
77
+ listener(line, "stderr");
78
+ }
79
+ }
80
+ });
81
+
82
+ child.then((result) => {
83
+ if (currentStatus !== "failed") {
84
+ setStatus(result.exitCode === 0 ? "completed" : "failed");
85
+ }
86
+ });
87
+
88
+ return {
89
+ id,
90
+
91
+ async kill() {
92
+ const s = currentStatus;
93
+ if (s === "completed" || s === "failed") return;
94
+
95
+ child.kill("SIGTERM");
96
+
97
+ await Promise.race([
98
+ child,
99
+ new Promise<void>((resolve) => setTimeout(resolve, KILL_TIMEOUT_MS)),
100
+ ]);
101
+
102
+ // Re-read: status may have changed during the await
103
+ const afterWait: ProcessStatus = currentStatus;
104
+ if (afterWait !== "completed" && afterWait !== "failed") {
105
+ child.kill("SIGKILL");
106
+ }
107
+ },
108
+
109
+ onOutput(listener) {
110
+ outputListeners.push(listener);
111
+ },
112
+ outputLines,
113
+ serviceName,
114
+ setStatus,
115
+
116
+ get status() {
117
+ return currentStatus;
118
+ },
119
+
120
+ async waitFor(condition) {
121
+ const satisfies = (s: ProcessStatus) => {
122
+ if (condition === "started") return s !== "pending" && s !== "starting";
123
+ if (condition === "healthy")
124
+ return s === "healthy" || s === "completed";
125
+ if (condition === "completed") return s === "completed";
126
+ return false;
127
+ };
128
+
129
+ if (satisfies(currentStatus)) return;
130
+ if (currentStatus === "failed") {
131
+ throw new Error(
132
+ `Process "${serviceName}" failed before reaching "${condition}"`,
133
+ );
134
+ }
135
+
136
+ return new Promise<void>((resolve, reject) => {
137
+ const tryResolve = (targetStatus: string) => {
138
+ const existing = waiters.get(targetStatus) ?? [];
139
+ existing.push(resolve);
140
+ waiters.set(targetStatus, existing);
141
+ };
142
+
143
+ if (condition === "started") {
144
+ tryResolve("running");
145
+ tryResolve("healthy");
146
+ tryResolve("completed");
147
+ } else if (condition === "healthy") {
148
+ tryResolve("healthy");
149
+ tryResolve("completed");
150
+ } else {
151
+ tryResolve("completed");
152
+ }
153
+
154
+ // Also listen for failure
155
+ const failCallbacks = waiters.get("failed") ?? [];
156
+ failCallbacks.push(() =>
157
+ reject(
158
+ new Error(
159
+ `Process "${serviceName}" failed before reaching "${condition}"`,
160
+ ),
161
+ ),
162
+ );
163
+ waiters.set("failed", failCallbacks);
164
+ });
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,51 @@
1
+ import type { EnvSource } from "../core/types.js";
2
+ import type { Logger } from "../logger/logger.js";
3
+ import type { IEventBus } from "./event-bus.js";
4
+ import type { OrchestratorProcess } from "./process.js";
5
+
6
+ export interface Deferred {
7
+ promise: Promise<void>;
8
+ resolve: () => void;
9
+ }
10
+
11
+ export function createDeferred(): Deferred {
12
+ let resolve!: () => void;
13
+ const promise = new Promise<void>((r) => {
14
+ resolve = r;
15
+ });
16
+ return { promise, resolve };
17
+ }
18
+
19
+ export interface RuntimeContext {
20
+ dryRun: boolean;
21
+ eventBus: IEventBus;
22
+ /** Dir inherited from nearest parent compound that set .dir() */
23
+ inheritedDir: string | null;
24
+ /** Env sources inherited from parent compounds (outermost first) */
25
+ inheritedEnv: EnvSource[];
26
+ logger: Logger;
27
+ processes: Map<string, OrchestratorProcess>;
28
+ /** Resolved when a command finishes its health check / startup */
29
+ readyPromises: Map<string, Deferred>;
30
+ root: string;
31
+ }
32
+
33
+ const CONTEXT_KEY = "__bes_runtime_context__";
34
+
35
+ export function setRuntimeContext(ctx: RuntimeContext): void {
36
+ (globalThis as Record<string, unknown>)[CONTEXT_KEY] = ctx;
37
+ }
38
+
39
+ export function getRuntimeContext(): RuntimeContext {
40
+ const ctx = (globalThis as Record<string, unknown>)[CONTEXT_KEY] as
41
+ | RuntimeContext
42
+ | undefined;
43
+ if (!ctx) {
44
+ throw new Error("No runtime context — are you running via the `bes` CLI?");
45
+ }
46
+ return ctx;
47
+ }
48
+
49
+ export function clearRuntimeContext(): void {
50
+ delete (globalThis as Record<string, unknown>)[CONTEXT_KEY];
51
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ComputeContext } from "../../core/types.js";
3
+ import { identity } from "../dna.js";
4
+
5
+ const ctx: ComputeContext = {
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
+ }
17
+
18
+ describe("DNA", () => {
19
+ it("port() returns a number in range 3000–9999", async () => {
20
+ const result = await resolveToken(identity("hono").port(), ctx);
21
+ const port = Number.parseInt(result, 10);
22
+ expect(port).toBeGreaterThanOrEqual(3000);
23
+ expect(port).toBeLessThanOrEqual(9999);
24
+ });
25
+
26
+ it("port() is deterministic for same cwd + suffix", async () => {
27
+ const r1 = await resolveToken(identity("hono").port(), ctx);
28
+ const r2 = await resolveToken(identity("hono").port(), ctx);
29
+ expect(r1).toBe(r2);
30
+ });
31
+
32
+ it("port() differs for different suffixes", async () => {
33
+ const r1 = await resolveToken(identity("hono").port(), ctx);
34
+ const r2 = await resolveToken(identity("next").port(), ctx);
35
+ expect(r1).not.toBe(r2);
36
+ });
37
+
38
+ it("port() differs for different cwds", async () => {
39
+ const ctx2: ComputeContext = { cwd: "/other/project", env: {}, root: "/" };
40
+ const r1 = await resolveToken(identity("hono").port(), ctx);
41
+ const r2 = await resolveToken(identity("hono").port(), ctx2);
42
+ expect(r1).not.toBe(r2);
43
+ });
44
+
45
+ it("url() returns http://localhost:{port}", async () => {
46
+ const result = await resolveToken(identity("hono").url(), ctx);
47
+ expect(result).toMatch(/^http:\/\/localhost:\d+$/);
48
+ });
49
+
50
+ it("url(path) appends the path", async () => {
51
+ const result = await resolveToken(identity("hono").url("/health"), ctx);
52
+ expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
53
+ });
54
+
55
+ it("url() port matches port()", async () => {
56
+ const port = await resolveToken(identity("hono").port(), ctx);
57
+ const url = await resolveToken(identity("hono").url("/api"), ctx);
58
+ expect(url).toBe(`http://localhost:${port}/api`);
59
+ });
60
+
61
+ it("has a name property matching the suffix", () => {
62
+ expect(identity("server").name).toBe("server");
63
+ });
64
+
65
+ it("has an empty name when no suffix is provided", () => {
66
+ expect(identity().name).toBe("");
67
+ });
68
+
69
+ it("localhostUrl() returns http://localhost:{port}", async () => {
70
+ const result = await resolveToken(identity("hono").localhostUrl(), ctx);
71
+ expect(result).toMatch(/^http:\/\/localhost:\d+$/);
72
+ });
73
+
74
+ it("localhostUrl(path) appends the path", async () => {
75
+ const result = await resolveToken(
76
+ identity("hono").localhostUrl("/health"),
77
+ ctx,
78
+ );
79
+ expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
80
+ });
81
+
82
+ it("localhostUrl() matches url() output", async () => {
83
+ const id = identity("hono");
84
+ const fromUrl = await resolveToken(id.url("/api"), ctx);
85
+ const fromLocalhostUrl = await resolveToken(id.localhostUrl("/api"), ctx);
86
+ expect(fromLocalhostUrl).toBe(fromUrl);
87
+ });
88
+ });
@@ -0,0 +1,44 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { Token } from "../core/types.js";
3
+
4
+ function computePort(
5
+ cwd: string,
6
+ suffix: string,
7
+ range = { max: 9999, min: 3000 },
8
+ ): number {
9
+ const hash = createHash("md5").update(`${cwd}:${suffix}`).digest("hex");
10
+ const num = Number.parseInt(hash.slice(0, 8), 16);
11
+ return range.min + (num % (range.max - range.min + 1));
12
+ }
13
+
14
+ export interface Identity {
15
+ localhostUrl: (path?: string) => Token;
16
+ readonly name: string;
17
+ port: () => Token;
18
+ url: (path?: string) => Token;
19
+ }
20
+
21
+ export function createIdentity(suffix?: string): Identity {
22
+ const name = suffix || "";
23
+
24
+ const urlToken = (pathValue?: string): Token => {
25
+ return async ({ cwd }) => {
26
+ const port = computePort(cwd, name);
27
+ const base = `http://localhost:${port}`;
28
+ return pathValue ? `${base}${pathValue}` : base;
29
+ };
30
+ };
31
+
32
+ return {
33
+ localhostUrl: urlToken,
34
+ name,
35
+ port: (): Token => {
36
+ return async ({ cwd }) => {
37
+ return computePort(cwd, name).toString();
38
+ };
39
+ },
40
+ url: urlToken,
41
+ };
42
+ }
43
+
44
+ export const identity = createIdentity;
package/src/project.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { basename, join } from "node:path";
4
+
5
+ export interface ProjectMeta {
6
+ uid: string;
7
+ }
8
+
9
+ function besDir(cwd: string): string {
10
+ return join(cwd, ".bes");
11
+ }
12
+
13
+ function projectMetaPath(cwd: string): string {
14
+ return join(besDir(cwd), "project.json");
15
+ }
16
+
17
+ export function readOrCreateProjectMeta(cwd: string): ProjectMeta {
18
+ const path = projectMetaPath(cwd);
19
+ if (existsSync(path)) {
20
+ return JSON.parse(readFileSync(path, "utf-8")) as ProjectMeta;
21
+ }
22
+ const meta: ProjectMeta = { uid: randomUUID() };
23
+ const dir = besDir(cwd);
24
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
25
+ writeFileSync(path, JSON.stringify(meta, null, "\t"));
26
+ return meta;
27
+ }
28
+
29
+ export function readProjectName(cwd: string): string {
30
+ const pkgPath = join(cwd, "package.json");
31
+ if (existsSync(pkgPath)) {
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as {
34
+ name?: string;
35
+ };
36
+ if (pkg.name) return pkg.name;
37
+ } catch {}
38
+ }
39
+ return basename(cwd);
40
+ }