@buildepicshit/cli 0.0.8 → 0.0.9

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
@@ -37,5 +37,5 @@
37
37
  "typecheck": "tsc --noEmit"
38
38
  },
39
39
  "type": "module",
40
- "version": "0.0.8"
40
+ "version": "0.0.9"
41
41
  }
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { createJiti } from "jiti";
4
+ import { setConfigRoot } from "./orchestrator/utils/config-context.js";
4
5
 
5
6
  const CONFIG_FILENAME = "bes.config.ts";
6
7
 
@@ -40,6 +41,11 @@ export async function loadConfig(
40
41
  );
41
42
  }
42
43
 
44
+ const root = dirname(configPath);
45
+
46
+ // Set root BEFORE evaluating config so identity() can compute ports eagerly
47
+ setConfigRoot(root);
48
+
43
49
  const jiti = createJiti(configPath, {
44
50
  fsCache: false,
45
51
  interopDefault: true,
@@ -63,6 +69,6 @@ export async function loadConfig(
63
69
 
64
70
  return {
65
71
  commands,
66
- root: dirname(configPath),
72
+ root,
67
73
  };
68
74
  }
@@ -1,8 +1,9 @@
1
- import { describe, expect, it } from "vitest";
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
2
  import { cmd } from "../command.js";
3
3
  import { health } from "../health-check.js";
4
4
  import { compute } from "../token.js";
5
5
  import type { ComputeContext } from "../types.js";
6
+ import { setConfigRoot } from "../../utils/config-context.js";
6
7
 
7
8
  const ctx: ComputeContext = {
8
9
  cwd: "/app",
@@ -10,6 +11,10 @@ const ctx: ComputeContext = {
10
11
  root: "/",
11
12
  };
12
13
 
14
+ beforeEach(() => {
15
+ setConfigRoot("/app");
16
+ });
17
+
13
18
  describe("cmd", () => {
14
19
  it("builds a simple command", async () => {
15
20
  const built = await cmd("next dev").build(ctx);
@@ -172,30 +177,24 @@ describe("command identity", () => {
172
177
  );
173
178
  });
174
179
 
175
- it("identity proxy still exposes .port() and .url()", async () => {
180
+ it("identity proxy still exposes .port() and .url()", () => {
176
181
  const c = cmd("node server.js").withIdentity("api");
177
182
  expect(c.identity.name).toBe("api");
178
- expect(typeof c.identity.port()).toBe("function");
179
- expect(typeof c.identity.url("/health")).toBe("function");
180
- expect(typeof c.identity.localhostUrl()).toBe("function");
183
+ expect(typeof c.identity.port()).toBe("string");
184
+ expect(typeof c.identity.url("/health")).toBe("string");
185
+ expect(typeof c.identity.localhostUrl()).toBe("string");
181
186
  });
182
187
 
183
- it("identity.port() returns a token", async () => {
188
+ it("identity.port() returns a string port number", () => {
184
189
  const c = cmd("node server.js").withIdentity("api").dir("apps/api");
185
- const token = c.identity.port();
186
- expect(typeof token).toBe("function");
187
- const port = Number.parseInt(
188
- await (token as (ctx: ComputeContext) => Promise<string>)(ctx),
189
- 10,
190
- );
190
+ const port = Number.parseInt(c.identity.port(), 10);
191
191
  expect(port).toBeGreaterThanOrEqual(3000);
192
192
  expect(port).toBeLessThanOrEqual(9999);
193
193
  });
194
194
 
195
- it("identity.localhostUrl() returns a token", async () => {
195
+ it("identity.localhostUrl() returns a URL string", () => {
196
196
  const c = cmd("node server.js").withIdentity("api");
197
- const token = c.identity.localhostUrl("/health");
198
- const url = await (token as (ctx: ComputeContext) => Promise<string>)(ctx);
197
+ const url = c.identity.localhostUrl("/health");
199
198
  expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
200
199
  });
201
200
 
@@ -207,36 +206,22 @@ describe("command identity", () => {
207
206
  expect(built.env.API_URL).toMatch(/^http:\/\/localhost:\d+$/);
208
207
  });
209
208
 
210
- it("identity.port(name) resolves a named sub-identity", async () => {
209
+ it("identity.port(name) resolves a named sub-identity", () => {
211
210
  const c = cmd("docker compose").withIdentity("infra");
212
- const dbPortToken = c.identity.port("database");
213
- const infraPortToken = c.identity.port();
214
- const dbPort = await (
215
- dbPortToken as (ctx: ComputeContext) => Promise<string>
216
- )(ctx);
217
- const infraPort = await (
218
- infraPortToken as (ctx: ComputeContext) => Promise<string>
219
- )(ctx);
211
+ const dbPort = c.identity.port("database");
212
+ const infraPort = c.identity.port();
220
213
  // Named sub-identity should produce a different port
221
214
  expect(dbPort).not.toBe(infraPort);
222
215
  expect(Number.parseInt(dbPort, 10)).toBeGreaterThanOrEqual(3000);
223
216
  });
224
217
 
225
- it("identity.localhostUrl with portIdentity option", async () => {
218
+ it("identity.localhostUrl with portIdentity option", () => {
226
219
  const c = cmd("docker compose").withIdentity("infra");
227
- const urlToken = c.identity.localhostUrl("/health", {
228
- portIdentity: "minio",
229
- });
230
- const url = await (urlToken as (ctx: ComputeContext) => Promise<string>)(
231
- ctx,
232
- );
220
+ const url = c.identity.localhostUrl("/health", { portIdentity: "minio" });
233
221
  expect(url).toMatch(/^http:\/\/localhost:\d+\/health$/);
234
222
 
235
223
  // Port in the URL should match minio identity, not infra
236
- const minioPortToken = c.identity.port("minio");
237
- const minioPort = await (
238
- minioPortToken as (ctx: ComputeContext) => Promise<string>
239
- )(ctx);
224
+ const minioPort = c.identity.port("minio");
240
225
  expect(url).toBe(`http://localhost:${minioPort}/health`);
241
226
  });
242
227
  });
@@ -326,3 +311,28 @@ describe("logs", () => {
326
311
  expect(built2).toBeDefined();
327
312
  });
328
313
  });
314
+
315
+ describe("ensurePort", () => {
316
+ it("returns a new command instance (immutability)", () => {
317
+ const base = cmd("node server.js").withIdentity("api");
318
+ const withEnsure = base.ensurePort();
319
+ expect(withEnsure).not.toBe(base);
320
+ });
321
+
322
+ it("propagates through builder chain", async () => {
323
+ const c = cmd("node server.js")
324
+ .withIdentity("api")
325
+ .ensurePort()
326
+ .dir("apps/api")
327
+ .env({ KEY: "value" })
328
+ .flag("verbose");
329
+ const built = await c.build(ctx);
330
+ expect(built).toBeDefined();
331
+ });
332
+
333
+ it("builds without error when ensurePort is set", async () => {
334
+ const c = cmd("node server.js").withIdentity("api").ensurePort();
335
+ const built = await c.build(ctx);
336
+ expect(built.command).toBe("node server.js");
337
+ });
338
+ });
@@ -6,6 +6,7 @@ import {
6
6
  getRuntimeContext,
7
7
  } from "../runner/runtime-context.js";
8
8
  import { createIdentity, type Identity } from "../utils/dna.js";
9
+ import { killProcessOnPort } from "../utils/port.js";
9
10
  import { resolveToken } from "./token.js";
10
11
  import {
11
12
  type BuiltCommand,
@@ -26,6 +27,7 @@ interface CommandState {
26
27
  readonly dependencies: readonly Runnable[];
27
28
  readonly dirPath: string | null;
28
29
  readonly displayName: string | null;
30
+ readonly ensurePort: boolean;
29
31
  readonly envSources: readonly EnvSource[];
30
32
  readonly flags: readonly { name: string; value?: Token }[];
31
33
  readonly healthChecks: readonly HealthCheck[];
@@ -69,6 +71,10 @@ class Command implements ICommand {
69
71
  return new Command({ ...this.state, identitySuffix: suffix });
70
72
  }
71
73
 
74
+ ensurePort(): ICommand {
75
+ return new Command({ ...this.state, ensurePort: true });
76
+ }
77
+
72
78
  logs(options: { silent?: boolean }): ICommand {
73
79
  return new Command({
74
80
  ...this.state,
@@ -91,13 +97,6 @@ class Command implements ICommand {
91
97
  }
92
98
 
93
99
  env(source: EnvSource): ICommand {
94
- if (isEnvCallback(source)) {
95
- const resolved = source(this);
96
- return new Command({
97
- ...this.state,
98
- envSources: [...this.state.envSources, resolved],
99
- });
100
- }
101
100
  return new Command({
102
101
  ...this.state,
103
102
  envSources: [...this.state.envSources, source],
@@ -157,7 +156,7 @@ class Command implements ICommand {
157
156
  // Resolve env: inherited sources first, then own sources (own wins)
158
157
  const env: Record<string, string> = {};
159
158
  for (const source of this.state.envSources) {
160
- await mergeEnvSource(env, source, ctx);
159
+ await mergeEnvSource(env, source, ctx, this);
161
160
  }
162
161
 
163
162
  return {
@@ -194,7 +193,7 @@ class Command implements ICommand {
194
193
  const allEnvSources = [...rtx.inheritedEnv, ...this.state.envSources];
195
194
 
196
195
  for (const source of allEnvSources) {
197
- await mergeEnvSource(computeCtx.env, source, computeCtx);
196
+ await mergeEnvSource(computeCtx.env, source, computeCtx, this);
198
197
  }
199
198
 
200
199
  const built = await this.build(computeCtx);
@@ -218,11 +217,26 @@ class Command implements ICommand {
218
217
  );
219
218
  rtx.logger.system(` dependsOn: ${depNames.join(", ")}`);
220
219
  }
220
+ if (this.state.ensurePort) {
221
+ rtx.logger.system(` ensurePort: ${this.identity.port()}`);
222
+ }
221
223
  rtx.logger.system("");
222
224
  deferred.resolve();
223
225
  return;
224
226
  }
225
227
 
228
+ if (this.state.ensurePort) {
229
+ const port = Number.parseInt(this.identity.port(), 10);
230
+ const killed = await killProcessOnPort(port);
231
+ if (killed) {
232
+ rtx.logger.output(
233
+ name,
234
+ `Killed process ${killed} on port ${port}`,
235
+ "stderr",
236
+ );
237
+ }
238
+ }
239
+
226
240
  const fullCmd = [built.command, ...built.args].join(" ");
227
241
  rtx.logger.starting(name, name);
228
242
  rtx.logger.system(` ${fullCmd}`);
@@ -314,10 +328,12 @@ async function mergeEnvSource(
314
328
  target: Record<string, string>,
315
329
  source: EnvSource,
316
330
  ctx: ComputeContext,
331
+ command?: ICommand,
317
332
  ): Promise<void> {
318
333
  if (isEnvCallback(source)) {
319
- // Should not happen — callbacks are resolved eagerly in Command.env()
320
- // Defensive: skip
334
+ if (!command) return;
335
+ const resolved = source(command);
336
+ await mergeEnvSource(target, resolved, ctx);
321
337
  return;
322
338
  }
323
339
  if (isLazyEnvSource(source)) {
@@ -343,6 +359,7 @@ export function cmd(base: string): ICommand {
343
359
  dependencies: [],
344
360
  dirPath: null,
345
361
  displayName: null,
362
+ ensurePort: false,
346
363
  envSources: [],
347
364
  flags: [],
348
365
  healthChecks: [],
@@ -54,6 +54,7 @@ export interface ICommand {
54
54
  collectNames(): string[];
55
55
  dependsOn(...deps: Runnable[]): ICommand;
56
56
  dir(path: string): ICommand;
57
+ ensurePort(): ICommand;
57
58
  env(source: EnvSource): ICommand;
58
59
  flag(name: string, value?: Token): ICommand;
59
60
  readonly identity: Identity;
@@ -59,6 +59,10 @@ class DockerCompose implements IDockerCompose {
59
59
  return new DockerCompose(this.inner.withIdentity(suffix), this.dcState);
60
60
  }
61
61
 
62
+ ensurePort(): IDockerCompose {
63
+ return new DockerCompose(this.inner.ensurePort(), this.dcState);
64
+ }
65
+
62
66
  logs(options: { silent?: boolean }): IDockerCompose {
63
67
  return new DockerCompose(this.inner.logs(options), this.dcState);
64
68
  }
@@ -37,6 +37,10 @@ class Nextjs implements INextjs {
37
37
  return new Nextjs(this.inner.withIdentity(suffix));
38
38
  }
39
39
 
40
+ ensurePort(): INextjs {
41
+ return new Nextjs(this.inner.ensurePort());
42
+ }
43
+
40
44
  logs(options: { silent?: boolean }): INextjs {
41
45
  return new Nextjs(this.inner.logs(options));
42
46
  }
@@ -85,9 +89,11 @@ class Nextjs implements INextjs {
85
89
  export function nextjs(): INextjs {
86
90
  // Base: `next` with PORT from identity. Mode (.arg("dev"/"start"/"build"))
87
91
  // and health checks are added by the consumer.
88
- const inner = cmd("next").env((self) => ({
89
- PORT: self.identity.port(),
90
- }));
92
+ const inner = cmd("next")
93
+ .ensurePort()
94
+ .env((self) => ({
95
+ PORT: self.identity.port(),
96
+ }));
91
97
  return new Nextjs(inner);
92
98
  }
93
99
 
@@ -1,133 +1,129 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import type { ComputeContext } from "../../core/types.js";
3
2
  import { identity } from "../dna.js";
4
3
 
5
- const ctx: ComputeContext = {
6
- cwd: "/projects/myapp",
7
- env: {},
8
- root: "/projects/myapp",
9
- };
10
-
11
- async function resolveToken(
12
- token: ReturnType<ReturnType<typeof identity>["port"]>,
13
- context: ComputeContext,
14
- ): Promise<string> {
15
- return typeof token === "function" ? token(context) : token;
16
- }
4
+ const root = "/projects/myapp";
17
5
 
18
6
  describe("DNA", () => {
19
- it("port() returns a number in range 3000–9999", async () => {
20
- const result = await resolveToken(identity("hono").port(), ctx);
7
+ it("port() returns a number in range 3000–9999", () => {
8
+ const result = identity("hono", root).port();
21
9
  const port = Number.parseInt(result, 10);
22
10
  expect(port).toBeGreaterThanOrEqual(3000);
23
11
  expect(port).toBeLessThanOrEqual(9999);
24
12
  });
25
13
 
26
- it("port() is deterministic for same cwd + suffix", async () => {
27
- const r1 = await resolveToken(identity("hono").port(), ctx);
28
- const r2 = await resolveToken(identity("hono").port(), ctx);
14
+ it("port() is deterministic for same root + suffix", () => {
15
+ const r1 = identity("hono", root).port();
16
+ const r2 = identity("hono", root).port();
29
17
  expect(r1).toBe(r2);
30
18
  });
31
19
 
32
- it("port() differs for different suffixes", async () => {
33
- const r1 = await resolveToken(identity("hono").port(), ctx);
34
- const r2 = await resolveToken(identity("next").port(), ctx);
20
+ it("port() differs for different suffixes", () => {
21
+ const r1 = identity("hono", root).port();
22
+ const r2 = identity("next", root).port();
35
23
  expect(r1).not.toBe(r2);
36
24
  });
37
25
 
38
- it("port() differs for different cwds", async () => {
39
- const ctx2: ComputeContext = { cwd: "/other/project", env: {}, root: "/" };
40
- const r1 = await resolveToken(identity("hono").port(), ctx);
41
- const r2 = await resolveToken(identity("hono").port(), ctx2);
26
+ it("port() differs for different roots", () => {
27
+ const r1 = identity("hono", root).port();
28
+ const r2 = identity("hono", "/other/project").port();
42
29
  expect(r1).not.toBe(r2);
43
30
  });
44
31
 
45
- it("url() returns http://localhost:{port}", async () => {
46
- const result = await resolveToken(identity("hono").url(), ctx);
32
+ it("url() returns http://localhost:{port}", () => {
33
+ const result = identity("hono", root).url();
47
34
  expect(result).toMatch(/^http:\/\/localhost:\d+$/);
48
35
  });
49
36
 
50
- it("url(path) appends the path", async () => {
51
- const result = await resolveToken(identity("hono").url("/health"), ctx);
37
+ it("url(path) appends the path", () => {
38
+ const result = identity("hono", root).url("/health");
52
39
  expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
53
40
  });
54
41
 
55
- it("url() port matches port()", async () => {
56
- const port = await resolveToken(identity("hono").port(), ctx);
57
- const url = await resolveToken(identity("hono").url("/api"), ctx);
42
+ it("url() port matches port()", () => {
43
+ const id = identity("hono", root);
44
+ const port = id.port();
45
+ const url = id.url("/api");
58
46
  expect(url).toBe(`http://localhost:${port}/api`);
59
47
  });
60
48
 
61
49
  it("has a name property matching the suffix", () => {
62
- expect(identity("server").name).toBe("server");
50
+ expect(identity("server", root).name).toBe("server");
63
51
  });
64
52
 
65
53
  it("has an empty name when no suffix is provided", () => {
66
- expect(identity().name).toBe("");
54
+ expect(identity(undefined, root).name).toBe("");
67
55
  });
68
56
 
69
- it("localhostUrl() returns http://localhost:{port}", async () => {
70
- const result = await resolveToken(identity("hono").localhostUrl(), ctx);
57
+ it("localhostUrl() returns http://localhost:{port}", () => {
58
+ const result = identity("hono", root).localhostUrl();
71
59
  expect(result).toMatch(/^http:\/\/localhost:\d+$/);
72
60
  });
73
61
 
74
- it("localhostUrl(path) appends the path", async () => {
75
- const result = await resolveToken(
76
- identity("hono").localhostUrl("/health"),
77
- ctx,
78
- );
62
+ it("localhostUrl(path) appends the path", () => {
63
+ const result = identity("hono", root).localhostUrl("/health");
79
64
  expect(result).toMatch(/^http:\/\/localhost:\d+\/health$/);
80
65
  });
81
66
 
82
- it("localhostUrl() matches url() output", async () => {
83
- const id = identity("hono");
84
- const fromUrl = await resolveToken(id.url("/api"), ctx);
85
- const fromLocalhostUrl = await resolveToken(id.localhostUrl("/api"), ctx);
67
+ it("localhostUrl() matches url() output", () => {
68
+ const id = identity("hono", root);
69
+ const fromUrl = id.url("/api");
70
+ const fromLocalhostUrl = id.localhostUrl("/api");
86
71
  expect(fromLocalhostUrl).toBe(fromUrl);
87
72
  });
88
73
 
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);
74
+ it("port(identityName) computes port for a different identity", () => {
75
+ const id = identity("infra", root);
76
+ const ownPort = id.port();
77
+ const dbPort = id.port("database");
93
78
  expect(ownPort).not.toBe(dbPort);
94
79
  const port = Number.parseInt(dbPort, 10);
95
80
  expect(port).toBeGreaterThanOrEqual(3000);
96
81
  expect(port).toBeLessThanOrEqual(9999);
97
82
  });
98
83
 
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);
84
+ it("port(identityName) is deterministic", () => {
85
+ const id = identity("infra", root);
86
+ const r1 = id.port("database");
87
+ const r2 = id.port("database");
103
88
  expect(r1).toBe(r2);
104
89
  });
105
90
 
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);
91
+ it("port(identityName) matches standalone identity port", () => {
92
+ const infra = identity("infra", root);
93
+ const db = identity("database", root);
94
+ const fromSub = infra.port("database");
95
+ const fromStandalone = db.port();
111
96
  expect(fromSub).toBe(fromStandalone);
112
97
  });
113
98
 
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
- );
99
+ it("url with portIdentity option uses different port", () => {
100
+ const id = identity("infra", root);
101
+ const minioPort = id.port("minio");
102
+ const url = id.url("/health", { portIdentity: "minio" });
121
103
  expect(url).toBe(`http://localhost:${minioPort}/health`);
122
104
  });
123
105
 
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
- );
106
+ it("localhostUrl with portIdentity option uses different port", () => {
107
+ const id = identity("infra", root);
108
+ const minioPort = id.port("minio");
109
+ const url = id.localhostUrl("/health", { portIdentity: "minio" });
131
110
  expect(url).toBe(`http://localhost:${minioPort}/health`);
132
111
  });
112
+
113
+ it("returns plain strings (not functions)", () => {
114
+ const id = identity("server", root);
115
+ expect(typeof id.port()).toBe("string");
116
+ expect(typeof id.url()).toBe("string");
117
+ expect(typeof id.localhostUrl()).toBe("string");
118
+ });
119
+
120
+ it("works in template literals", () => {
121
+ const id = identity("database", root);
122
+ const url = `postgresql://user:pass@localhost:${id.port()}/mydb`;
123
+ expect(url).toMatch(/^postgresql:\/\/user:pass@localhost:\d+\/mydb$/);
124
+ });
125
+
126
+ it("throws when no root is available", () => {
127
+ expect(() => identity("test")).toThrow("no root available");
128
+ });
133
129
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared config context using globalThis so it survives across module
3
+ * instances (jiti creates separate module caches from Node's native one).
4
+ */
5
+
6
+ const KEY = "__bes_config_root__";
7
+
8
+ export function setConfigRoot(root: string): void {
9
+ (globalThis as Record<string, unknown>)[KEY] = root;
10
+ }
11
+
12
+ export function getConfigRoot(): string | null {
13
+ return ((globalThis as Record<string, unknown>)[KEY] as string) ?? null;
14
+ }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import type { Token } from "../core/types.js";
2
+ import { getConfigRoot } from "./config-context.js";
3
3
 
4
4
  // Well-known/reserved ports in the 3000-9999 range to avoid
5
5
  const RESERVED_PORTS = new Set([
@@ -57,37 +57,41 @@ function computePort(
57
57
  }
58
58
 
59
59
  export interface Identity {
60
- localhostUrl: (path?: string, options?: { portIdentity?: string }) => Token;
60
+ localhostUrl: (path?: string, options?: { portIdentity?: string }) => string;
61
61
  readonly name: string;
62
- port: (identityName?: string) => Token;
63
- url: (path?: string, options?: { portIdentity?: string }) => Token;
62
+ port: (identityName?: string) => string;
63
+ url: (path?: string, options?: { portIdentity?: string }) => string;
64
64
  }
65
65
 
66
- export function createIdentity(suffix?: string): Identity {
66
+ export function createIdentity(suffix?: string, root?: string): Identity {
67
67
  const name = suffix || "";
68
+ const resolvedRoot = root ?? getConfigRoot();
68
69
 
69
- const urlToken = (
70
+ if (!resolvedRoot) {
71
+ throw new Error(
72
+ "Cannot create identity: no root available. " +
73
+ "Ensure identity() is called during config evaluation or pass root explicitly.",
74
+ );
75
+ }
76
+
77
+ const urlFn = (
70
78
  pathValue?: string,
71
79
  options?: { portIdentity?: string },
72
- ): Token => {
73
- return async ({ cwd }) => {
74
- const portName = options?.portIdentity ?? name;
75
- const port = computePort(cwd, portName);
76
- const base = `http://localhost:${port}`;
77
- return pathValue ? `${base}${pathValue}` : base;
78
- };
80
+ ): string => {
81
+ const portName = options?.portIdentity ?? name;
82
+ const port = computePort(resolvedRoot, portName);
83
+ const base = `http://localhost:${port}`;
84
+ return pathValue ? `${base}${pathValue}` : base;
79
85
  };
80
86
 
81
87
  return {
82
- localhostUrl: urlToken,
88
+ localhostUrl: urlFn,
83
89
  name,
84
- port: (identityName?: string): Token => {
85
- return async ({ cwd }) => {
86
- const portName = identityName ?? name;
87
- return computePort(cwd, portName).toString();
88
- };
90
+ port: (identityName?: string): string => {
91
+ const portName = identityName ?? name;
92
+ return computePort(resolvedRoot, portName).toString();
89
93
  },
90
- url: urlToken,
94
+ url: urlFn,
91
95
  };
92
96
  }
93
97
 
@@ -0,0 +1,51 @@
1
+ import { execSync } from "node:child_process";
2
+
3
+ /**
4
+ * Find and kill any process listening on the given port.
5
+ * Returns the PID of the first process killed, or null if the port was free.
6
+ */
7
+ export async function killProcessOnPort(
8
+ port: number,
9
+ ): Promise<number | null> {
10
+ let pids: number[];
11
+ try {
12
+ const output = execSync(`lsof -ti :${port}`, {
13
+ encoding: "utf8",
14
+ stdio: ["pipe", "pipe", "pipe"],
15
+ }).trim();
16
+ if (!output) return null;
17
+ pids = output
18
+ .split("\n")
19
+ .map((p) => Number.parseInt(p.trim(), 10))
20
+ .filter(Boolean);
21
+ } catch {
22
+ return null; // No process on that port
23
+ }
24
+
25
+ if (pids.length === 0) return null;
26
+
27
+ const first = pids[0];
28
+
29
+ // SIGTERM first
30
+ for (const pid of pids) {
31
+ try {
32
+ process.kill(pid, "SIGTERM");
33
+ } catch {
34
+ // Already exited
35
+ }
36
+ }
37
+
38
+ // Wait, then SIGKILL survivors
39
+ await new Promise((resolve) => setTimeout(resolve, 1000));
40
+
41
+ for (const pid of pids) {
42
+ try {
43
+ process.kill(pid, 0); // Check if still alive
44
+ process.kill(pid, "SIGKILL");
45
+ } catch {
46
+ // Already dead
47
+ }
48
+ }
49
+
50
+ return first;
51
+ }