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 +18 -2
- package/dist/portcall.mjs +150 -41
- package/package.json +1 -1
- package/schema.json +4 -0
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.
|
|
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
|
-
|
|
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:
|
|
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 ?? {},
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
2295
|
+
const log = logPath(project.instance, service.name);
|
|
2191
2296
|
const spec = {
|
|
2192
|
-
project: project.
|
|
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.
|
|
2219
|
-
project: project.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
2489
|
-
seen.add(project.
|
|
2490
|
-
for (const st of await projectStatus(project, state)) {
|
|
2491
|
-
const
|
|
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
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": [
|