@buildepicshit/cli 0.0.3 → 0.0.5
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/orchestrator/core/__tests__/command.test.ts +68 -26
- package/src/orchestrator/core/command.ts +28 -26
- package/src/orchestrator/core/health-check.ts +6 -1
- package/src/orchestrator/core/index.ts +2 -0
- package/src/orchestrator/core/types.ts +2 -2
- package/src/orchestrator/index.ts +6 -1
- package/src/orchestrator/presets/docker.ts +128 -1
- package/src/orchestrator/presets/drizzle.ts +93 -0
- package/src/orchestrator/presets/index.ts +3 -2
- package/src/orchestrator/presets/nextjs.ts +85 -43
- package/src/orchestrator/utils/__tests__/dna.test.ts +45 -0
- package/src/orchestrator/utils/dna.ts +50 -9
package/package.json
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
"esbuild": "^0.25.5"
|
|
17
17
|
},
|
|
18
18
|
"exports": {
|
|
19
|
-
".": "./src/orchestrator/index.ts"
|
|
19
|
+
".": "./src/orchestrator/index.ts",
|
|
20
|
+
"./presets": "./src/orchestrator/presets/index.ts"
|
|
20
21
|
},
|
|
21
22
|
"files": [
|
|
22
23
|
"dist",
|
|
@@ -34,5 +35,5 @@
|
|
|
34
35
|
"typecheck": "tsc --noEmit"
|
|
35
36
|
},
|
|
36
37
|
"type": "module",
|
|
37
|
-
"version": "0.0.
|
|
38
|
+
"version": "0.0.5"
|
|
38
39
|
}
|
|
@@ -119,44 +119,44 @@ describe("cmd", () => {
|
|
|
119
119
|
describe("command identity", () => {
|
|
120
120
|
it("returns default identity from dir basename", () => {
|
|
121
121
|
const c = cmd("node server.js").dir("apps/api");
|
|
122
|
-
const id = c.identity
|
|
122
|
+
const id = c.identity;
|
|
123
123
|
expect(id.name).toBe("api");
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
it("returns default identity from display name", () => {
|
|
127
127
|
const c = cmd("node server.js").as("my-service");
|
|
128
|
-
expect(c.identity
|
|
128
|
+
expect(c.identity.name).toBe("my-service");
|
|
129
129
|
});
|
|
130
130
|
|
|
131
131
|
it("returns default identity from base command", () => {
|
|
132
132
|
const c = cmd("tsx watch src/main.ts");
|
|
133
|
-
expect(c.identity
|
|
133
|
+
expect(c.identity.name).toBe("tsx");
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
-
it("overrides identity with .
|
|
137
|
-
const c = cmd("node server.js").
|
|
138
|
-
expect(c.identity
|
|
136
|
+
it("overrides identity with .withIdentity(suffix)", () => {
|
|
137
|
+
const c = cmd("node server.js").withIdentity("custom").dir("apps/api");
|
|
138
|
+
expect(c.identity.name).toBe("custom");
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
it("identity suffix propagates through builder chain", () => {
|
|
142
142
|
const c = cmd("node server.js")
|
|
143
|
-
.
|
|
143
|
+
.withIdentity("api")
|
|
144
144
|
.dir("apps/server")
|
|
145
145
|
.env({ KEY: "value" })
|
|
146
146
|
.flag("verbose");
|
|
147
|
-
expect(c.identity
|
|
147
|
+
expect(c.identity.name).toBe("api");
|
|
148
148
|
});
|
|
149
149
|
|
|
150
150
|
it("identity is immutable across branches", () => {
|
|
151
151
|
const base = cmd("node server.js");
|
|
152
|
-
const withId = base.
|
|
153
|
-
expect(base.identity
|
|
154
|
-
expect(withId.identity
|
|
152
|
+
const withId = base.withIdentity("custom");
|
|
153
|
+
expect(base.identity.name).toBe("node");
|
|
154
|
+
expect(withId.identity.name).toBe("custom");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
it("identity
|
|
158
|
-
const c = cmd("node server.js").
|
|
159
|
-
const token = c.identity
|
|
157
|
+
it("identity.port() returns a token", async () => {
|
|
158
|
+
const c = cmd("node server.js").withIdentity("api").dir("apps/api");
|
|
159
|
+
const token = c.identity.port();
|
|
160
160
|
expect(typeof token).toBe("function");
|
|
161
161
|
const port = Number.parseInt(
|
|
162
162
|
await (token as (ctx: ComputeContext) => Promise<string>)(ctx),
|
|
@@ -166,40 +166,82 @@ describe("command identity", () => {
|
|
|
166
166
|
expect(port).toBeLessThanOrEqual(9999);
|
|
167
167
|
});
|
|
168
168
|
|
|
169
|
-
it("identity
|
|
170
|
-
const c = cmd("node server.js").
|
|
171
|
-
const token = c.identity
|
|
169
|
+
it("identity.localhostUrl() returns a token", async () => {
|
|
170
|
+
const c = cmd("node server.js").withIdentity("api");
|
|
171
|
+
const token = c.identity.localhostUrl("/health");
|
|
172
172
|
const url = await (token as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
173
173
|
expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
174
174
|
});
|
|
175
175
|
|
|
176
176
|
it("cross-command identity access works", async () => {
|
|
177
|
-
const server = cmd("node server.js").
|
|
177
|
+
const server = cmd("node server.js").withIdentity("api").dir("apps/api");
|
|
178
178
|
const built = await cmd("next dev")
|
|
179
|
-
.env({ API_URL: server.identity
|
|
179
|
+
.env({ API_URL: server.identity.localhostUrl() })
|
|
180
180
|
.build(ctx);
|
|
181
181
|
expect(built.env.API_URL).toMatch(/^http:\/\/localhost:\d+$/);
|
|
182
182
|
});
|
|
183
|
+
|
|
184
|
+
it("identity.port(name) resolves a named sub-identity", async () => {
|
|
185
|
+
const c = cmd("docker compose").withIdentity("infra");
|
|
186
|
+
const dbPortToken = c.identity.port("database");
|
|
187
|
+
const infraPortToken = c.identity.port();
|
|
188
|
+
const dbPort = await (dbPortToken as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
189
|
+
const infraPort = await (infraPortToken as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
190
|
+
// Named sub-identity should produce a different port
|
|
191
|
+
expect(dbPort).not.toBe(infraPort);
|
|
192
|
+
expect(Number.parseInt(dbPort, 10)).toBeGreaterThanOrEqual(3000);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("identity.localhostUrl with portIdentity option", async () => {
|
|
196
|
+
const c = cmd("docker compose").withIdentity("infra");
|
|
197
|
+
const urlToken = c.identity.localhostUrl("/health", { portIdentity: "minio" });
|
|
198
|
+
const url = await (urlToken as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
199
|
+
expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
|
|
200
|
+
|
|
201
|
+
// Port in the URL should match minio identity, not infra
|
|
202
|
+
const minioPortToken = c.identity.port("minio");
|
|
203
|
+
const minioPort = await (minioPortToken as (ctx: ComputeContext) => Promise<string>)(ctx);
|
|
204
|
+
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("multiple waitFor", () => {
|
|
209
|
+
it("appends health checks instead of replacing", () => {
|
|
210
|
+
const c = cmd("node server.js")
|
|
211
|
+
.waitFor(health.http("http://localhost:3000"))
|
|
212
|
+
.waitFor(health.tcp("localhost", "5432"));
|
|
213
|
+
// The command should build without error (checks are stored internally)
|
|
214
|
+
expect(c).toBeDefined();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("accepts callback form for multiple waitFor", async () => {
|
|
218
|
+
const c = cmd("node server.js")
|
|
219
|
+
.withIdentity("api")
|
|
220
|
+
.waitFor((self) => health.http(self.identity.url("/livez")))
|
|
221
|
+
.waitFor((self) => health.tcp("localhost", self.identity.port()));
|
|
222
|
+
const built = await c.build(ctx);
|
|
223
|
+
expect(built).toBeDefined();
|
|
224
|
+
});
|
|
183
225
|
});
|
|
184
226
|
|
|
185
227
|
describe("waitFor callback form", () => {
|
|
186
228
|
it("accepts a callback that receives the command", async () => {
|
|
187
229
|
const c = cmd("node server.js")
|
|
188
|
-
.
|
|
230
|
+
.withIdentity("api")
|
|
189
231
|
.dir("apps/api")
|
|
190
|
-
.waitFor((self) => health.http(self.identity
|
|
232
|
+
.waitFor((self) => health.http(self.identity.url("/livez")));
|
|
191
233
|
|
|
192
234
|
const built = await c.build(ctx);
|
|
193
235
|
expect(built).toBeDefined();
|
|
194
|
-
expect(c.identity
|
|
236
|
+
expect(c.identity.name).toBe("api");
|
|
195
237
|
});
|
|
196
238
|
});
|
|
197
239
|
|
|
198
240
|
describe("env callback form", () => {
|
|
199
241
|
it("accepts a callback that receives the command", async () => {
|
|
200
242
|
const c = cmd("node server.js")
|
|
201
|
-
.
|
|
202
|
-
.env((self) => ({ PORT: self.identity
|
|
243
|
+
.withIdentity("api")
|
|
244
|
+
.env((self) => ({ PORT: self.identity.port() }));
|
|
203
245
|
|
|
204
246
|
const built = await c.build(ctx);
|
|
205
247
|
const port = Number.parseInt(built.env.PORT, 10);
|
|
@@ -209,9 +251,9 @@ describe("env callback form", () => {
|
|
|
209
251
|
|
|
210
252
|
it("mixes callback env with record env", async () => {
|
|
211
253
|
const c = cmd("node server.js")
|
|
212
|
-
.
|
|
254
|
+
.withIdentity("api")
|
|
213
255
|
.env({ A: "1" })
|
|
214
|
-
.env((self) => ({ PORT: self.identity
|
|
256
|
+
.env((self) => ({ PORT: self.identity.port() }))
|
|
215
257
|
.env({ B: "2" });
|
|
216
258
|
|
|
217
259
|
const built = await c.build(ctx);
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
createDeferred,
|
|
6
6
|
getRuntimeContext,
|
|
7
7
|
} from "../runner/runtime-context.js";
|
|
8
|
-
import type { Identity } from "../utils/dna.js";
|
|
9
8
|
import { createIdentity } from "../utils/dna.js";
|
|
10
9
|
import { resolveToken } from "./token.js";
|
|
11
10
|
import {
|
|
@@ -29,7 +28,7 @@ interface CommandState {
|
|
|
29
28
|
readonly displayName: string | null;
|
|
30
29
|
readonly envSources: readonly EnvSource[];
|
|
31
30
|
readonly flags: readonly { name: string; value?: Token }[];
|
|
32
|
-
readonly
|
|
31
|
+
readonly healthChecks: readonly HealthCheck[];
|
|
33
32
|
readonly identitySuffix: string | null;
|
|
34
33
|
}
|
|
35
34
|
|
|
@@ -41,16 +40,14 @@ class Command implements ICommand {
|
|
|
41
40
|
this.state = state;
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
identity()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
return createIdentity(this.state.identitySuffix ?? this.deriveName());
|
|
43
|
+
get identity() {
|
|
44
|
+
return createIdentity(
|
|
45
|
+
this.state.identitySuffix ?? this.deriveName(),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
withIdentity(suffix: string): ICommand {
|
|
50
|
+
return new Command({ ...this.state, identitySuffix: suffix });
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
flag(name: string, value?: Token): ICommand {
|
|
@@ -92,7 +89,7 @@ class Command implements ICommand {
|
|
|
92
89
|
const resolved = typeof check === "function" ? check(this) : check;
|
|
93
90
|
return new Command({
|
|
94
91
|
...this.state,
|
|
95
|
-
|
|
92
|
+
healthChecks: [...this.state.healthChecks, resolved],
|
|
96
93
|
});
|
|
97
94
|
}
|
|
98
95
|
|
|
@@ -185,8 +182,11 @@ class Command implements ICommand {
|
|
|
185
182
|
if (envKeys.length > 0) {
|
|
186
183
|
rtx.logger.system(` env: ${JSON.stringify(built.env)}`);
|
|
187
184
|
}
|
|
188
|
-
if (this.state.
|
|
189
|
-
|
|
185
|
+
if (this.state.healthChecks.length > 0) {
|
|
186
|
+
const types = this.state.healthChecks
|
|
187
|
+
.map((hc) => hc.type)
|
|
188
|
+
.join(", ");
|
|
189
|
+
rtx.logger.system(` waitFor: ${types}`);
|
|
190
190
|
}
|
|
191
191
|
if (this.state.dependencies.length > 0) {
|
|
192
192
|
const depNames = this.state.dependencies.flatMap((d) =>
|
|
@@ -228,16 +228,18 @@ class Command implements ICommand {
|
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
if (this.state.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
231
|
+
if (this.state.healthChecks.length > 0) {
|
|
232
|
+
for (const check of this.state.healthChecks) {
|
|
233
|
+
try {
|
|
234
|
+
await waitForHealthy(check, computeCtx, proc);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
rtx.logger.output(
|
|
237
|
+
name,
|
|
238
|
+
err instanceof Error ? err.message : String(err),
|
|
239
|
+
"stderr",
|
|
240
|
+
);
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
241
243
|
}
|
|
242
244
|
proc.setStatus("healthy");
|
|
243
245
|
rtx.logger.healthy(name);
|
|
@@ -315,7 +317,7 @@ export function cmd(base: string): ICommand {
|
|
|
315
317
|
displayName: null,
|
|
316
318
|
envSources: [],
|
|
317
319
|
flags: [],
|
|
318
|
-
|
|
320
|
+
healthChecks: [],
|
|
319
321
|
identitySuffix: null,
|
|
320
322
|
});
|
|
321
323
|
}
|
|
@@ -20,14 +20,19 @@ export function customHealthCheck(check: () => Promise<boolean>): HealthCheck {
|
|
|
20
20
|
return { check, type: "custom" };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
export function postgresHealthCheck(port: Token): HealthCheck {
|
|
24
|
+
return { host: "localhost", port, type: "tcp" };
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
/**
|
|
24
28
|
* Namespace for health check factories.
|
|
25
|
-
* Usage: health.http(url), health.tcp(host, port), health.
|
|
29
|
+
* Usage: health.http(url), health.tcp(host, port), health.postgres(port)
|
|
26
30
|
*/
|
|
27
31
|
export const health = {
|
|
28
32
|
custom: customHealthCheck,
|
|
29
33
|
exec: execHealthCheck,
|
|
30
34
|
http: httpHealthCheck,
|
|
35
|
+
postgres: postgresHealthCheck,
|
|
31
36
|
stdout: stdoutHealthCheck,
|
|
32
37
|
tcp: tcpHealthCheck,
|
|
33
38
|
};
|
|
@@ -8,6 +8,7 @@ export {
|
|
|
8
8
|
execHealthCheck,
|
|
9
9
|
health,
|
|
10
10
|
httpHealthCheck,
|
|
11
|
+
postgresHealthCheck,
|
|
11
12
|
stdoutHealthCheck,
|
|
12
13
|
tcpHealthCheck,
|
|
13
14
|
} from "./health-check.js";
|
|
@@ -28,6 +29,7 @@ export type {
|
|
|
28
29
|
HealthCheckCallback,
|
|
29
30
|
ICommand,
|
|
30
31
|
ICompound,
|
|
32
|
+
|
|
31
33
|
LazyEnvSource,
|
|
32
34
|
ProcessStatus,
|
|
33
35
|
Runnable,
|
|
@@ -56,10 +56,10 @@ export interface ICommand {
|
|
|
56
56
|
dir(path: string): ICommand;
|
|
57
57
|
env(source: EnvSource): ICommand;
|
|
58
58
|
flag(name: string, value?: Token): ICommand;
|
|
59
|
-
identity
|
|
60
|
-
identity(suffix: string): ICommand;
|
|
59
|
+
readonly identity: Identity;
|
|
61
60
|
run(): Promise<void>;
|
|
62
61
|
waitFor(check: HealthCheck | HealthCheckCallback): ICommand;
|
|
62
|
+
withIdentity(suffix: string): ICommand;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
// ─── Compound ────────────────────────────────────────────────────────────────
|
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
health,
|
|
11
11
|
httpHealthCheck,
|
|
12
12
|
par,
|
|
13
|
+
postgresHealthCheck,
|
|
13
14
|
seq,
|
|
14
15
|
stdoutHealthCheck,
|
|
15
16
|
tcpHealthCheck,
|
|
@@ -24,6 +25,7 @@ export type {
|
|
|
24
25
|
HealthCheckCallback,
|
|
25
26
|
ICommand,
|
|
26
27
|
ICompound,
|
|
28
|
+
|
|
27
29
|
ProcessStatus,
|
|
28
30
|
Runnable,
|
|
29
31
|
Token,
|
|
@@ -32,13 +34,16 @@ export type {
|
|
|
32
34
|
export type { Logger } from "./logger/logger.js";
|
|
33
35
|
export { createLazyLogger, createLogger } from "./logger/logger.js";
|
|
34
36
|
// Presets
|
|
35
|
-
export type { NextPreset } from "./presets/index.js";
|
|
37
|
+
export type { IDockerCompose, IDrizzle, INextjs, NextPreset } from "./presets/index.js";
|
|
36
38
|
export {
|
|
37
39
|
docker,
|
|
40
|
+
dockerCompose,
|
|
41
|
+
drizzle,
|
|
38
42
|
esbuild,
|
|
39
43
|
esbuildWatch,
|
|
40
44
|
hono,
|
|
41
45
|
next,
|
|
46
|
+
nextjs,
|
|
42
47
|
nodemon,
|
|
43
48
|
tsx,
|
|
44
49
|
} from "./presets/index.js";
|
|
@@ -1,6 +1,133 @@
|
|
|
1
1
|
import { cmd } from "../core/command.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
BuiltCommand,
|
|
4
|
+
ComputeContext,
|
|
5
|
+
EnvSource,
|
|
6
|
+
HealthCheck,
|
|
7
|
+
HealthCheckCallback,
|
|
8
|
+
ICommand,
|
|
9
|
+
Runnable,
|
|
10
|
+
Token,
|
|
11
|
+
} from "../core/types.js";
|
|
12
|
+
import type { Identity } from "../utils/dna.js";
|
|
3
13
|
|
|
14
|
+
export interface IDockerCompose extends ICommand {
|
|
15
|
+
file(path: string): IDockerCompose;
|
|
16
|
+
up(options?: { detach?: boolean }): IDockerCompose;
|
|
17
|
+
withIdentity(suffix: string): IDockerCompose;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface DockerComposeState {
|
|
21
|
+
readonly detach: boolean;
|
|
22
|
+
readonly filePath: string | null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class DockerCompose implements IDockerCompose {
|
|
26
|
+
readonly __type = "command" as const;
|
|
27
|
+
private readonly inner: ICommand;
|
|
28
|
+
private readonly dcState: DockerComposeState;
|
|
29
|
+
|
|
30
|
+
constructor(inner: ICommand, dcState: DockerComposeState) {
|
|
31
|
+
this.inner = inner;
|
|
32
|
+
this.dcState = dcState;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── Docker-compose-specific methods ─────────────────────────────────
|
|
36
|
+
|
|
37
|
+
up(options?: { detach?: boolean }): IDockerCompose {
|
|
38
|
+
return new DockerCompose(this.inner, {
|
|
39
|
+
...this.dcState,
|
|
40
|
+
detach: options?.detach ?? true,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
file(path: string): IDockerCompose {
|
|
45
|
+
return new DockerCompose(this.inner, {
|
|
46
|
+
...this.dcState,
|
|
47
|
+
filePath: path,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── ICommand delegation (returns IDockerCompose for chaining) ───────
|
|
52
|
+
|
|
53
|
+
get identity(): Identity {
|
|
54
|
+
return this.inner.identity;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
withIdentity(suffix: string): IDockerCompose {
|
|
58
|
+
return new DockerCompose(this.inner.withIdentity(suffix), this.dcState);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
flag(name: string, value?: Token): IDockerCompose {
|
|
62
|
+
return new DockerCompose(this.inner.flag(name, value), this.dcState);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
arg(value: Token): IDockerCompose {
|
|
66
|
+
return new DockerCompose(this.inner.arg(value), this.dcState);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
env(source: EnvSource): IDockerCompose {
|
|
70
|
+
return new DockerCompose(this.inner.env(source), this.dcState);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
dir(path: string): IDockerCompose {
|
|
74
|
+
return new DockerCompose(this.inner.dir(path), this.dcState);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
waitFor(check: HealthCheck | HealthCheckCallback): IDockerCompose {
|
|
78
|
+
return new DockerCompose(this.inner.waitFor(check), this.dcState);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
as(name: string): IDockerCompose {
|
|
82
|
+
return new DockerCompose(this.inner.as(name), this.dcState);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
dependsOn(...deps: Runnable[]): IDockerCompose {
|
|
86
|
+
return new DockerCompose(
|
|
87
|
+
this.inner.dependsOn(...deps),
|
|
88
|
+
this.dcState,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
collectNames(): string[] {
|
|
93
|
+
return this.inner.collectNames();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async build(ctx: ComputeContext): Promise<BuiltCommand> {
|
|
97
|
+
// Build the final docker compose command from state
|
|
98
|
+
let command = this.inner;
|
|
99
|
+
if (this.dcState.filePath) {
|
|
100
|
+
command = command.flag("file", this.dcState.filePath);
|
|
101
|
+
}
|
|
102
|
+
command = command.arg("up");
|
|
103
|
+
if (this.dcState.detach) {
|
|
104
|
+
command = command.flag("detach");
|
|
105
|
+
}
|
|
106
|
+
return command.build(ctx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async run(): Promise<void> {
|
|
110
|
+
// Build a resolved inner command with docker-compose args applied
|
|
111
|
+
let command = this.inner;
|
|
112
|
+
if (this.dcState.filePath) {
|
|
113
|
+
command = command.flag("file", this.dcState.filePath);
|
|
114
|
+
}
|
|
115
|
+
command = command.arg("up");
|
|
116
|
+
if (this.dcState.detach) {
|
|
117
|
+
command = command.flag("detach");
|
|
118
|
+
}
|
|
119
|
+
return command.run();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function dockerCompose(): IDockerCompose {
|
|
124
|
+
return new DockerCompose(cmd("docker compose"), {
|
|
125
|
+
detach: false,
|
|
126
|
+
filePath: null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** @deprecated Use `dockerCompose()` instead */
|
|
4
131
|
export function docker(options?: {
|
|
5
132
|
detach?: boolean;
|
|
6
133
|
file?: Token;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { cmd } from "../core/command.js";
|
|
2
|
+
import type {
|
|
3
|
+
EnvSource,
|
|
4
|
+
ICommand,
|
|
5
|
+
LazyEnvSource,
|
|
6
|
+
Runnable,
|
|
7
|
+
Token,
|
|
8
|
+
} from "../core/types.js";
|
|
9
|
+
|
|
10
|
+
export interface IDrizzle {
|
|
11
|
+
database(port: Token): IDrizzle;
|
|
12
|
+
dependsOn(...deps: Runnable[]): IDrizzle;
|
|
13
|
+
dir(path: string): IDrizzle;
|
|
14
|
+
envFile(source: LazyEnvSource): IDrizzle;
|
|
15
|
+
readonly migrate: ICommand;
|
|
16
|
+
readonly studio: ICommand;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface DrizzleState {
|
|
20
|
+
readonly databasePort: Token | null;
|
|
21
|
+
readonly dependencies: readonly Runnable[];
|
|
22
|
+
readonly dirPath: string | null;
|
|
23
|
+
readonly envSources: readonly EnvSource[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class Drizzle implements IDrizzle {
|
|
27
|
+
private readonly state: DrizzleState;
|
|
28
|
+
|
|
29
|
+
constructor(state: DrizzleState) {
|
|
30
|
+
this.state = state;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
database(port: Token): IDrizzle {
|
|
34
|
+
return new Drizzle({ ...this.state, databasePort: port });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
dependsOn(...deps: Runnable[]): IDrizzle {
|
|
38
|
+
return new Drizzle({
|
|
39
|
+
...this.state,
|
|
40
|
+
dependencies: [...this.state.dependencies, ...deps],
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
dir(path: string): IDrizzle {
|
|
45
|
+
return new Drizzle({ ...this.state, dirPath: path });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
envFile(source: LazyEnvSource): IDrizzle {
|
|
49
|
+
return new Drizzle({
|
|
50
|
+
...this.state,
|
|
51
|
+
envSources: [...this.state.envSources, source],
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get migrate(): ICommand {
|
|
56
|
+
return this.buildCommand("drizzle-kit migrate");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
get studio(): ICommand {
|
|
60
|
+
return this.buildCommand("drizzle-kit studio");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private buildCommand(base: string): ICommand {
|
|
64
|
+
let command: ICommand = cmd(base);
|
|
65
|
+
|
|
66
|
+
if (this.state.dirPath) {
|
|
67
|
+
command = command.dir(this.state.dirPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const source of this.state.envSources) {
|
|
71
|
+
command = command.env(source);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.state.databasePort) {
|
|
75
|
+
command = command.env({ DATABASE_PORT: this.state.databasePort });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (this.state.dependencies.length > 0) {
|
|
79
|
+
command = command.dependsOn(...this.state.dependencies);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return command;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function drizzle(): IDrizzle {
|
|
87
|
+
return new Drizzle({
|
|
88
|
+
databasePort: null,
|
|
89
|
+
dependencies: [],
|
|
90
|
+
dirPath: null,
|
|
91
|
+
envSources: [],
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export { docker } from "./docker.js";
|
|
1
|
+
export { type IDockerCompose, docker, dockerCompose } from "./docker.js";
|
|
2
|
+
export { type IDrizzle, drizzle } from "./drizzle.js";
|
|
2
3
|
export { esbuild, esbuildWatch } from "./esbuild.js";
|
|
3
4
|
export { hono } from "./hono.js";
|
|
4
|
-
export { type NextPreset, next } from "./nextjs.js";
|
|
5
|
+
export { type INextjs, type NextPreset, next, nextjs } from "./nextjs.js";
|
|
5
6
|
export { nodemon, tsx } from "./node.js";
|
|
@@ -1,48 +1,90 @@
|
|
|
1
|
-
import { cmd } from "../core/command";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
1
|
+
import { cmd } from "../core/command.js";
|
|
2
|
+
import type {
|
|
3
|
+
BuiltCommand,
|
|
4
|
+
ComputeContext,
|
|
5
|
+
EnvSource,
|
|
6
|
+
HealthCheck,
|
|
7
|
+
HealthCheckCallback,
|
|
8
|
+
ICommand,
|
|
9
|
+
Runnable,
|
|
10
|
+
Token,
|
|
11
|
+
} from "../core/types.js";
|
|
12
|
+
import type { Identity } from "../utils/dna.js";
|
|
13
|
+
|
|
14
|
+
export interface INextjs extends ICommand {
|
|
15
|
+
withIdentity(suffix: string): INextjs;
|
|
11
16
|
}
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
/** @deprecated Use `INextjs` instead */
|
|
19
|
+
export type NextPreset = INextjs;
|
|
20
|
+
|
|
21
|
+
class Nextjs implements INextjs {
|
|
22
|
+
readonly __type = "command" as const;
|
|
23
|
+
private readonly inner: ICommand;
|
|
24
|
+
|
|
25
|
+
constructor(inner: ICommand) {
|
|
26
|
+
this.inner = inner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ─── ICommand delegation (returns INextjs for chaining) ─────────────
|
|
30
|
+
|
|
31
|
+
get identity(): Identity {
|
|
32
|
+
return this.inner.identity;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
withIdentity(suffix: string): INextjs {
|
|
36
|
+
return new Nextjs(this.inner.withIdentity(suffix));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
flag(name: string, value?: Token): INextjs {
|
|
40
|
+
return new Nextjs(this.inner.flag(name, value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
arg(value: Token): INextjs {
|
|
44
|
+
return new Nextjs(this.inner.arg(value));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
env(source: EnvSource): INextjs {
|
|
48
|
+
return new Nextjs(this.inner.env(source));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
dir(path: string): INextjs {
|
|
52
|
+
return new Nextjs(this.inner.dir(path));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
waitFor(check: HealthCheck | HealthCheckCallback): INextjs {
|
|
56
|
+
return new Nextjs(this.inner.waitFor(check));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
as(name: string): INextjs {
|
|
60
|
+
return new Nextjs(this.inner.as(name));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dependsOn(...deps: Runnable[]): INextjs {
|
|
64
|
+
return new Nextjs(this.inner.dependsOn(...deps));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
collectNames(): string[] {
|
|
68
|
+
return this.inner.collectNames();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
build(ctx: ComputeContext): Promise<BuiltCommand> {
|
|
72
|
+
return this.inner.build(ctx);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
run(): Promise<void> {
|
|
76
|
+
return this.inner.run();
|
|
77
|
+
}
|
|
16
78
|
}
|
|
17
79
|
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
return {
|
|
27
|
-
build(): ICommand {
|
|
28
|
-
return applyDir(cmd("next build"));
|
|
29
|
-
},
|
|
30
|
-
dev(): ICommand {
|
|
31
|
-
return applyDir(
|
|
32
|
-
cmd("next dev")
|
|
33
|
-
.waitFor(health.http(identity.url("/livez")))
|
|
34
|
-
.env({ PORT: identity.port() }),
|
|
35
|
-
);
|
|
36
|
-
},
|
|
37
|
-
dir(path: string): NextPreset {
|
|
38
|
-
return createNextPreset(identity, path);
|
|
39
|
-
},
|
|
40
|
-
start(): ICommand {
|
|
41
|
-
return applyDir(
|
|
42
|
-
cmd("next start")
|
|
43
|
-
.waitFor(health.http(identity.url("/livez")))
|
|
44
|
-
.env({ PORT: identity.port() }),
|
|
45
|
-
);
|
|
46
|
-
},
|
|
47
|
-
};
|
|
80
|
+
export function nextjs(): INextjs {
|
|
81
|
+
// Base: `next` with PORT from identity. Mode (.arg("dev"/"start"/"build"))
|
|
82
|
+
// and health checks are added by the consumer.
|
|
83
|
+
const inner = cmd("next").env((self) => ({
|
|
84
|
+
PORT: self.identity.port(),
|
|
85
|
+
}));
|
|
86
|
+
return new Nextjs(inner);
|
|
48
87
|
}
|
|
88
|
+
|
|
89
|
+
/** @deprecated Use `nextjs()` instead */
|
|
90
|
+
export const next = nextjs;
|
|
@@ -85,4 +85,49 @@ describe("DNA", () => {
|
|
|
85
85
|
const fromLocalhostUrl = await resolveToken(id.localhostUrl("/api"), ctx);
|
|
86
86
|
expect(fromLocalhostUrl).toBe(fromUrl);
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
it("port(identityName) computes port for a different identity", async () => {
|
|
90
|
+
const id = identity("infra");
|
|
91
|
+
const ownPort = await resolveToken(id.port(), ctx);
|
|
92
|
+
const dbPort = await resolveToken(id.port("database"), ctx);
|
|
93
|
+
expect(ownPort).not.toBe(dbPort);
|
|
94
|
+
const port = Number.parseInt(dbPort, 10);
|
|
95
|
+
expect(port).toBeGreaterThanOrEqual(3000);
|
|
96
|
+
expect(port).toBeLessThanOrEqual(9999);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("port(identityName) is deterministic", async () => {
|
|
100
|
+
const id = identity("infra");
|
|
101
|
+
const r1 = await resolveToken(id.port("database"), ctx);
|
|
102
|
+
const r2 = await resolveToken(id.port("database"), ctx);
|
|
103
|
+
expect(r1).toBe(r2);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("port(identityName) matches standalone identity port", async () => {
|
|
107
|
+
const infra = identity("infra");
|
|
108
|
+
const db = identity("database");
|
|
109
|
+
const fromSub = await resolveToken(infra.port("database"), ctx);
|
|
110
|
+
const fromStandalone = await resolveToken(db.port(), ctx);
|
|
111
|
+
expect(fromSub).toBe(fromStandalone);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("url with portIdentity option uses different port", async () => {
|
|
115
|
+
const id = identity("infra");
|
|
116
|
+
const minioPort = await resolveToken(id.port("minio"), ctx);
|
|
117
|
+
const url = await resolveToken(
|
|
118
|
+
id.url("/health", { portIdentity: "minio" }),
|
|
119
|
+
ctx,
|
|
120
|
+
);
|
|
121
|
+
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("localhostUrl with portIdentity option uses different port", async () => {
|
|
125
|
+
const id = identity("infra");
|
|
126
|
+
const minioPort = await resolveToken(id.port("minio"), ctx);
|
|
127
|
+
const url = await resolveToken(
|
|
128
|
+
id.localhostUrl("/health", { portIdentity: "minio" }),
|
|
129
|
+
ctx,
|
|
130
|
+
);
|
|
131
|
+
expect(url).toBe(`http://localhost:${minioPort}/health`);
|
|
132
|
+
});
|
|
88
133
|
});
|
|
@@ -1,29 +1,69 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import type { Token } from "../core/types.js";
|
|
3
3
|
|
|
4
|
+
// Well-known/reserved ports in the 3000-9999 range to avoid
|
|
5
|
+
const RESERVED_PORTS = new Set([
|
|
6
|
+
3306, // MySQL
|
|
7
|
+
3389, // RDP
|
|
8
|
+
4369, // Erlang EPMD
|
|
9
|
+
5432, // PostgreSQL
|
|
10
|
+
5433, // PostgreSQL alt
|
|
11
|
+
5672, // AMQP (RabbitMQ)
|
|
12
|
+
5984, // CouchDB
|
|
13
|
+
6379, // Redis
|
|
14
|
+
6380, // Redis alt
|
|
15
|
+
6660, 6661, 6662, 6663, 6664, 6665, 6666, 6667, 6668, 6669, // IRC
|
|
16
|
+
6697, // IRC over TLS (ircs-u)
|
|
17
|
+
7474, // Neo4j
|
|
18
|
+
8080, // HTTP alternate
|
|
19
|
+
8443, // HTTPS alternate
|
|
20
|
+
8888, // Common dev port
|
|
21
|
+
9042, // Cassandra
|
|
22
|
+
9090, // Prometheus
|
|
23
|
+
9200, // Elasticsearch HTTP
|
|
24
|
+
9300, // Elasticsearch transport
|
|
25
|
+
9418, // Git
|
|
26
|
+
]);
|
|
27
|
+
|
|
4
28
|
function computePort(
|
|
5
29
|
cwd: string,
|
|
6
30
|
suffix: string,
|
|
7
31
|
range = { max: 9999, min: 3000 },
|
|
8
32
|
): number {
|
|
9
|
-
const
|
|
33
|
+
const span = range.max - range.min + 1;
|
|
34
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
35
|
+
const input = attempt === 0 ? `${cwd}:${suffix}` : `${cwd}:${suffix}:${attempt}`;
|
|
36
|
+
const hash = createHash("md5").update(input).digest("hex");
|
|
37
|
+
const num = Number.parseInt(hash.slice(0, 8), 16);
|
|
38
|
+
const port = range.min + (num % span);
|
|
39
|
+
if (!RESERVED_PORTS.has(port)) return port;
|
|
40
|
+
}
|
|
41
|
+
// Extremely unlikely fallback — just offset from the last attempt
|
|
42
|
+
const hash = createHash("md5").update(`${cwd}:${suffix}:fallback`).digest("hex");
|
|
10
43
|
const num = Number.parseInt(hash.slice(0, 8), 16);
|
|
11
|
-
return range.min + (num %
|
|
44
|
+
return range.min + (num % span);
|
|
12
45
|
}
|
|
13
46
|
|
|
14
47
|
export interface Identity {
|
|
15
|
-
localhostUrl: (
|
|
48
|
+
localhostUrl: (
|
|
49
|
+
path?: string,
|
|
50
|
+
options?: { portIdentity?: string },
|
|
51
|
+
) => Token;
|
|
16
52
|
readonly name: string;
|
|
17
|
-
port: () => Token;
|
|
18
|
-
url: (path?: string) => Token;
|
|
53
|
+
port: (identityName?: string) => Token;
|
|
54
|
+
url: (path?: string, options?: { portIdentity?: string }) => Token;
|
|
19
55
|
}
|
|
20
56
|
|
|
21
57
|
export function createIdentity(suffix?: string): Identity {
|
|
22
58
|
const name = suffix || "";
|
|
23
59
|
|
|
24
|
-
const urlToken = (
|
|
60
|
+
const urlToken = (
|
|
61
|
+
pathValue?: string,
|
|
62
|
+
options?: { portIdentity?: string },
|
|
63
|
+
): Token => {
|
|
25
64
|
return async ({ cwd }) => {
|
|
26
|
-
const
|
|
65
|
+
const portName = options?.portIdentity ?? name;
|
|
66
|
+
const port = computePort(cwd, portName);
|
|
27
67
|
const base = `http://localhost:${port}`;
|
|
28
68
|
return pathValue ? `${base}${pathValue}` : base;
|
|
29
69
|
};
|
|
@@ -32,9 +72,10 @@ export function createIdentity(suffix?: string): Identity {
|
|
|
32
72
|
return {
|
|
33
73
|
localhostUrl: urlToken,
|
|
34
74
|
name,
|
|
35
|
-
port: (): Token => {
|
|
75
|
+
port: (identityName?: string): Token => {
|
|
36
76
|
return async ({ cwd }) => {
|
|
37
|
-
|
|
77
|
+
const portName = identityName ?? name;
|
|
78
|
+
return computePort(cwd, portName).toString();
|
|
38
79
|
};
|
|
39
80
|
},
|
|
40
81
|
url: urlToken,
|