@buildepicshit/cli 0.0.2 → 0.0.4

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,97 @@
1
+ import { cmd } from "../core/command.js";
2
+ import type {
3
+ BuiltCommand,
4
+ ComputeContext,
5
+ EnvSource,
6
+ HealthCheck,
7
+ HealthCheckCallback,
8
+ ICommand,
9
+ IdentityAccessor,
10
+ Runnable,
11
+ Token,
12
+ } from "../core/types.js";
13
+
14
+ export interface INextjs extends ICommand {}
15
+
16
+ /** @deprecated Use `INextjs` instead */
17
+ export type NextPreset = INextjs;
18
+
19
+ class Nextjs implements INextjs {
20
+ readonly __type = "command" as const;
21
+ private readonly inner: ICommand;
22
+
23
+ constructor(inner: ICommand) {
24
+ this.inner = inner;
25
+ }
26
+
27
+ // ─── ICommand delegation (returns INextjs for chaining) ─────────────
28
+
29
+ get identity(): IdentityAccessor {
30
+ const innerAccessor = this.inner.identity;
31
+ const self = this;
32
+ const setter = (suffix: string): INextjs => {
33
+ return new Nextjs(self.inner.identity(suffix));
34
+ };
35
+ Object.defineProperty(setter, "name", {
36
+ configurable: true,
37
+ value: innerAccessor.name,
38
+ });
39
+ return Object.assign(setter, {
40
+ localhostUrl: innerAccessor.localhostUrl,
41
+ port: innerAccessor.port,
42
+ url: innerAccessor.url,
43
+ }) as IdentityAccessor;
44
+ }
45
+
46
+ flag(name: string, value?: Token): INextjs {
47
+ return new Nextjs(this.inner.flag(name, value));
48
+ }
49
+
50
+ arg(value: Token): INextjs {
51
+ return new Nextjs(this.inner.arg(value));
52
+ }
53
+
54
+ env(source: EnvSource): INextjs {
55
+ return new Nextjs(this.inner.env(source));
56
+ }
57
+
58
+ dir(path: string): INextjs {
59
+ return new Nextjs(this.inner.dir(path));
60
+ }
61
+
62
+ waitFor(check: HealthCheck | HealthCheckCallback): INextjs {
63
+ return new Nextjs(this.inner.waitFor(check));
64
+ }
65
+
66
+ as(name: string): INextjs {
67
+ return new Nextjs(this.inner.as(name));
68
+ }
69
+
70
+ dependsOn(...deps: Runnable[]): INextjs {
71
+ return new Nextjs(this.inner.dependsOn(...deps));
72
+ }
73
+
74
+ collectNames(): string[] {
75
+ return this.inner.collectNames();
76
+ }
77
+
78
+ build(ctx: ComputeContext): Promise<BuiltCommand> {
79
+ return this.inner.build(ctx);
80
+ }
81
+
82
+ run(): Promise<void> {
83
+ return this.inner.run();
84
+ }
85
+ }
86
+
87
+ export function nextjs(): INextjs {
88
+ // Base: `next` with PORT from identity. Mode (.arg("dev"/"start"/"build"))
89
+ // and health checks are added by the consumer.
90
+ const inner = cmd("next").env((self) => ({
91
+ PORT: self.identity.port(),
92
+ }));
93
+ return new Nextjs(inner);
94
+ }
95
+
96
+ /** @deprecated Use `nextjs()` instead */
97
+ export const next = nextjs;
@@ -0,0 +1,12 @@
1
+ import { cmd } from "../core/command.js";
2
+ import type { ICommand } from "../core/types.js";
3
+
4
+ export function tsx(entry: string, watch?: boolean): ICommand {
5
+ let command = cmd("tsx");
6
+ if (watch) command = command.arg("watch");
7
+ return command.arg(entry);
8
+ }
9
+
10
+ export function nodemon(entry: string): ICommand {
11
+ return cmd("nodemon").arg(entry);
12
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createEventBus, type DevEvent } from "../event-bus.js";
3
+
4
+ describe("createEventBus", () => {
5
+ it("emits events to listeners", () => {
6
+ const bus = createEventBus();
7
+ const listener = vi.fn();
8
+ bus.on(listener);
9
+
10
+ const event: DevEvent = { type: "stack:ready" };
11
+ bus.emit(event);
12
+
13
+ expect(listener).toHaveBeenCalledWith(event);
14
+ });
15
+
16
+ it("supports multiple listeners", () => {
17
+ const bus = createEventBus();
18
+ const l1 = vi.fn();
19
+ const l2 = vi.fn();
20
+ bus.on(l1);
21
+ bus.on(l2);
22
+
23
+ bus.emit({ type: "stack:ready" });
24
+
25
+ expect(l1).toHaveBeenCalledOnce();
26
+ expect(l2).toHaveBeenCalledOnce();
27
+ });
28
+
29
+ it("removes listeners with off()", () => {
30
+ const bus = createEventBus();
31
+ const listener = vi.fn();
32
+ bus.on(listener);
33
+ bus.off(listener);
34
+
35
+ bus.emit({ type: "stack:ready" });
36
+
37
+ expect(listener).not.toHaveBeenCalled();
38
+ });
39
+
40
+ it("emits process:registered with meta", () => {
41
+ const bus = createEventBus();
42
+ const listener = vi.fn();
43
+ bus.on(listener);
44
+
45
+ bus.emit({
46
+ data: {
47
+ command: "next dev --port 3456",
48
+ cwd: "/apps/web",
49
+ id: "web:dev",
50
+ serviceName: "web",
51
+ },
52
+ type: "process:registered",
53
+ });
54
+
55
+ expect(listener).toHaveBeenCalledWith(
56
+ expect.objectContaining({ type: "process:registered" }),
57
+ );
58
+ });
59
+
60
+ it("emits log entries with timestamp", () => {
61
+ const bus = createEventBus();
62
+ const events: DevEvent[] = [];
63
+ bus.on((e) => events.push(e));
64
+
65
+ bus.emit({
66
+ data: {
67
+ id: "api:dev",
68
+ line: "Server listening on port 3000",
69
+ serviceName: "api",
70
+ stream: "stdout",
71
+ timestamp: Date.now(),
72
+ },
73
+ type: "log",
74
+ });
75
+
76
+ expect(events).toHaveLength(1);
77
+ expect(events[0].type).toBe("log");
78
+ });
79
+
80
+ it("emits stack:failed with error details", () => {
81
+ const bus = createEventBus();
82
+ const listener = vi.fn();
83
+ bus.on(listener);
84
+
85
+ bus.emit({
86
+ data: { error: "Process crashed", service: "api" },
87
+ type: "stack:failed",
88
+ });
89
+
90
+ expect(listener).toHaveBeenCalledWith(
91
+ expect.objectContaining({
92
+ data: { error: "Process crashed", service: "api" },
93
+ type: "stack:failed",
94
+ }),
95
+ );
96
+ });
97
+ });
@@ -0,0 +1,55 @@
1
+ import type { ProcessStatus } from "../core/types.js";
2
+
3
+ // ─── Event payload types ─────────────────────────────────────────────────────
4
+
5
+ export interface ProcessMeta {
6
+ command: string;
7
+ cwd: string;
8
+ id: string;
9
+ serviceName: string;
10
+ }
11
+
12
+ export interface LogEntry {
13
+ id: string;
14
+ line: string;
15
+ serviceName: string;
16
+ stream: "stderr" | "stdout";
17
+ timestamp: number;
18
+ }
19
+
20
+ // ─── Event union ─────────────────────────────────────────────────────────────
21
+
22
+ export type DevEvent =
23
+ | { data: LogEntry; type: "log" }
24
+ | { data: ProcessMeta; type: "process:registered" }
25
+ | { data: { id: string; status: ProcessStatus }; type: "process:status" }
26
+ | { data: { error: string; service: string }; type: "stack:failed" }
27
+ | { type: "stack:ready" };
28
+
29
+ // ─── EventBus ────────────────────────────────────────────────────────────────
30
+
31
+ type Listener = (event: DevEvent) => void;
32
+
33
+ export interface IEventBus {
34
+ emit(event: DevEvent): void;
35
+ off(listener: Listener): void;
36
+ on(listener: Listener): void;
37
+ }
38
+
39
+ export function createEventBus(): IEventBus {
40
+ const listeners = new Set<Listener>();
41
+
42
+ return {
43
+ emit(event: DevEvent): void {
44
+ for (const listener of listeners) {
45
+ listener(event);
46
+ }
47
+ },
48
+ off(listener: Listener): void {
49
+ listeners.delete(listener);
50
+ },
51
+ on(listener: Listener): void {
52
+ listeners.add(listener);
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,129 @@
1
+ import { createConnection } from "node:net";
2
+ import { resolveToken } from "../core/token.js";
3
+ import type { ComputeContext, HealthCheck } from "../core/types.js";
4
+ import type { OrchestratorProcess } from "./process.js";
5
+
6
+ interface HealthCheckOptions {
7
+ intervalMs?: number;
8
+ maxRetries?: number;
9
+ timeoutMs?: number;
10
+ }
11
+
12
+ const DEFAULT_INTERVAL = 1000;
13
+ const DEFAULT_TIMEOUT = 30_000;
14
+ const DEFAULT_RETRIES = 30;
15
+
16
+ export async function waitForHealthy(
17
+ check: HealthCheck,
18
+ ctx: ComputeContext,
19
+ proc: OrchestratorProcess,
20
+ options: HealthCheckOptions = {},
21
+ ): Promise<void> {
22
+ const interval = options.intervalMs ?? DEFAULT_INTERVAL;
23
+ const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT;
24
+ const maxRetries = options.maxRetries ?? DEFAULT_RETRIES;
25
+
26
+ const startTime = Date.now();
27
+ let retries = 0;
28
+
29
+ while (retries < maxRetries && Date.now() - startTime < timeout) {
30
+ // Bail if process died
31
+ if (proc.status === "failed" || proc.status === "completed") {
32
+ throw new Error(
33
+ `Process "${proc.serviceName}" exited before becoming healthy`,
34
+ );
35
+ }
36
+
37
+ try {
38
+ const healthy = await runCheck(check, ctx, proc);
39
+ if (healthy) return;
40
+ } catch {
41
+ // Check failed, retry
42
+ }
43
+
44
+ retries++;
45
+ await sleep(interval);
46
+ }
47
+
48
+ throw new Error(
49
+ `Health check for "${proc.serviceName}" timed out after ${timeout}ms (${retries} retries)`,
50
+ );
51
+ }
52
+
53
+ async function runCheck(
54
+ check: HealthCheck,
55
+ ctx: ComputeContext,
56
+ proc: OrchestratorProcess,
57
+ ): Promise<boolean> {
58
+ switch (check.type) {
59
+ case "tcp":
60
+ return checkTcp(
61
+ await resolveToken(check.host, ctx),
62
+ Number.parseInt(await resolveToken(check.port, ctx), 10),
63
+ );
64
+
65
+ case "http": {
66
+ const resolvedUrl = await resolveToken(check.url, ctx);
67
+ return checkHttp(resolvedUrl, check.status);
68
+ }
69
+
70
+ case "stdout":
71
+ return checkStdout(check.pattern, proc);
72
+
73
+ case "exec": {
74
+ const built = await check.command.build(ctx);
75
+ const fullCmd = [built.command, ...built.args].join(" ");
76
+ const { execa } = await import("execa");
77
+ const result = await execa({ reject: false, shell: true })`${fullCmd}`;
78
+ return result.exitCode === 0;
79
+ }
80
+
81
+ case "custom":
82
+ return check.check();
83
+
84
+ default:
85
+ throw new Error(
86
+ `Unknown health check type: ${(check as { type: string }).type}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ function checkTcp(host: string, port: number): Promise<boolean> {
92
+ return new Promise((resolve) => {
93
+ const socket = createConnection({ host, port }, () => {
94
+ socket.destroy();
95
+ resolve(true);
96
+ });
97
+ socket.on("error", () => {
98
+ socket.destroy();
99
+ resolve(false);
100
+ });
101
+ socket.setTimeout(2000, () => {
102
+ socket.destroy();
103
+ resolve(false);
104
+ });
105
+ });
106
+ }
107
+
108
+ async function checkHttp(
109
+ url: string,
110
+ expectedStatus?: number,
111
+ ): Promise<boolean> {
112
+ try {
113
+ const response = await fetch(url, {
114
+ signal: AbortSignal.timeout(2000),
115
+ });
116
+ if (expectedStatus) return response.status === expectedStatus;
117
+ return response.ok;
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function checkStdout(pattern: RegExp, proc: OrchestratorProcess): boolean {
124
+ return proc.outputLines.some((line) => pattern.test(line));
125
+ }
126
+
127
+ function sleep(ms: number): Promise<void> {
128
+ return new Promise((resolve) => setTimeout(resolve, ms));
129
+ }
@@ -0,0 +1,17 @@
1
+ export {
2
+ createEventBus,
3
+ type DevEvent,
4
+ type IEventBus,
5
+ type LogEntry,
6
+ type ProcessMeta,
7
+ } from "./event-bus.js";
8
+ export { waitForHealthy } from "./health-runner.js";
9
+ export { type OrchestratorProcess, spawnProcess } from "./process.js";
10
+ export {
11
+ clearRuntimeContext,
12
+ createDeferred,
13
+ type Deferred,
14
+ getRuntimeContext,
15
+ type RuntimeContext,
16
+ setRuntimeContext,
17
+ } from "./runtime-context.js";
@@ -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
+ }