portcall 0.1.0 → 0.2.0

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/README.md CHANGED
@@ -43,6 +43,22 @@ Node 20.19+, macOS or Linux.
43
43
  - Each command runs with `PORT` set to its service's port. `env` takes `{port}` and `{ports.<service>}`.
44
44
  - A service without `command` is external (a database in Docker, a tunnel...): portcall only checks that its port answers.
45
45
  - `cwd` runs a command in a subfolder; `readyTimeout` (seconds, default 60) bounds how long `up` waits for the port.
46
+ - `"worktrees": true` lets each git worktree run its own servers at the same time (see below).
47
+
48
+ ### Git worktrees
49
+
50
+ By default, every checkout of a project shares its declared ports: one checkout runs a service at a time, and the others see it as `elsewhere`.
51
+
52
+ With `"worktrees": true`, each linked worktree gets its own ports. The first time portcall runs in a worktree, it picks an offset (a multiple of 10) that moves the worktree's services onto ports no known project declares and nothing listens on, and keeps it. The main checkout keeps the declared ports, and external services (a database...) keep theirs: worktrees share them.
53
+
54
+ ```sh
55
+ cd ../myapp-feature-x # git worktree add ../myapp-feature-x
56
+ portcall up # server on 3010, web on 5183 (offset 10)
57
+ portcall status --json # "port": 3010, "basePort": 3000, "worktree": "myapp-feature-x"
58
+ portcall logs -p myapp@myapp-feature-x
59
+ ```
60
+
61
+ This only works if the app takes its ports from portcall rather than hardcoding them: read `PORT` (in `vite.config`, `server: { port: Number(process.env.PORT) || 5173, strictPort: true }`), and pass the other services' URLs through `env` with `{ports.<service>}`, including proxy targets and allowed origins.
46
62
 
47
63
  ## Commands
48
64
 
@@ -56,7 +72,7 @@ Node 20.19+, macOS or Linux.
56
72
  | `portcall ports` | Every known project's ports, with conflicts. Exits 1 on a conflict. |
57
73
  | `portcall agents` | Prints a section to paste into `AGENTS.md` / `CLAUDE.md`. |
58
74
 
59
- Every command takes `--json`, `-p <project>` (act on a known project from anywhere) and `-C <dir>`.
75
+ Every command takes `--json`, `-p <project>` (act on a known project from anywhere; `<project>@<worktree>` for a worktree with its own ports) and `-C <dir>`.
60
76
 
61
77
  ### Service states
62
78
 
@@ -67,7 +83,7 @@ Every command takes `--json`, `-p <project>` (act on a known project from anywhe
67
83
  | `stopped` | nothing on its port |
68
84
  | `unmanaged` | its port is held by a process started inside the project, outside portcall (say, in a terminal). `up` leaves it be; `down --force` stops it |
69
85
  | `taken` | its port is held by something outside the project. portcall never touches it |
70
- | `elsewhere` | portcall runs it from another checkout of the project (a git worktree) |
86
+ | `elsewhere` | portcall runs it from another checkout of the project (a git worktree, without `"worktrees": true`) |
71
87
 
72
88
  ## With coding agents
73
89
 
package/dist/portcall.mjs CHANGED
@@ -11,7 +11,7 @@ import { readlink } from "node:fs/promises";
11
11
  import { connect } from "node:net";
12
12
  import { createInterface } from "node:readline";
13
13
  // package.json
14
- var version$1 = "0.1.0";
14
+ var version$1 = "0.2.0";
15
15
  // node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/core.js
16
16
  var _a$1;
17
17
  const _zodDesc = {
@@ -714,6 +714,7 @@ const safeParseAsync = /* @__PURE__*/ _safeParseAsync($ZodRealError);
714
714
  // node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/regexes.js
715
715
  const anyString = /^[\s\S]{0,}$/;
716
716
  const number$1 = /^-?\d+(?:\.\d+)?$/;
717
+ const boolean$1 = /^(?:true|false)$/i;
717
718
  // node_modules/.pnpm/zod@4.6.7/node_modules/zod/v4/core/checks.js
718
719
  const $ZodCheck = /*@__PURE__*/ $constructor("$ZodCheck", (inst, def) => {
719
720
  var _a;
@@ -1042,6 +1043,24 @@ const $ZodNumberFormat = /*@__PURE__*/ $constructor("$ZodNumberFormat", (inst, d
1042
1043
  $ZodCheckNumberFormat.init(inst, def);
1043
1044
  $ZodNumber.init(inst, def);
1044
1045
  });
1046
+ const $ZodBoolean = /*@__PURE__*/ $constructor("$ZodBoolean", (inst, def) => {
1047
+ $ZodType.init(inst, def);
1048
+ inst._zod.pattern = boolean$1;
1049
+ inst._zod.parse = (payload, _ctx) => {
1050
+ if (def.coerce) try {
1051
+ payload.value = Boolean(payload.value);
1052
+ } catch (_) {}
1053
+ const input = payload.value;
1054
+ if (typeof input === "boolean") return payload;
1055
+ payload.issues.push({
1056
+ expected: "boolean",
1057
+ code: "invalid_type",
1058
+ input,
1059
+ inst
1060
+ });
1061
+ return payload;
1062
+ };
1063
+ });
1045
1064
  const $ZodNever = /*@__PURE__*/ $constructor("$ZodNever", (inst, def) => {
1046
1065
  $ZodType.init(inst, def);
1047
1066
  inst._zod.parse = (payload, _ctx) => {
@@ -1441,6 +1460,13 @@ function _int(Class, params) {
1441
1460
  });
1442
1461
  }
1443
1462
  // @__NO_SIDE_EFFECTS__
1463
+ function _boolean(Class, params) {
1464
+ return new Class({
1465
+ type: "boolean",
1466
+ ...normalizeParams(params)
1467
+ });
1468
+ }
1469
+ // @__NO_SIDE_EFFECTS__
1444
1470
  function _never(Class, params) {
1445
1471
  return new Class({
1446
1472
  type: "never",
@@ -1581,6 +1607,14 @@ const ZodMiniNumberFormat = /*@__PURE__*/ $constructor("ZodMiniNumberFormat", (i
1581
1607
  function int(params) {
1582
1608
  return /* @__PURE__ */ _int(ZodMiniNumberFormat, params);
1583
1609
  }
1610
+ const ZodMiniBoolean = /*@__PURE__*/ $constructor("ZodMiniBoolean", (inst, def) => {
1611
+ $ZodBoolean.init(inst, def);
1612
+ ZodMiniType.init(inst, def);
1613
+ });
1614
+ // @__NO_SIDE_EFFECTS__
1615
+ function boolean(params) {
1616
+ return /* @__PURE__ */ _boolean(ZodMiniBoolean, params);
1617
+ }
1584
1618
  const ZodMiniNever = /*@__PURE__*/ $constructor("ZodMiniNever", (inst, def) => {
1585
1619
  $ZodNever.init(inst, def);
1586
1620
  ZodMiniType.init(inst, def);
@@ -1649,7 +1683,8 @@ const serviceSchema = /* @__PURE__ */ strictObject({
1649
1683
  const configSchema = /* @__PURE__ */ strictObject({
1650
1684
  $schema: /* @__PURE__ */ optional(/* @__PURE__ */ string()),
1651
1685
  name: nameSchema.check(describe$1("Project name, used by `portcall -p <name>` and in the logs folder.")),
1652
- services: (/* @__PURE__ */ record(nameSchema, serviceSchema)).check(describe$1("The project's services, by name."))
1686
+ services: (/* @__PURE__ */ record(nameSchema, serviceSchema)).check(describe$1("The project's services, by name.")),
1687
+ worktrees: (/* @__PURE__ */ optional(/* @__PURE__ */ boolean())).check(describe$1("Let each git worktree of the project run its services at the same time, on ports offset from the declared ones (external services keep theirs). Commands must take their port from PORT and {ports.<service>}. Default: false, all checkouts share the declared ports."))
1653
1688
  });
1654
1689
  var ConfigError = class extends Error {};
1655
1690
  function findConfig(from) {
@@ -1662,16 +1697,37 @@ function findConfig(from) {
1662
1697
  dir = parent;
1663
1698
  }
1664
1699
  }
1665
- function loadProject(file) {
1700
+ /** The worktree name when `dir` is inside a linked git worktree (not the main checkout). */
1701
+ function linkedWorktree(dir) {
1702
+ for (let current = resolve(dir);;) {
1703
+ const dotGit = join(current, ".git");
1704
+ if (existsSync(dotGit)) {
1705
+ let content;
1706
+ try {
1707
+ content = readFileSync(dotGit, "utf8");
1708
+ } catch {
1709
+ return;
1710
+ }
1711
+ const gitDir = /^gitdir: (.+)$/m.exec(content)?.[1]?.trim();
1712
+ if (!gitDir) return void 0;
1713
+ const resolved = resolve(current, gitDir);
1714
+ return existsSync(join(resolved, "commondir")) ? basename(resolved) : void 0;
1715
+ }
1716
+ const parent = dirname(current);
1717
+ if (parent === current) return void 0;
1718
+ current = parent;
1719
+ }
1720
+ }
1721
+ function loadProject(file, offsets = {}) {
1666
1722
  let raw;
1667
1723
  try {
1668
1724
  raw = JSON.parse(readFileSync(file, "utf8"));
1669
1725
  } catch (error) {
1670
1726
  throw new ConfigError(`${file}: ${error.message}`);
1671
1727
  }
1672
- return parseProject(raw, join(realpathSync(dirname(file)), basename(file)));
1728
+ return parseProject(raw, join(realpathSync(dirname(file)), basename(file)), offsets);
1673
1729
  }
1674
- function parseProject(raw, file) {
1730
+ function parseProject(raw, file, offsets = {}) {
1675
1731
  const parsed = configSchema.safeParse(raw);
1676
1732
  if (!parsed.success) throw new ConfigError(`${file}:\n${prettifyError(parsed.error)}`);
1677
1733
  const config = parsed.data;
@@ -1684,24 +1740,33 @@ function parseProject(raw, file) {
1684
1740
  if (other) throw new ConfigError(`${file}: services "${other}" and "${name}" both use port ${service.port}`);
1685
1741
  ports.set(service.port, name);
1686
1742
  }
1743
+ const worktree = config.worktrees ? linkedWorktree(root) : void 0;
1744
+ const portOffset = worktree ? offsets[root] : void 0;
1745
+ const effective = new Map(entries.map(([name, service]) => [name, service.port + (service.command ? portOffset ?? 0 : 0)]));
1687
1746
  const services = entries.map(([name, service]) => ({
1688
1747
  name,
1689
- port: service.port,
1748
+ port: effective.get(name),
1749
+ basePort: service.port,
1690
1750
  command: service.command,
1691
1751
  cwd: resolve(root, service.cwd ?? "."),
1692
- env: expandEnv(service.env ?? {}, service.port, ports, file, name),
1752
+ env: expandEnv(service.env ?? {}, effective.get(name), effective, file, name),
1693
1753
  readyTimeout: service.readyTimeout ?? DEFAULT_READY_TIMEOUT
1694
1754
  }));
1755
+ const instance = worktree ? `${config.name}@${worktree}` : config.name;
1695
1756
  return {
1696
1757
  name: config.name,
1758
+ instance,
1697
1759
  root,
1698
1760
  file,
1699
- services
1761
+ services,
1762
+ ...worktree ? {
1763
+ worktree,
1764
+ portOffset
1765
+ } : {}
1700
1766
  };
1701
1767
  }
1702
1768
  /** Fills `{port}` (this service) and `{ports.<service>}` (any service of the project). */
1703
- function expandEnv(env, port, ports, file, service) {
1704
- const byName = new Map([...ports].map(([p, name]) => [name, p]));
1769
+ function expandEnv(env, port, byName, file, service) {
1705
1770
  const out = {};
1706
1771
  for (const [key, value] of Object.entries(env)) out[key] = value.replace(/\{(port|ports\.([\w.-]+))\}/g, (_, whole, other) => {
1707
1772
  if (whole === "port") return String(port);
@@ -2008,12 +2073,13 @@ function knownProjects() {
2008
2073
  const errors = [];
2009
2074
  const kept = [];
2010
2075
  const roots = readRoots();
2076
+ const offsets = readState().offsets;
2011
2077
  for (const root of roots) {
2012
2078
  const file = join(root, CONFIG_FILE);
2013
2079
  if (!existsSync(file)) continue;
2014
2080
  kept.push(root);
2015
2081
  try {
2016
- projects.push(loadProject(file));
2082
+ projects.push(loadProject(file, offsets));
2017
2083
  } catch (error) {
2018
2084
  errors.push(error.message);
2019
2085
  }
@@ -2033,10 +2099,12 @@ async function serviceStatus(project, service, state) {
2033
2099
  project: project.name,
2034
2100
  service: service.name,
2035
2101
  port: service.port,
2102
+ ...service.port !== service.basePort ? { basePort: service.basePort } : {},
2103
+ ...project.worktree ? { worktree: project.worktree } : {},
2036
2104
  url: `http://localhost:${service.port}`,
2037
2105
  external: !service.command
2038
2106
  };
2039
- const record = state.services[recordKey(project.name, service.name)];
2107
+ const record = state.services[recordKey(project.instance, service.name)];
2040
2108
  if (record) {
2041
2109
  const alive = isAlive(record.supervisorPid) || isAlive(record.childPid);
2042
2110
  const managed = {
@@ -2063,7 +2131,7 @@ async function serviceStatus(project, service, state) {
2063
2131
  state: await isListening(service.port) ? "running" : "starting"
2064
2132
  };
2065
2133
  }
2066
- const log = service.command ? logPath(project.name, service.name) : void 0;
2134
+ const log = service.command ? logPath(project.instance, service.name) : void 0;
2067
2135
  if (!await isListening(service.port)) return {
2068
2136
  ...base,
2069
2137
  log,
@@ -2085,22 +2153,52 @@ async function serviceStatus(project, service, state) {
2085
2153
  async function projectStatus(project, state, only) {
2086
2154
  return Promise.all((only ?? project.services).map((service) => serviceStatus(project, service, state)));
2087
2155
  }
2156
+ // src/worktrees.ts
2157
+ const OFFSET_STEP = 10;
2158
+ const MAX_TRIES = 100;
2159
+ /**
2160
+ * Gives a worktree of a `worktrees: true` project its port offset the first time portcall meets it:
2161
+ * the first multiple of 10 that moves its services onto ports no known project declares and nothing listens on.
2162
+ */
2163
+ async function withPortOffset(project) {
2164
+ if (!project.worktree || project.portOffset !== void 0) return project;
2165
+ const claimed = new Set(knownProjects().projects.filter((p) => p.root !== project.root).flatMap((p) => p.services.map((s) => s.port)));
2166
+ for (const service of project.services) claimed.add(service.basePort);
2167
+ const runnable = project.services.filter((s) => s.command);
2168
+ let chosen;
2169
+ for (let i = 1; i <= MAX_TRIES && chosen === void 0; i++) {
2170
+ const offset = i * OFFSET_STEP;
2171
+ const ports = runnable.map((s) => s.basePort + offset);
2172
+ if (ports.some((port) => port > 65535)) break;
2173
+ if (ports.some((port) => claimed.has(port))) continue;
2174
+ if (!(await Promise.all(ports.map((port) => isListening(port)))).some(Boolean)) chosen = offset;
2175
+ }
2176
+ if (chosen === void 0) throw new Error(`no free port offset for ${project.instance}: every candidate port is declared or in use`);
2177
+ await updateState((state) => {
2178
+ const offsets = state.offsets ??= {};
2179
+ for (const root of Object.keys(offsets)) if (!existsSync(join(root, "portcall.json"))) delete offsets[root];
2180
+ offsets[project.root] ??= chosen;
2181
+ });
2182
+ return loadProject(project.file, readState().offsets);
2183
+ }
2088
2184
  // src/commands.ts
2089
2185
  const SUPERVISE_COMMAND = "__supervise";
2090
2186
  const STOP_WAIT_MS = 8e3;
2091
2187
  var UsageError = class extends Error {};
2092
- function resolveProject(cwd, name) {
2188
+ /** `name` is a project name, or `name@worktree` for a worktree with its own ports. */
2189
+ async function resolveProject(cwd, name) {
2093
2190
  if (name) {
2094
2191
  const { projects } = knownProjects();
2095
- const matches = projects.filter((project) => project.name === name);
2192
+ const exact = projects.filter((project) => project.instance === name);
2193
+ const matches = exact.length ? exact : projects.filter((project) => project.name === name);
2096
2194
  if (matches.length === 0) throw new UsageError(`unknown project "${name}": run any portcall command once inside it so portcall knows where it is`);
2097
2195
  const here = matches.find((project) => cwd === project.root || cwd.startsWith(`${project.root}/`));
2098
- if (here || matches.length === 1) return here ?? matches[0];
2099
- throw new UsageError(`"${name}" exists in several checkouts, run portcall from the one you want:\n${matches.map((p) => ` ${p.root}`).join("\n")}`);
2196
+ if (here || matches.length === 1) return withPortOffset(here ?? matches[0]);
2197
+ throw new UsageError(`"${name}" exists in several checkouts, run portcall from the one you want${matches.some((p) => p.worktree) ? " or name it as project@worktree" : ""}:\n${matches.map((p) => ` ${p.instance} ${p.root}`).join("\n")}`);
2100
2198
  }
2101
2199
  const file = findConfig(cwd);
2102
2200
  if (!file) throw new ConfigError(`no portcall.json in ${cwd} or above (see \`portcall help\`)`);
2103
- const project = loadProject(file);
2201
+ const project = await withPortOffset(loadProject(file, readState().offsets));
2104
2202
  rememberProject(project.root);
2105
2203
  return project;
2106
2204
  }
@@ -2113,6 +2211,12 @@ function selectServices(project, names, runnableOnly = false) {
2113
2211
  return service;
2114
2212
  });
2115
2213
  }
2214
+ function worktreeFields(project) {
2215
+ return project.worktree ? {
2216
+ worktree: project.worktree,
2217
+ portOffset: project.portOffset
2218
+ } : {};
2219
+ }
2116
2220
  function table(rows) {
2117
2221
  const widths = rows[0].map((_, i) => Math.max(...rows.map((row) => row[i].length)));
2118
2222
  return rows.map((row) => row.map((cell, i) => cell.padEnd(widths[i])).join(" ").trimEnd()).join("\n");
@@ -2148,7 +2252,7 @@ function statusRows(statuses, withProject) {
2148
2252
  ];
2149
2253
  const now = Date.now();
2150
2254
  return [head, ...statuses.map((s) => [
2151
- ...withProject ? [s.project] : [],
2255
+ ...withProject ? [s.worktree ? `${s.project}@${s.worktree}` : s.project] : [],
2152
2256
  s.service,
2153
2257
  s.state,
2154
2258
  String(s.port),
@@ -2172,10 +2276,11 @@ async function status(io, cwd, opts) {
2172
2276
  }
2173
2277
  return 0;
2174
2278
  }
2175
- const project = resolveProject(cwd, opts.project);
2279
+ const project = await resolveProject(cwd, opts.project);
2176
2280
  const statuses = await projectStatus(project, state);
2177
2281
  io.out(io.json ? JSON.stringify({
2178
2282
  project: project.name,
2283
+ ...worktreeFields(project),
2179
2284
  root: project.root,
2180
2285
  services: statuses
2181
2286
  }, null, 2) : table(statusRows(statuses, false)));
@@ -2187,9 +2292,9 @@ const UP_FAILURES = [
2187
2292
  "blocked"
2188
2293
  ];
2189
2294
  function startService(project, service, state) {
2190
- const log = logPath(project.name, service.name);
2295
+ const log = logPath(project.instance, service.name);
2191
2296
  const spec = {
2192
- project: project.name,
2297
+ project: project.instance,
2193
2298
  service: service.name,
2194
2299
  command: service.command,
2195
2300
  cwd: service.cwd,
@@ -2215,8 +2320,8 @@ function startService(project, service, state) {
2215
2320
  exited = true;
2216
2321
  });
2217
2322
  child.unref();
2218
- state.services[recordKey(project.name, service.name)] = {
2219
- project: project.name,
2323
+ state.services[recordKey(project.instance, service.name)] = {
2324
+ project: project.instance,
2220
2325
  service: service.name,
2221
2326
  root: project.root,
2222
2327
  port: service.port,
@@ -2240,7 +2345,7 @@ async function waitReady(service, exited) {
2240
2345
  }
2241
2346
  }
2242
2347
  async function up(io, cwd, names, opts) {
2243
- const project = resolveProject(cwd, opts.project);
2348
+ const project = await resolveProject(cwd, opts.project);
2244
2349
  const services = selectServices(project, names);
2245
2350
  const statuses = await projectStatus(project, readState(), services);
2246
2351
  const results = [];
@@ -2309,9 +2414,9 @@ async function up(io, cwd, names, opts) {
2309
2414
  outcome: "started",
2310
2415
  detail: `ready in ${((Date.now() - t0) / 1e3).toFixed(1)}s`
2311
2416
  };
2312
- const logTail = readLog(logPath(project.name, service.name), service.name).slice(-30).map((l) => formatLine(l));
2417
+ const logTail = readLog(logPath(project.instance, service.name), service.name).slice(-30).map((l) => formatLine(l));
2313
2418
  if (ready === "exited") {
2314
- const record = readState().services[recordKey(project.name, service.name)];
2419
+ const record = readState().services[recordKey(project.instance, service.name)];
2315
2420
  return {
2316
2421
  ...base,
2317
2422
  outcome: "crashed",
@@ -2331,6 +2436,7 @@ async function up(io, cwd, names, opts) {
2331
2436
  const failed = results.some((r) => UP_FAILURES.includes(r.outcome));
2332
2437
  if (io.json) io.out(JSON.stringify({
2333
2438
  project: project.name,
2439
+ ...worktreeFields(project),
2334
2440
  ok: !failed,
2335
2441
  services: results
2336
2442
  }, null, 2));
@@ -2371,12 +2477,12 @@ async function stopPid(pid) {
2371
2477
  if (!await waitForDeath(pid, STOP_WAIT_MS)) signal(pid, "SIGKILL");
2372
2478
  }
2373
2479
  async function down(io, cwd, names, opts) {
2374
- const project = resolveProject(cwd, opts.project);
2480
+ const project = await resolveProject(cwd, opts.project);
2375
2481
  const services = selectServices(project, names);
2376
2482
  const state = readState();
2377
2483
  const statuses = await projectStatus(project, state, services);
2378
2484
  const results = await Promise.all(statuses.map(async (st) => {
2379
- const record = state.services[recordKey(project.name, st.service)];
2485
+ const record = state.services[recordKey(project.instance, st.service)];
2380
2486
  if (st.external) return {
2381
2487
  service: st.service,
2382
2488
  outcome: "skipped",
@@ -2444,7 +2550,7 @@ async function down(io, cwd, names, opts) {
2444
2550
  return 0;
2445
2551
  }
2446
2552
  async function restart(io, cwd, names, opts) {
2447
- const runnable = selectServices(resolveProject(cwd, opts.project), names, true).map((s) => s.name);
2553
+ const runnable = selectServices(await resolveProject(cwd, opts.project), names, true).map((s) => s.name);
2448
2554
  await down(io, cwd, runnable, {
2449
2555
  ...opts,
2450
2556
  quiet: true
@@ -2452,10 +2558,10 @@ async function restart(io, cwd, names, opts) {
2452
2558
  return up(io, cwd, runnable, opts);
2453
2559
  }
2454
2560
  async function logs(io, cwd, names, opts) {
2455
- const project = resolveProject(cwd, opts.project);
2561
+ const project = await resolveProject(cwd, opts.project);
2456
2562
  const services = selectServices(project, names, true);
2457
2563
  const files = services.map((s) => ({
2458
- file: logPath(project.name, s.name),
2564
+ file: logPath(project.instance, s.name),
2459
2565
  service: s.name
2460
2566
  }));
2461
2567
  const width = services.length > 1 ? Math.max(...services.map((s) => s.name.length)) : 0;
@@ -2476,19 +2582,20 @@ async function logs(io, cwd, names, opts) {
2476
2582
  return 0;
2477
2583
  }
2478
2584
  async function ports(io, cwd) {
2479
- const file = findConfig(cwd);
2480
- if (file) rememberProject(loadProject(file).root);
2585
+ if (findConfig(cwd)) await resolveProject(cwd);
2481
2586
  const { projects, errors } = knownProjects();
2482
2587
  const state = readState();
2588
+ const claimant = (project, service) => service.command ? project.instance : project.name;
2483
2589
  const claims = /* @__PURE__ */ new Map();
2484
- for (const project of projects) for (const service of project.services) claims.set(service.port, (claims.get(service.port) ?? /* @__PURE__ */ new Set()).add(project.name));
2590
+ for (const project of projects) for (const service of project.services) claims.set(service.port, (claims.get(service.port) ?? /* @__PURE__ */ new Set()).add(claimant(project, service)));
2485
2591
  const seen = /* @__PURE__ */ new Set();
2486
2592
  const rows = [];
2487
2593
  for (const project of projects) {
2488
- if (seen.has(project.name)) continue;
2489
- seen.add(project.name);
2490
- for (const st of await projectStatus(project, state)) {
2491
- const others = [...claims.get(st.port)].filter((name) => name !== project.name);
2594
+ if (seen.has(project.instance)) continue;
2595
+ seen.add(project.instance);
2596
+ for (const [i, st] of (await projectStatus(project, state)).entries()) {
2597
+ const own = claimant(project, project.services[i]);
2598
+ const others = [...claims.get(st.port)].filter((name) => name !== own);
2492
2599
  rows.push({
2493
2600
  ...st,
2494
2601
  conflict: others
@@ -2509,7 +2616,7 @@ async function ports(io, cwd) {
2509
2616
  "INFO"
2510
2617
  ], ...rows.map((r) => [
2511
2618
  String(r.port),
2512
- r.project,
2619
+ r.worktree ? `${r.project}@${r.worktree}` : r.project,
2513
2620
  r.service,
2514
2621
  r.state,
2515
2622
  [r.conflict.length ? `CONFLICT with ${r.conflict.join(", ")}` : "", describe(r)].filter(Boolean).join("; ")
@@ -2632,7 +2739,8 @@ Usage:
2632
2739
  portcall agents Print an AGENTS.md section telling coding agents how to use portcall
2633
2740
 
2634
2741
  Options:
2635
- -p, --project <name> Act on a known project from anywhere (default: the portcall.json at or above the cwd)
2742
+ -p, --project <name> Act on a known project from anywhere (default: the portcall.json at or above the cwd);
2743
+ <name>@<worktree> for a git worktree with its own ports
2636
2744
  -C, --cwd <dir> Run as if started in <dir>
2637
2745
  --json Machine-readable output
2638
2746
  --no-wait up/restart: return without waiting for the port
@@ -2658,6 +2766,7 @@ portcall.json, at the project root:
2658
2766
  }
2659
2767
  A service without "command" is external (a database in Docker...): portcall only checks its port.
2660
2768
  Each command gets PORT set to its port. Logs and state live in ~/.portcall (PORTCALL_HOME).
2769
+ "worktrees": true gives each git worktree its own ports (declared + an offset), so they run at once.
2661
2770
  `;
2662
2771
  async function main(argv) {
2663
2772
  if (argv[0] === "__supervise") {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "portcall",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.2.0",
5
5
  "description": "Run your local dev servers in the background, on fixed ports, with logs your coding agents can read",
6
6
  "author": "Joris Gallot",
7
7
  "license": "MIT",
package/schema.json CHANGED
@@ -56,6 +56,10 @@
56
56
  "additionalProperties": false
57
57
  },
58
58
  "description": "The project's services, by name."
59
+ },
60
+ "worktrees": {
61
+ "description": "Let each git worktree of the project run its services at the same time, on ports offset from the declared ones (external services keep theirs). Commands must take their port from PORT and {ports.<service>}. Default: false, all checkouts share the declared ports.",
62
+ "type": "boolean"
59
63
  }
60
64
  },
61
65
  "required": [