@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 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.3"
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().name).toBe("my-service");
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().name).toBe("tsx");
133
+ expect(c.identity.name).toBe("tsx");
134
134
  });
135
135
 
136
- it("overrides identity with .identity(suffix)", () => {
137
- const c = cmd("node server.js").identity("custom").dir("apps/api");
138
- expect(c.identity().name).toBe("custom");
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
- .identity("api")
143
+ .withIdentity("api")
144
144
  .dir("apps/server")
145
145
  .env({ KEY: "value" })
146
146
  .flag("verbose");
147
- expect(c.identity().name).toBe("api");
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.identity("custom");
153
- expect(base.identity().name).toBe("node");
154
- expect(withId.identity().name).toBe("custom");
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().port() returns a token", async () => {
158
- const c = cmd("node server.js").identity("api").dir("apps/api");
159
- const token = c.identity().port();
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().localhostUrl() returns a token", async () => {
170
- const c = cmd("node server.js").identity("api");
171
- const token = c.identity().localhostUrl("/health");
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").identity("api").dir("apps/api");
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().localhostUrl() })
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
- .identity("api")
230
+ .withIdentity("api")
189
231
  .dir("apps/api")
190
- .waitFor((self) => health.http(self.identity().url("/livez")));
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().name).toBe("api");
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
- .identity("api")
202
- .env((self) => ({ PORT: self.identity().port() }));
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
- .identity("api")
254
+ .withIdentity("api")
213
255
  .env({ A: "1" })
214
- .env((self) => ({ PORT: self.identity().port() }))
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 healthCheck: HealthCheck | null;
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(): 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());
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
- healthCheck: resolved,
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.healthCheck) {
189
- rtx.logger.system(` waitFor: ${this.state.healthCheck.type}`);
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.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;
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
- healthCheck: null,
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.stdout(pattern)
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(): 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 { ICommand, Token } from "../core/types.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";
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 { health } from "../core/health-check";
3
- import type { ICommand } from "../core/types";
4
- import { createIdentity } from "../utils/dna";
5
-
6
- export interface NextPreset {
7
- build(): ICommand;
8
- dev(): ICommand;
9
- dir(path: string): NextPreset;
10
- start(): ICommand;
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
- export function next(suffix = "next"): NextPreset {
14
- const identity = createIdentity(suffix);
15
- return createNextPreset(identity, null);
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 createNextPreset(
19
- identity: ReturnType<typeof createIdentity>,
20
- dirPath: string | null,
21
- ): NextPreset {
22
- function applyDir(command: ICommand): ICommand {
23
- return dirPath ? command.dir(dirPath) : command;
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 hash = createHash("md5").update(`${cwd}:${suffix}`).digest("hex");
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 % (range.max - range.min + 1));
44
+ return range.min + (num % span);
12
45
  }
13
46
 
14
47
  export interface Identity {
15
- localhostUrl: (path?: string) => Token;
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 = (pathValue?: string): Token => {
60
+ const urlToken = (
61
+ pathValue?: string,
62
+ options?: { portIdentity?: string },
63
+ ): Token => {
25
64
  return async ({ cwd }) => {
26
- const port = computePort(cwd, name);
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
- return computePort(cwd, name).toString();
77
+ const portName = identityName ?? name;
78
+ return computePort(cwd, portName).toString();
38
79
  };
39
80
  },
40
81
  url: urlToken,