@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.
- package/package.json +5 -3
- 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 +264 -0
- package/src/orchestrator/core/__tests__/token.test.ts +145 -0
- package/src/orchestrator/core/command.ts +334 -0
- package/src/orchestrator/core/compound.ts +84 -0
- package/src/orchestrator/core/health-check.ts +38 -0
- package/src/orchestrator/core/index.ts +39 -0
- package/src/orchestrator/core/token.ts +88 -0
- package/src/orchestrator/core/types.ts +102 -0
- package/src/orchestrator/index.ts +67 -0
- package/src/orchestrator/logger/logger.ts +123 -0
- package/src/orchestrator/presets/docker.ts +164 -0
- package/src/orchestrator/presets/drizzle.ts +93 -0
- package/src/orchestrator/presets/esbuild.ts +12 -0
- package/src/orchestrator/presets/hono.ts +7 -0
- package/src/orchestrator/presets/index.ts +6 -0
- package/src/orchestrator/presets/nextjs.ts +97 -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 +133 -0
- package/src/orchestrator/utils/dna.ts +85 -0
- package/src/project.ts +40 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,334 @@
|
|
|
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 { createIdentity } from "../utils/dna.js";
|
|
9
|
+
import { resolveToken } from "./token.js";
|
|
10
|
+
import {
|
|
11
|
+
type BuiltCommand,
|
|
12
|
+
type ComputeContext,
|
|
13
|
+
type EnvSource,
|
|
14
|
+
type HealthCheck,
|
|
15
|
+
type HealthCheckCallback,
|
|
16
|
+
type ICommand,
|
|
17
|
+
type IdentityAccessor,
|
|
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 healthChecks: readonly HealthCheck[];
|
|
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
|
+
get identity(): IdentityAccessor {
|
|
45
|
+
const id = createIdentity(
|
|
46
|
+
this.state.identitySuffix ?? this.deriveName(),
|
|
47
|
+
);
|
|
48
|
+
const self = this;
|
|
49
|
+
const setter = (suffix: string): ICommand => {
|
|
50
|
+
return new Command({ ...self.state, identitySuffix: suffix });
|
|
51
|
+
};
|
|
52
|
+
// Function.name is read-only, must use defineProperty
|
|
53
|
+
Object.defineProperty(setter, "name", {
|
|
54
|
+
configurable: true,
|
|
55
|
+
value: id.name,
|
|
56
|
+
});
|
|
57
|
+
return Object.assign(setter, {
|
|
58
|
+
localhostUrl: id.localhostUrl,
|
|
59
|
+
port: id.port,
|
|
60
|
+
url: id.url,
|
|
61
|
+
}) as IdentityAccessor;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
flag(name: string, value?: Token): ICommand {
|
|
65
|
+
return new Command({
|
|
66
|
+
...this.state,
|
|
67
|
+
flags: [...this.state.flags, { name, value }],
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
arg(value: Token): ICommand {
|
|
72
|
+
return new Command({
|
|
73
|
+
...this.state,
|
|
74
|
+
args: [...this.state.args, value],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
env(source: EnvSource): ICommand {
|
|
79
|
+
if (isEnvCallback(source)) {
|
|
80
|
+
const resolved = source(this);
|
|
81
|
+
return new Command({
|
|
82
|
+
...this.state,
|
|
83
|
+
envSources: [...this.state.envSources, resolved],
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return new Command({
|
|
87
|
+
...this.state,
|
|
88
|
+
envSources: [...this.state.envSources, source],
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
dir(path: string): ICommand {
|
|
93
|
+
return new Command({
|
|
94
|
+
...this.state,
|
|
95
|
+
dirPath: path,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
waitFor(check: HealthCheck | HealthCheckCallback): ICommand {
|
|
100
|
+
const resolved = typeof check === "function" ? check(this) : check;
|
|
101
|
+
return new Command({
|
|
102
|
+
...this.state,
|
|
103
|
+
healthChecks: [...this.state.healthChecks, resolved],
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
as(name: string): ICommand {
|
|
108
|
+
return new Command({
|
|
109
|
+
...this.state,
|
|
110
|
+
displayName: name,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
dependsOn(...deps: Runnable[]): ICommand {
|
|
115
|
+
return new Command({
|
|
116
|
+
...this.state,
|
|
117
|
+
dependencies: [...this.state.dependencies, ...deps],
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
collectNames(): string[] {
|
|
122
|
+
return [this.deriveName()];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async build(ctx: ComputeContext): Promise<BuiltCommand> {
|
|
126
|
+
const args: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const flag of this.state.flags) {
|
|
129
|
+
if (flag.value !== undefined) {
|
|
130
|
+
const resolved = await resolveToken(flag.value, ctx);
|
|
131
|
+
args.push(`--${flag.name}`, resolved);
|
|
132
|
+
} else {
|
|
133
|
+
args.push(`--${flag.name}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const arg of this.state.args) {
|
|
138
|
+
const resolved = await resolveToken(arg, ctx);
|
|
139
|
+
if (resolved) args.push(resolved);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Resolve env: inherited sources first, then own sources (own wins)
|
|
143
|
+
const env: Record<string, string> = {};
|
|
144
|
+
for (const source of this.state.envSources) {
|
|
145
|
+
await mergeEnvSource(env, source, ctx);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
args,
|
|
150
|
+
command: this.state.base,
|
|
151
|
+
cwd: ctx.cwd,
|
|
152
|
+
env,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async run(): Promise<void> {
|
|
157
|
+
const rtx = getRuntimeContext();
|
|
158
|
+
const name = this.deriveName();
|
|
159
|
+
|
|
160
|
+
// Register a ready promise so dependents can wait on us
|
|
161
|
+
const deferred = createDeferred();
|
|
162
|
+
rtx.readyPromises.set(name, deferred);
|
|
163
|
+
|
|
164
|
+
// Wait for dependencies to be ready before starting
|
|
165
|
+
await this.waitForDependencies();
|
|
166
|
+
|
|
167
|
+
// Resolve cwd: own dir > inherited dir > root
|
|
168
|
+
const dirPath = this.state.dirPath ?? rtx.inheritedDir;
|
|
169
|
+
const cwd = dirPath ? resolve(rtx.root, dirPath) : rtx.root;
|
|
170
|
+
|
|
171
|
+
const computeCtx: ComputeContext = {
|
|
172
|
+
cwd,
|
|
173
|
+
env: { ...(process.env as Record<string, string>) },
|
|
174
|
+
root: rtx.root,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Resolve env: inherited sources first (outermost to innermost),
|
|
178
|
+
// then own sources. Later sources override earlier ones.
|
|
179
|
+
const allEnvSources = [...rtx.inheritedEnv, ...this.state.envSources];
|
|
180
|
+
|
|
181
|
+
for (const source of allEnvSources) {
|
|
182
|
+
await mergeEnvSource(computeCtx.env, source, computeCtx);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const built = await this.build(computeCtx);
|
|
186
|
+
|
|
187
|
+
if (rtx.dryRun) {
|
|
188
|
+
const fullCmd = [built.command, ...built.args].join(" ");
|
|
189
|
+
rtx.logger.system(` ${name}`);
|
|
190
|
+
rtx.logger.system(` cmd: ${fullCmd}`);
|
|
191
|
+
rtx.logger.system(` cwd: ${built.cwd}`);
|
|
192
|
+
const envKeys = Object.keys(built.env);
|
|
193
|
+
if (envKeys.length > 0) {
|
|
194
|
+
rtx.logger.system(` env: ${JSON.stringify(built.env)}`);
|
|
195
|
+
}
|
|
196
|
+
if (this.state.healthChecks.length > 0) {
|
|
197
|
+
const types = this.state.healthChecks
|
|
198
|
+
.map((hc) => hc.type)
|
|
199
|
+
.join(", ");
|
|
200
|
+
rtx.logger.system(` waitFor: ${types}`);
|
|
201
|
+
}
|
|
202
|
+
if (this.state.dependencies.length > 0) {
|
|
203
|
+
const depNames = this.state.dependencies.flatMap((d) =>
|
|
204
|
+
d.collectNames(),
|
|
205
|
+
);
|
|
206
|
+
rtx.logger.system(` dependsOn: ${depNames.join(", ")}`);
|
|
207
|
+
}
|
|
208
|
+
rtx.logger.system("");
|
|
209
|
+
deferred.resolve();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
rtx.logger.starting(name, name);
|
|
214
|
+
|
|
215
|
+
const proc = spawnProcess(name, name, built);
|
|
216
|
+
rtx.processes.set(name, proc);
|
|
217
|
+
|
|
218
|
+
rtx.eventBus.emit({
|
|
219
|
+
data: {
|
|
220
|
+
command: [built.command, ...built.args].join(" "),
|
|
221
|
+
cwd: built.cwd,
|
|
222
|
+
id: name,
|
|
223
|
+
serviceName: name,
|
|
224
|
+
},
|
|
225
|
+
type: "process:registered",
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
proc.onOutput((line, stream) => {
|
|
229
|
+
rtx.logger.output(name, line, stream);
|
|
230
|
+
rtx.eventBus.emit({
|
|
231
|
+
data: {
|
|
232
|
+
id: name,
|
|
233
|
+
line,
|
|
234
|
+
serviceName: name,
|
|
235
|
+
stream,
|
|
236
|
+
timestamp: Date.now(),
|
|
237
|
+
},
|
|
238
|
+
type: "log",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (this.state.healthChecks.length > 0) {
|
|
243
|
+
for (const check of this.state.healthChecks) {
|
|
244
|
+
try {
|
|
245
|
+
await waitForHealthy(check, computeCtx, proc);
|
|
246
|
+
} catch (err) {
|
|
247
|
+
rtx.logger.output(
|
|
248
|
+
name,
|
|
249
|
+
err instanceof Error ? err.message : String(err),
|
|
250
|
+
"stderr",
|
|
251
|
+
);
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
proc.setStatus("healthy");
|
|
256
|
+
rtx.logger.healthy(name);
|
|
257
|
+
} else {
|
|
258
|
+
await proc.waitFor("started");
|
|
259
|
+
rtx.logger.ready(name);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Signal that this command is ready — dependents can proceed
|
|
263
|
+
deferred.resolve();
|
|
264
|
+
|
|
265
|
+
rtx.eventBus.emit({
|
|
266
|
+
data: { id: name, status: proc.status },
|
|
267
|
+
type: "process:status",
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async waitForDependencies(): Promise<void> {
|
|
272
|
+
if (this.state.dependencies.length === 0) return;
|
|
273
|
+
|
|
274
|
+
const rtx = getRuntimeContext();
|
|
275
|
+
|
|
276
|
+
for (const dep of this.state.dependencies) {
|
|
277
|
+
for (const depName of dep.collectNames()) {
|
|
278
|
+
// Poll until the dependency registers its ready promise
|
|
279
|
+
let ready = rtx.readyPromises.get(depName);
|
|
280
|
+
while (!ready) {
|
|
281
|
+
await sleep(50);
|
|
282
|
+
ready = rtx.readyPromises.get(depName);
|
|
283
|
+
}
|
|
284
|
+
await ready.promise;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private deriveName(): string {
|
|
290
|
+
if (this.state.displayName) return this.state.displayName;
|
|
291
|
+
if (this.state.dirPath) return basename(this.state.dirPath);
|
|
292
|
+
return this.state.base.split(" ")[0];
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function mergeEnvSource(
|
|
297
|
+
target: Record<string, string>,
|
|
298
|
+
source: EnvSource,
|
|
299
|
+
ctx: ComputeContext,
|
|
300
|
+
): Promise<void> {
|
|
301
|
+
if (isEnvCallback(source)) {
|
|
302
|
+
// Should not happen — callbacks are resolved eagerly in Command.env()
|
|
303
|
+
// Defensive: skip
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (isLazyEnvSource(source)) {
|
|
307
|
+
const resolved = await source.resolve(ctx);
|
|
308
|
+
Object.assign(target, resolved);
|
|
309
|
+
} else {
|
|
310
|
+
for (const [key, token] of Object.entries(
|
|
311
|
+
source as Record<string, Token>,
|
|
312
|
+
)) {
|
|
313
|
+
target[key] = await resolveToken(token, ctx);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function sleep(ms: number): Promise<void> {
|
|
319
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function cmd(base: string): ICommand {
|
|
323
|
+
return new Command({
|
|
324
|
+
args: [],
|
|
325
|
+
base,
|
|
326
|
+
dependencies: [],
|
|
327
|
+
dirPath: null,
|
|
328
|
+
displayName: null,
|
|
329
|
+
envSources: [],
|
|
330
|
+
flags: [],
|
|
331
|
+
healthChecks: [],
|
|
332
|
+
identitySuffix: null,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
@@ -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,38 @@
|
|
|
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
|
+
export function postgresHealthCheck(port: Token): HealthCheck {
|
|
24
|
+
return { host: "localhost", port, type: "tcp" };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Namespace for health check factories.
|
|
29
|
+
* Usage: health.http(url), health.tcp(host, port), health.postgres(port)
|
|
30
|
+
*/
|
|
31
|
+
export const health = {
|
|
32
|
+
custom: customHealthCheck,
|
|
33
|
+
exec: execHealthCheck,
|
|
34
|
+
http: httpHealthCheck,
|
|
35
|
+
postgres: postgresHealthCheck,
|
|
36
|
+
stdout: stdoutHealthCheck,
|
|
37
|
+
tcp: tcpHealthCheck,
|
|
38
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
postgresHealthCheck,
|
|
12
|
+
stdoutHealthCheck,
|
|
13
|
+
tcpHealthCheck,
|
|
14
|
+
} from "./health-check.js";
|
|
15
|
+
// Tokens
|
|
16
|
+
export {
|
|
17
|
+
compute,
|
|
18
|
+
fromEnv,
|
|
19
|
+
fromFile,
|
|
20
|
+
fromPackageJson,
|
|
21
|
+
resolveToken,
|
|
22
|
+
} from "./token.js";
|
|
23
|
+
export type {
|
|
24
|
+
BuiltCommand,
|
|
25
|
+
ComputeContext,
|
|
26
|
+
EnvCallback,
|
|
27
|
+
EnvSource,
|
|
28
|
+
HealthCheck,
|
|
29
|
+
HealthCheckCallback,
|
|
30
|
+
ICommand,
|
|
31
|
+
ICompound,
|
|
32
|
+
IdentityAccessor,
|
|
33
|
+
LazyEnvSource,
|
|
34
|
+
ProcessStatus,
|
|
35
|
+
Runnable,
|
|
36
|
+
Token,
|
|
37
|
+
} from "./types.js";
|
|
38
|
+
// Types
|
|
39
|
+
export { isEnvCallback, isLazyEnvSource } from "./types.js";
|