@buildepicshit/cli 0.0.3 → 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 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.4"
38
39
  }
@@ -119,23 +119,23 @@ 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
136
  it("overrides identity with .identity(suffix)", () => {
137
137
  const c = cmd("node server.js").identity("custom").dir("apps/api");
138
- expect(c.identity().name).toBe("custom");
138
+ expect(c.identity.name).toBe("custom");
139
139
  });
140
140
 
141
141
  it("identity suffix propagates through builder chain", () => {
@@ -144,19 +144,19 @@ describe("command identity", () => {
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
152
  const withId = base.identity("custom");
153
- expect(base.identity().name).toBe("node");
154
- expect(withId.identity().name).toBe("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 () => {
157
+ it("identity.port() returns a token", async () => {
158
158
  const c = cmd("node server.js").identity("api").dir("apps/api");
159
- const token = c.identity().port();
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,9 +166,9 @@ describe("command identity", () => {
166
166
  expect(port).toBeLessThanOrEqual(9999);
167
167
  });
168
168
 
169
- it("identity().localhostUrl() returns a token", async () => {
169
+ it("identity.localhostUrl() returns a token", async () => {
170
170
  const c = cmd("node server.js").identity("api");
171
- const token = c.identity().localhostUrl("/health");
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
  });
@@ -176,10 +176,52 @@ describe("command identity", () => {
176
176
  it("cross-command identity access works", async () => {
177
177
  const server = cmd("node server.js").identity("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").identity("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").identity("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
+ .identity("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", () => {
@@ -187,11 +229,11 @@ describe("waitFor callback form", () => {
187
229
  const c = cmd("node server.js")
188
230
  .identity("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
 
@@ -199,7 +241,7 @@ describe("env callback form", () => {
199
241
  it("accepts a callback that receives the command", async () => {
200
242
  const c = cmd("node server.js")
201
243
  .identity("api")
202
- .env((self) => ({ PORT: self.identity().port() }));
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);
@@ -211,7 +253,7 @@ describe("env callback form", () => {
211
253
  const c = cmd("node server.js")
212
254
  .identity("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 {
@@ -15,6 +14,7 @@ import {
15
14
  type HealthCheck,
16
15
  type HealthCheckCallback,
17
16
  type ICommand,
17
+ type IdentityAccessor,
18
18
  isEnvCallback,
19
19
  isLazyEnvSource,
20
20
  type Runnable,
@@ -29,7 +29,7 @@ interface CommandState {
29
29
  readonly displayName: string | null;
30
30
  readonly envSources: readonly EnvSource[];
31
31
  readonly flags: readonly { name: string; value?: Token }[];
32
- readonly healthCheck: HealthCheck | null;
32
+ readonly healthChecks: readonly HealthCheck[];
33
33
  readonly identitySuffix: string | null;
34
34
  }
35
35
 
@@ -41,16 +41,24 @@ class Command implements ICommand {
41
41
  this.state = state;
42
42
  }
43
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());
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;
54
62
  }
55
63
 
56
64
  flag(name: string, value?: Token): ICommand {
@@ -92,7 +100,7 @@ class Command implements ICommand {
92
100
  const resolved = typeof check === "function" ? check(this) : check;
93
101
  return new Command({
94
102
  ...this.state,
95
- healthCheck: resolved,
103
+ healthChecks: [...this.state.healthChecks, resolved],
96
104
  });
97
105
  }
98
106
 
@@ -185,8 +193,11 @@ class Command implements ICommand {
185
193
  if (envKeys.length > 0) {
186
194
  rtx.logger.system(` env: ${JSON.stringify(built.env)}`);
187
195
  }
188
- if (this.state.healthCheck) {
189
- rtx.logger.system(` waitFor: ${this.state.healthCheck.type}`);
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}`);
190
201
  }
191
202
  if (this.state.dependencies.length > 0) {
192
203
  const depNames = this.state.dependencies.flatMap((d) =>
@@ -228,16 +239,18 @@ class Command implements ICommand {
228
239
  });
229
240
  });
230
241
 
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;
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
+ }
241
254
  }
242
255
  proc.setStatus("healthy");
243
256
  rtx.logger.healthy(name);
@@ -315,7 +328,7 @@ export function cmd(base: string): ICommand {
315
328
  displayName: null,
316
329
  envSources: [],
317
330
  flags: [],
318
- healthCheck: null,
331
+ healthChecks: [],
319
332
  identitySuffix: null,
320
333
  });
321
334
  }
@@ -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
+ IdentityAccessor,
31
33
  LazyEnvSource,
32
34
  ProcessStatus,
33
35
  Runnable,
@@ -46,6 +46,10 @@ export interface BuiltCommand {
46
46
  env: Record<string, string>;
47
47
  }
48
48
 
49
+ export interface IdentityAccessor extends Identity {
50
+ (suffix: string): ICommand;
51
+ }
52
+
49
53
  export interface ICommand {
50
54
  readonly __type: "command";
51
55
  arg(value: Token): ICommand;
@@ -56,8 +60,7 @@ export interface ICommand {
56
60
  dir(path: string): ICommand;
57
61
  env(source: EnvSource): ICommand;
58
62
  flag(name: string, value?: Token): ICommand;
59
- identity(): Identity;
60
- identity(suffix: string): ICommand;
63
+ readonly identity: IdentityAccessor;
61
64
  run(): Promise<void>;
62
65
  waitFor(check: HealthCheck | HealthCheckCallback): ICommand;
63
66
  }
@@ -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
+ IdentityAccessor,
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,144 @@
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
+ IdentityAccessor,
10
+ Runnable,
11
+ Token,
12
+ } from "../core/types.js";
3
13
 
14
+ export interface IDockerCompose extends ICommand {
15
+ file(path: string): IDockerCompose;
16
+ up(options?: { detach?: boolean }): IDockerCompose;
17
+ }
18
+
19
+ interface DockerComposeState {
20
+ readonly detach: boolean;
21
+ readonly filePath: string | null;
22
+ }
23
+
24
+ class DockerCompose implements IDockerCompose {
25
+ readonly __type = "command" as const;
26
+ private readonly inner: ICommand;
27
+ private readonly dcState: DockerComposeState;
28
+
29
+ constructor(inner: ICommand, dcState: DockerComposeState) {
30
+ this.inner = inner;
31
+ this.dcState = dcState;
32
+ }
33
+
34
+ // ─── Docker-compose-specific methods ─────────────────────────────────
35
+
36
+ up(options?: { detach?: boolean }): IDockerCompose {
37
+ return new DockerCompose(this.inner, {
38
+ ...this.dcState,
39
+ detach: options?.detach ?? true,
40
+ });
41
+ }
42
+
43
+ file(path: string): IDockerCompose {
44
+ return new DockerCompose(this.inner, {
45
+ ...this.dcState,
46
+ filePath: path,
47
+ });
48
+ }
49
+
50
+ // ─── ICommand delegation (returns IDockerCompose for chaining) ───────
51
+
52
+ get identity(): IdentityAccessor {
53
+ const innerAccessor = this.inner.identity;
54
+ const self = this;
55
+ const setter = (suffix: string): IDockerCompose => {
56
+ return new DockerCompose(
57
+ self.inner.identity(suffix),
58
+ self.dcState,
59
+ );
60
+ };
61
+ Object.defineProperty(setter, "name", {
62
+ configurable: true,
63
+ value: innerAccessor.name,
64
+ });
65
+ return Object.assign(setter, {
66
+ localhostUrl: innerAccessor.localhostUrl,
67
+ port: innerAccessor.port,
68
+ url: innerAccessor.url,
69
+ }) as IdentityAccessor;
70
+ }
71
+
72
+ flag(name: string, value?: Token): IDockerCompose {
73
+ return new DockerCompose(this.inner.flag(name, value), this.dcState);
74
+ }
75
+
76
+ arg(value: Token): IDockerCompose {
77
+ return new DockerCompose(this.inner.arg(value), this.dcState);
78
+ }
79
+
80
+ env(source: EnvSource): IDockerCompose {
81
+ return new DockerCompose(this.inner.env(source), this.dcState);
82
+ }
83
+
84
+ dir(path: string): IDockerCompose {
85
+ return new DockerCompose(this.inner.dir(path), this.dcState);
86
+ }
87
+
88
+ waitFor(check: HealthCheck | HealthCheckCallback): IDockerCompose {
89
+ return new DockerCompose(this.inner.waitFor(check), this.dcState);
90
+ }
91
+
92
+ as(name: string): IDockerCompose {
93
+ return new DockerCompose(this.inner.as(name), this.dcState);
94
+ }
95
+
96
+ dependsOn(...deps: Runnable[]): IDockerCompose {
97
+ return new DockerCompose(
98
+ this.inner.dependsOn(...deps),
99
+ this.dcState,
100
+ );
101
+ }
102
+
103
+ collectNames(): string[] {
104
+ return this.inner.collectNames();
105
+ }
106
+
107
+ async build(ctx: ComputeContext): Promise<BuiltCommand> {
108
+ // Build the final docker compose command from state
109
+ let command = this.inner;
110
+ if (this.dcState.filePath) {
111
+ command = command.flag("file", this.dcState.filePath);
112
+ }
113
+ command = command.arg("up");
114
+ if (this.dcState.detach) {
115
+ command = command.flag("detach");
116
+ }
117
+ return command.build(ctx);
118
+ }
119
+
120
+ async run(): Promise<void> {
121
+ // Build a resolved inner command with docker-compose args applied
122
+ let command = this.inner;
123
+ if (this.dcState.filePath) {
124
+ command = command.flag("file", this.dcState.filePath);
125
+ }
126
+ command = command.arg("up");
127
+ if (this.dcState.detach) {
128
+ command = command.flag("detach");
129
+ }
130
+ return command.run();
131
+ }
132
+ }
133
+
134
+ export function dockerCompose(): IDockerCompose {
135
+ return new DockerCompose(cmd("docker compose"), {
136
+ detach: false,
137
+ filePath: null,
138
+ });
139
+ }
140
+
141
+ /** @deprecated Use `dockerCompose()` instead */
4
142
  export function docker(options?: {
5
143
  detach?: boolean;
6
144
  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,97 @@
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;
11
- }
1
+ import { cmd } from "../core/command.js";
2
+ import type {
3
+ BuiltCommand,
4
+ ComputeContext,
5
+ EnvSource,
6
+ HealthCheck,
7
+ HealthCheckCallback,
8
+ ICommand,
9
+ IdentityAccessor,
10
+ Runnable,
11
+ Token,
12
+ } from "../core/types.js";
13
+
14
+ export interface INextjs extends ICommand {}
15
+
16
+ /** @deprecated Use `INextjs` instead */
17
+ export type NextPreset = INextjs;
18
+
19
+ class Nextjs implements INextjs {
20
+ readonly __type = "command" as const;
21
+ private readonly inner: ICommand;
22
+
23
+ constructor(inner: ICommand) {
24
+ this.inner = inner;
25
+ }
26
+
27
+ // ─── ICommand delegation (returns INextjs for chaining) ─────────────
28
+
29
+ get identity(): IdentityAccessor {
30
+ const innerAccessor = this.inner.identity;
31
+ const self = this;
32
+ const setter = (suffix: string): INextjs => {
33
+ return new Nextjs(self.inner.identity(suffix));
34
+ };
35
+ Object.defineProperty(setter, "name", {
36
+ configurable: true,
37
+ value: innerAccessor.name,
38
+ });
39
+ return Object.assign(setter, {
40
+ localhostUrl: innerAccessor.localhostUrl,
41
+ port: innerAccessor.port,
42
+ url: innerAccessor.url,
43
+ }) as IdentityAccessor;
44
+ }
45
+
46
+ flag(name: string, value?: Token): INextjs {
47
+ return new Nextjs(this.inner.flag(name, value));
48
+ }
49
+
50
+ arg(value: Token): INextjs {
51
+ return new Nextjs(this.inner.arg(value));
52
+ }
53
+
54
+ env(source: EnvSource): INextjs {
55
+ return new Nextjs(this.inner.env(source));
56
+ }
12
57
 
13
- export function next(suffix = "next"): NextPreset {
14
- const identity = createIdentity(suffix);
15
- return createNextPreset(identity, null);
58
+ dir(path: string): INextjs {
59
+ return new Nextjs(this.inner.dir(path));
60
+ }
61
+
62
+ waitFor(check: HealthCheck | HealthCheckCallback): INextjs {
63
+ return new Nextjs(this.inner.waitFor(check));
64
+ }
65
+
66
+ as(name: string): INextjs {
67
+ return new Nextjs(this.inner.as(name));
68
+ }
69
+
70
+ dependsOn(...deps: Runnable[]): INextjs {
71
+ return new Nextjs(this.inner.dependsOn(...deps));
72
+ }
73
+
74
+ collectNames(): string[] {
75
+ return this.inner.collectNames();
76
+ }
77
+
78
+ build(ctx: ComputeContext): Promise<BuiltCommand> {
79
+ return this.inner.build(ctx);
80
+ }
81
+
82
+ run(): Promise<void> {
83
+ return this.inner.run();
84
+ }
16
85
  }
17
86
 
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
- };
87
+ export function nextjs(): INextjs {
88
+ // Base: `next` with PORT from identity. Mode (.arg("dev"/"start"/"build"))
89
+ // and health checks are added by the consumer.
90
+ const inner = cmd("next").env((self) => ({
91
+ PORT: self.identity.port(),
92
+ }));
93
+ return new Nextjs(inner);
48
94
  }
95
+
96
+ /** @deprecated Use `nextjs()` instead */
97
+ 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,