@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.
- package/package.json +6 -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
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { basename, resolve } from "node:path";
|
|
2
|
+
import { waitForHealthy } from "../runner/health-runner.js";
|
|
3
|
+
import { spawnProcess } from "../runner/process.js";
|
|
4
|
+
import {
|
|
5
|
+
createDeferred,
|
|
6
|
+
getRuntimeContext,
|
|
7
|
+
} from "../runner/runtime-context.js";
|
|
8
|
+
import type { Identity } from "../utils/dna.js";
|
|
9
|
+
import { createIdentity } from "../utils/dna.js";
|
|
10
|
+
import { resolveToken } from "./token.js";
|
|
11
|
+
import {
|
|
12
|
+
type BuiltCommand,
|
|
13
|
+
type ComputeContext,
|
|
14
|
+
type EnvSource,
|
|
15
|
+
type HealthCheck,
|
|
16
|
+
type HealthCheckCallback,
|
|
17
|
+
type ICommand,
|
|
18
|
+
isEnvCallback,
|
|
19
|
+
isLazyEnvSource,
|
|
20
|
+
type Runnable,
|
|
21
|
+
type Token,
|
|
22
|
+
} from "./types.js";
|
|
23
|
+
|
|
24
|
+
interface CommandState {
|
|
25
|
+
readonly args: readonly Token[];
|
|
26
|
+
readonly base: string;
|
|
27
|
+
readonly dependencies: readonly Runnable[];
|
|
28
|
+
readonly dirPath: string | null;
|
|
29
|
+
readonly displayName: string | null;
|
|
30
|
+
readonly envSources: readonly EnvSource[];
|
|
31
|
+
readonly flags: readonly { name: string; value?: Token }[];
|
|
32
|
+
readonly healthCheck: HealthCheck | null;
|
|
33
|
+
readonly identitySuffix: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
class Command implements ICommand {
|
|
37
|
+
readonly __type = "command" as const;
|
|
38
|
+
private readonly state: CommandState;
|
|
39
|
+
|
|
40
|
+
constructor(state: CommandState) {
|
|
41
|
+
this.state = state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
identity(): Identity;
|
|
45
|
+
identity(suffix: string): ICommand;
|
|
46
|
+
identity(suffix?: string): Identity | ICommand {
|
|
47
|
+
if (suffix !== undefined) {
|
|
48
|
+
return new Command({
|
|
49
|
+
...this.state,
|
|
50
|
+
identitySuffix: suffix,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return createIdentity(this.state.identitySuffix ?? this.deriveName());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
flag(name: string, value?: Token): ICommand {
|
|
57
|
+
return new Command({
|
|
58
|
+
...this.state,
|
|
59
|
+
flags: [...this.state.flags, { name, value }],
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
arg(value: Token): ICommand {
|
|
64
|
+
return new Command({
|
|
65
|
+
...this.state,
|
|
66
|
+
args: [...this.state.args, value],
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
env(source: EnvSource): ICommand {
|
|
71
|
+
if (isEnvCallback(source)) {
|
|
72
|
+
const resolved = source(this);
|
|
73
|
+
return new Command({
|
|
74
|
+
...this.state,
|
|
75
|
+
envSources: [...this.state.envSources, resolved],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return new Command({
|
|
79
|
+
...this.state,
|
|
80
|
+
envSources: [...this.state.envSources, source],
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
dir(path: string): ICommand {
|
|
85
|
+
return new Command({
|
|
86
|
+
...this.state,
|
|
87
|
+
dirPath: path,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
waitFor(check: HealthCheck | HealthCheckCallback): ICommand {
|
|
92
|
+
const resolved = typeof check === "function" ? check(this) : check;
|
|
93
|
+
return new Command({
|
|
94
|
+
...this.state,
|
|
95
|
+
healthCheck: resolved,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
as(name: string): ICommand {
|
|
100
|
+
return new Command({
|
|
101
|
+
...this.state,
|
|
102
|
+
displayName: name,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
dependsOn(...deps: Runnable[]): ICommand {
|
|
107
|
+
return new Command({
|
|
108
|
+
...this.state,
|
|
109
|
+
dependencies: [...this.state.dependencies, ...deps],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
collectNames(): string[] {
|
|
114
|
+
return [this.deriveName()];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async build(ctx: ComputeContext): Promise<BuiltCommand> {
|
|
118
|
+
const args: string[] = [];
|
|
119
|
+
|
|
120
|
+
for (const flag of this.state.flags) {
|
|
121
|
+
if (flag.value !== undefined) {
|
|
122
|
+
const resolved = await resolveToken(flag.value, ctx);
|
|
123
|
+
args.push(`--${flag.name}`, resolved);
|
|
124
|
+
} else {
|
|
125
|
+
args.push(`--${flag.name}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const arg of this.state.args) {
|
|
130
|
+
const resolved = await resolveToken(arg, ctx);
|
|
131
|
+
if (resolved) args.push(resolved);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Resolve env: inherited sources first, then own sources (own wins)
|
|
135
|
+
const env: Record<string, string> = {};
|
|
136
|
+
for (const source of this.state.envSources) {
|
|
137
|
+
await mergeEnvSource(env, source, ctx);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
args,
|
|
142
|
+
command: this.state.base,
|
|
143
|
+
cwd: ctx.cwd,
|
|
144
|
+
env,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async run(): Promise<void> {
|
|
149
|
+
const rtx = getRuntimeContext();
|
|
150
|
+
const name = this.deriveName();
|
|
151
|
+
|
|
152
|
+
// Register a ready promise so dependents can wait on us
|
|
153
|
+
const deferred = createDeferred();
|
|
154
|
+
rtx.readyPromises.set(name, deferred);
|
|
155
|
+
|
|
156
|
+
// Wait for dependencies to be ready before starting
|
|
157
|
+
await this.waitForDependencies();
|
|
158
|
+
|
|
159
|
+
// Resolve cwd: own dir > inherited dir > root
|
|
160
|
+
const dirPath = this.state.dirPath ?? rtx.inheritedDir;
|
|
161
|
+
const cwd = dirPath ? resolve(rtx.root, dirPath) : rtx.root;
|
|
162
|
+
|
|
163
|
+
const computeCtx: ComputeContext = {
|
|
164
|
+
cwd,
|
|
165
|
+
env: { ...(process.env as Record<string, string>) },
|
|
166
|
+
root: rtx.root,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Resolve env: inherited sources first (outermost to innermost),
|
|
170
|
+
// then own sources. Later sources override earlier ones.
|
|
171
|
+
const allEnvSources = [...rtx.inheritedEnv, ...this.state.envSources];
|
|
172
|
+
|
|
173
|
+
for (const source of allEnvSources) {
|
|
174
|
+
await mergeEnvSource(computeCtx.env, source, computeCtx);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const built = await this.build(computeCtx);
|
|
178
|
+
|
|
179
|
+
if (rtx.dryRun) {
|
|
180
|
+
const fullCmd = [built.command, ...built.args].join(" ");
|
|
181
|
+
rtx.logger.system(` ${name}`);
|
|
182
|
+
rtx.logger.system(` cmd: ${fullCmd}`);
|
|
183
|
+
rtx.logger.system(` cwd: ${built.cwd}`);
|
|
184
|
+
const envKeys = Object.keys(built.env);
|
|
185
|
+
if (envKeys.length > 0) {
|
|
186
|
+
rtx.logger.system(` env: ${JSON.stringify(built.env)}`);
|
|
187
|
+
}
|
|
188
|
+
if (this.state.healthCheck) {
|
|
189
|
+
rtx.logger.system(` waitFor: ${this.state.healthCheck.type}`);
|
|
190
|
+
}
|
|
191
|
+
if (this.state.dependencies.length > 0) {
|
|
192
|
+
const depNames = this.state.dependencies.flatMap((d) =>
|
|
193
|
+
d.collectNames(),
|
|
194
|
+
);
|
|
195
|
+
rtx.logger.system(` dependsOn: ${depNames.join(", ")}`);
|
|
196
|
+
}
|
|
197
|
+
rtx.logger.system("");
|
|
198
|
+
deferred.resolve();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
rtx.logger.starting(name, name);
|
|
203
|
+
|
|
204
|
+
const proc = spawnProcess(name, name, built);
|
|
205
|
+
rtx.processes.set(name, proc);
|
|
206
|
+
|
|
207
|
+
rtx.eventBus.emit({
|
|
208
|
+
data: {
|
|
209
|
+
command: [built.command, ...built.args].join(" "),
|
|
210
|
+
cwd: built.cwd,
|
|
211
|
+
id: name,
|
|
212
|
+
serviceName: name,
|
|
213
|
+
},
|
|
214
|
+
type: "process:registered",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
proc.onOutput((line, stream) => {
|
|
218
|
+
rtx.logger.output(name, line, stream);
|
|
219
|
+
rtx.eventBus.emit({
|
|
220
|
+
data: {
|
|
221
|
+
id: name,
|
|
222
|
+
line,
|
|
223
|
+
serviceName: name,
|
|
224
|
+
stream,
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
},
|
|
227
|
+
type: "log",
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (this.state.healthCheck) {
|
|
232
|
+
try {
|
|
233
|
+
await waitForHealthy(this.state.healthCheck, computeCtx, proc);
|
|
234
|
+
} catch (err) {
|
|
235
|
+
rtx.logger.output(
|
|
236
|
+
name,
|
|
237
|
+
err instanceof Error ? err.message : String(err),
|
|
238
|
+
"stderr",
|
|
239
|
+
);
|
|
240
|
+
throw err;
|
|
241
|
+
}
|
|
242
|
+
proc.setStatus("healthy");
|
|
243
|
+
rtx.logger.healthy(name);
|
|
244
|
+
} else {
|
|
245
|
+
await proc.waitFor("started");
|
|
246
|
+
rtx.logger.ready(name);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Signal that this command is ready — dependents can proceed
|
|
250
|
+
deferred.resolve();
|
|
251
|
+
|
|
252
|
+
rtx.eventBus.emit({
|
|
253
|
+
data: { id: name, status: proc.status },
|
|
254
|
+
type: "process:status",
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private async waitForDependencies(): Promise<void> {
|
|
259
|
+
if (this.state.dependencies.length === 0) return;
|
|
260
|
+
|
|
261
|
+
const rtx = getRuntimeContext();
|
|
262
|
+
|
|
263
|
+
for (const dep of this.state.dependencies) {
|
|
264
|
+
for (const depName of dep.collectNames()) {
|
|
265
|
+
// Poll until the dependency registers its ready promise
|
|
266
|
+
let ready = rtx.readyPromises.get(depName);
|
|
267
|
+
while (!ready) {
|
|
268
|
+
await sleep(50);
|
|
269
|
+
ready = rtx.readyPromises.get(depName);
|
|
270
|
+
}
|
|
271
|
+
await ready.promise;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
private deriveName(): string {
|
|
277
|
+
if (this.state.displayName) return this.state.displayName;
|
|
278
|
+
if (this.state.dirPath) return basename(this.state.dirPath);
|
|
279
|
+
return this.state.base.split(" ")[0];
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function mergeEnvSource(
|
|
284
|
+
target: Record<string, string>,
|
|
285
|
+
source: EnvSource,
|
|
286
|
+
ctx: ComputeContext,
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
if (isEnvCallback(source)) {
|
|
289
|
+
// Should not happen — callbacks are resolved eagerly in Command.env()
|
|
290
|
+
// Defensive: skip
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (isLazyEnvSource(source)) {
|
|
294
|
+
const resolved = await source.resolve(ctx);
|
|
295
|
+
Object.assign(target, resolved);
|
|
296
|
+
} else {
|
|
297
|
+
for (const [key, token] of Object.entries(
|
|
298
|
+
source as Record<string, Token>,
|
|
299
|
+
)) {
|
|
300
|
+
target[key] = await resolveToken(token, ctx);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function sleep(ms: number): Promise<void> {
|
|
306
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function cmd(base: string): ICommand {
|
|
310
|
+
return new Command({
|
|
311
|
+
args: [],
|
|
312
|
+
base,
|
|
313
|
+
dependencies: [],
|
|
314
|
+
dirPath: null,
|
|
315
|
+
displayName: null,
|
|
316
|
+
envSources: [],
|
|
317
|
+
flags: [],
|
|
318
|
+
healthCheck: null,
|
|
319
|
+
identitySuffix: null,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { getRuntimeContext } from "../runner/runtime-context.js";
|
|
2
|
+
import type { EnvSource, ICompound, Runnable } from "./types.js";
|
|
3
|
+
|
|
4
|
+
interface CompoundState {
|
|
5
|
+
readonly dirPath: string | null;
|
|
6
|
+
readonly entries: readonly Runnable[];
|
|
7
|
+
readonly envSources: readonly EnvSource[];
|
|
8
|
+
readonly mode: "par" | "seq";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
class Compound implements ICompound {
|
|
12
|
+
readonly __type = "compound" as const;
|
|
13
|
+
readonly mode: "par" | "seq";
|
|
14
|
+
private readonly state: CompoundState;
|
|
15
|
+
|
|
16
|
+
constructor(state: CompoundState) {
|
|
17
|
+
this.state = state;
|
|
18
|
+
this.mode = state.mode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
env(source: EnvSource): ICompound {
|
|
22
|
+
return new Compound({
|
|
23
|
+
...this.state,
|
|
24
|
+
envSources: [...this.state.envSources, source],
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
dir(path: string): ICompound {
|
|
29
|
+
return new Compound({
|
|
30
|
+
...this.state,
|
|
31
|
+
dirPath: path,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
collectNames(): string[] {
|
|
36
|
+
return this.state.entries.flatMap((entry) => entry.collectNames());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async run(): Promise<void> {
|
|
40
|
+
const rtx = getRuntimeContext();
|
|
41
|
+
|
|
42
|
+
// Save parent inheritance state
|
|
43
|
+
const prevEnv = rtx.inheritedEnv;
|
|
44
|
+
const prevDir = rtx.inheritedDir;
|
|
45
|
+
|
|
46
|
+
// Push our env/dir onto the inheritance stack
|
|
47
|
+
rtx.inheritedEnv = [...prevEnv, ...this.state.envSources];
|
|
48
|
+
if (this.state.dirPath !== null) {
|
|
49
|
+
rtx.inheritedDir = this.state.dirPath;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
if (this.state.mode === "par") {
|
|
54
|
+
await Promise.all(this.state.entries.map((entry) => entry.run()));
|
|
55
|
+
} else {
|
|
56
|
+
for (const entry of this.state.entries) {
|
|
57
|
+
await entry.run();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
// Restore parent inheritance state
|
|
62
|
+
rtx.inheritedEnv = prevEnv;
|
|
63
|
+
rtx.inheritedDir = prevDir;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function par(...entries: Runnable[]): ICompound {
|
|
69
|
+
return new Compound({
|
|
70
|
+
dirPath: null,
|
|
71
|
+
entries,
|
|
72
|
+
envSources: [],
|
|
73
|
+
mode: "par",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function seq(...entries: Runnable[]): ICompound {
|
|
78
|
+
return new Compound({
|
|
79
|
+
dirPath: null,
|
|
80
|
+
entries,
|
|
81
|
+
envSources: [],
|
|
82
|
+
mode: "seq",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HealthCheck, ICommand, Token } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function tcpHealthCheck(host: Token, port: Token): HealthCheck {
|
|
4
|
+
return { host, port, type: "tcp" };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function httpHealthCheck(url: Token, status?: number): HealthCheck {
|
|
8
|
+
return { status, type: "http", url };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function stdoutHealthCheck(pattern: RegExp): HealthCheck {
|
|
12
|
+
return { pattern, type: "stdout" };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function execHealthCheck(command: ICommand): HealthCheck {
|
|
16
|
+
return { command, type: "exec" };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function customHealthCheck(check: () => Promise<boolean>): HealthCheck {
|
|
20
|
+
return { check, type: "custom" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Namespace for health check factories.
|
|
25
|
+
* Usage: health.http(url), health.tcp(host, port), health.stdout(pattern)
|
|
26
|
+
*/
|
|
27
|
+
export const health = {
|
|
28
|
+
custom: customHealthCheck,
|
|
29
|
+
exec: execHealthCheck,
|
|
30
|
+
http: httpHealthCheck,
|
|
31
|
+
stdout: stdoutHealthCheck,
|
|
32
|
+
tcp: tcpHealthCheck,
|
|
33
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Command builder
|
|
2
|
+
export { cmd } from "./command.js";
|
|
3
|
+
// Compound commands
|
|
4
|
+
export { par, seq } from "./compound.js";
|
|
5
|
+
// Health checks
|
|
6
|
+
export {
|
|
7
|
+
customHealthCheck,
|
|
8
|
+
execHealthCheck,
|
|
9
|
+
health,
|
|
10
|
+
httpHealthCheck,
|
|
11
|
+
stdoutHealthCheck,
|
|
12
|
+
tcpHealthCheck,
|
|
13
|
+
} from "./health-check.js";
|
|
14
|
+
// Tokens
|
|
15
|
+
export {
|
|
16
|
+
compute,
|
|
17
|
+
fromEnv,
|
|
18
|
+
fromFile,
|
|
19
|
+
fromPackageJson,
|
|
20
|
+
resolveToken,
|
|
21
|
+
} from "./token.js";
|
|
22
|
+
export type {
|
|
23
|
+
BuiltCommand,
|
|
24
|
+
ComputeContext,
|
|
25
|
+
EnvCallback,
|
|
26
|
+
EnvSource,
|
|
27
|
+
HealthCheck,
|
|
28
|
+
HealthCheckCallback,
|
|
29
|
+
ICommand,
|
|
30
|
+
ICompound,
|
|
31
|
+
LazyEnvSource,
|
|
32
|
+
ProcessStatus,
|
|
33
|
+
Runnable,
|
|
34
|
+
Token,
|
|
35
|
+
} from "./types.js";
|
|
36
|
+
// Types
|
|
37
|
+
export { isEnvCallback, isLazyEnvSource } from "./types.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import type { ComputeContext, LazyEnvSource, Token } from "./types.js";
|
|
4
|
+
|
|
5
|
+
export function compute(fn: (ctx: ComputeContext) => Promise<string>): Token {
|
|
6
|
+
return fn;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function fromEnv(key: string, fallback?: string): Token {
|
|
10
|
+
return async ({ env }: ComputeContext) => {
|
|
11
|
+
const value = env[key];
|
|
12
|
+
if (value !== undefined) return value;
|
|
13
|
+
if (fallback !== undefined) return fallback;
|
|
14
|
+
throw new Error(
|
|
15
|
+
`Environment variable "${key}" is not set and no fallback provided`,
|
|
16
|
+
);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function fromPackageJson(path: string, fallback?: string): Token {
|
|
21
|
+
return async ({ cwd }: ComputeContext) => {
|
|
22
|
+
const raw = await readFile(join(cwd, "package.json"), "utf8");
|
|
23
|
+
const pkg = JSON.parse(raw);
|
|
24
|
+
|
|
25
|
+
const value = path.split(".").reduce<unknown>((obj, key) => {
|
|
26
|
+
if (obj != null && typeof obj === "object") {
|
|
27
|
+
return (obj as Record<string, unknown>)[key];
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}, pkg);
|
|
31
|
+
|
|
32
|
+
if (value !== undefined) return String(value);
|
|
33
|
+
if (fallback !== undefined) return fallback;
|
|
34
|
+
throw new Error(
|
|
35
|
+
`package.json path "${path}" not found in ${cwd}/package.json and no fallback provided`,
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse .env file(s) relative to the command's cwd.
|
|
42
|
+
* Multiple files can be passed — later files override earlier ones.
|
|
43
|
+
*/
|
|
44
|
+
export function fromFile(...paths: string[]): LazyEnvSource {
|
|
45
|
+
return {
|
|
46
|
+
__type: "env-source",
|
|
47
|
+
resolve: async (ctx: ComputeContext) => {
|
|
48
|
+
const env: Record<string, string> = {};
|
|
49
|
+
|
|
50
|
+
for (const filePath of paths) {
|
|
51
|
+
const fullPath = resolve(ctx.cwd, filePath);
|
|
52
|
+
let content: string;
|
|
53
|
+
try {
|
|
54
|
+
content = await readFile(fullPath, "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
continue; // skip missing files silently
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const line of content.split("\n")) {
|
|
60
|
+
const trimmed = line.trim();
|
|
61
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
62
|
+
const eqIdx = trimmed.indexOf("=");
|
|
63
|
+
if (eqIdx === -1) continue;
|
|
64
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
65
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
66
|
+
// Strip surrounding quotes
|
|
67
|
+
if (
|
|
68
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
69
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
70
|
+
) {
|
|
71
|
+
value = value.slice(1, -1);
|
|
72
|
+
}
|
|
73
|
+
env[key] = value;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return env;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function resolveToken(
|
|
83
|
+
token: Token,
|
|
84
|
+
ctx: ComputeContext,
|
|
85
|
+
): Promise<string> {
|
|
86
|
+
if (typeof token === "string") return token;
|
|
87
|
+
return token(ctx);
|
|
88
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { Identity } from "../utils/dna.js";
|
|
2
|
+
|
|
3
|
+
// ─── Token ───────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type ComputeContext = {
|
|
6
|
+
cwd: string;
|
|
7
|
+
env: Record<string, string>;
|
|
8
|
+
root: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type Token = string | ((ctx: ComputeContext) => Promise<string>);
|
|
12
|
+
|
|
13
|
+
// ─── Env Source ──────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface LazyEnvSource {
|
|
16
|
+
__type: "env-source";
|
|
17
|
+
resolve: (ctx: ComputeContext) => Promise<Record<string, string>>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type EnvCallback = (
|
|
21
|
+
self: ICommand,
|
|
22
|
+
) => Record<string, Token> | LazyEnvSource;
|
|
23
|
+
|
|
24
|
+
export type EnvSource = Record<string, Token> | LazyEnvSource | EnvCallback;
|
|
25
|
+
|
|
26
|
+
export function isLazyEnvSource(
|
|
27
|
+
source: Record<string, Token> | LazyEnvSource,
|
|
28
|
+
): source is LazyEnvSource {
|
|
29
|
+
return (
|
|
30
|
+
typeof source === "object" &&
|
|
31
|
+
"__type" in source &&
|
|
32
|
+
source.__type === "env-source"
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function isEnvCallback(source: EnvSource): source is EnvCallback {
|
|
37
|
+
return typeof source === "function";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Command ─────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface BuiltCommand {
|
|
43
|
+
args: string[];
|
|
44
|
+
command: string;
|
|
45
|
+
cwd: string;
|
|
46
|
+
env: Record<string, string>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ICommand {
|
|
50
|
+
readonly __type: "command";
|
|
51
|
+
arg(value: Token): ICommand;
|
|
52
|
+
as(name: string): ICommand;
|
|
53
|
+
build(ctx: ComputeContext): Promise<BuiltCommand>;
|
|
54
|
+
collectNames(): string[];
|
|
55
|
+
dependsOn(...deps: Runnable[]): ICommand;
|
|
56
|
+
dir(path: string): ICommand;
|
|
57
|
+
env(source: EnvSource): ICommand;
|
|
58
|
+
flag(name: string, value?: Token): ICommand;
|
|
59
|
+
identity(): Identity;
|
|
60
|
+
identity(suffix: string): ICommand;
|
|
61
|
+
run(): Promise<void>;
|
|
62
|
+
waitFor(check: HealthCheck | HealthCheckCallback): ICommand;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ─── Compound ────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export interface ICompound {
|
|
68
|
+
readonly __type: "compound";
|
|
69
|
+
collectNames(): string[];
|
|
70
|
+
dir(path: string): ICompound;
|
|
71
|
+
env(source: EnvSource): ICompound;
|
|
72
|
+
readonly mode: "par" | "seq";
|
|
73
|
+
run(): Promise<void>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Runnable ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export type Runnable = ICommand | ICompound;
|
|
79
|
+
|
|
80
|
+
// ─── Health Check ────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
export type HealthCheckCallback = (self: ICommand) => HealthCheck;
|
|
83
|
+
|
|
84
|
+
export type HealthCheck =
|
|
85
|
+
| { type: "tcp"; host: Token; port: Token }
|
|
86
|
+
| { type: "http"; url: Token; status?: number }
|
|
87
|
+
| { type: "stdout"; pattern: RegExp }
|
|
88
|
+
| { type: "exec"; command: ICommand }
|
|
89
|
+
| { type: "custom"; check: () => Promise<boolean> };
|
|
90
|
+
|
|
91
|
+
// ─── Process ─────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
export type ProcessStatus =
|
|
94
|
+
| "completed"
|
|
95
|
+
| "failed"
|
|
96
|
+
| "healthy"
|
|
97
|
+
| "pending"
|
|
98
|
+
| "running"
|
|
99
|
+
| "starting";
|