procdeck 1.0.0 → 1.1.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/dist/cli.js CHANGED
@@ -45446,6 +45446,12 @@ var ProcSpecSchema = Schema_exports.Struct({
45446
45446
  examples: ["http://localhost:${port}"]
45447
45447
  })
45448
45448
  ),
45449
+ port: Schema_exports.optionalKey(
45450
+ Schema_exports.Number.annotate({
45451
+ description: "Pin this proc's assigned `${port}` to a fixed public number instead of a random free one \u2014 dependents outside the deck (dotenv files, mobile simulators, teammates' scripts) keep their hardcoded addresses, and an observed proc still binds a hidden internal port behind the HTTP observer. Requires `${port}` somewhere in the spec.",
45452
+ examples: [8787]
45453
+ }).pipe(Schema_exports.check(Schema_exports.isInt(), Schema_exports.isBetween({ minimum: 1, maximum: 65535 })))
45454
+ ),
45449
45455
  needs: Schema_exports.optionalKey(
45450
45456
  Schema_exports.Array(Schema_exports.String).annotate({
45451
45457
  description: "Ids of procs that must be ready before this one spawns. Declarative: readiness is auto-detected, no port numbers in the config.",
@@ -45553,6 +45559,25 @@ var validatePortTemplates = (procs) => {
45553
45559
  }
45554
45560
  return void 0;
45555
45561
  };
45562
+ var validatePinnedPorts = (config) => {
45563
+ const uiPort = config.port ?? DEFAULT_UI_PORT;
45564
+ const holders = /* @__PURE__ */ new Map();
45565
+ for (const proc of config.procs) {
45566
+ if (proc.port === void 0) continue;
45567
+ if (!usesOwnPort(proc)) {
45568
+ return `proc "${proc.id}" pins port ${proc.port} but does not use \${port} \u2014 the pin is the public side of an assigned port`;
45569
+ }
45570
+ if (proc.port === uiPort) {
45571
+ return `proc "${proc.id}" pins port ${proc.port}, which is the deck UI's own port`;
45572
+ }
45573
+ const holder = holders.get(proc.port);
45574
+ if (holder !== void 0) {
45575
+ return `procs "${holder}" and "${proc.id}" pin the same port ${proc.port}`;
45576
+ }
45577
+ holders.set(proc.port, proc.id);
45578
+ }
45579
+ return void 0;
45580
+ };
45556
45581
  var ProcdeckConfigSchema = Schema_exports.Struct({
45557
45582
  // JSON configs point at the published schema for editor completion; the key
45558
45583
  // is meaningless at runtime but must be accepted, because Struct is exact.
@@ -45586,7 +45611,7 @@ var ProcdeckConfigSchema = Schema_exports.Struct({
45586
45611
  }).pipe(
45587
45612
  Schema_exports.check(
45588
45613
  Schema_exports.makeFilter(
45589
- (config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs)
45614
+ (config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs) ?? validatePinnedPorts(config)
45590
45615
  )
45591
45616
  )
45592
45617
  ).annotate({
@@ -47320,12 +47345,18 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
47320
47345
  const live = yield* PubSub_exports.sliding({ capacity: LIVE_BUFFER });
47321
47346
  const wantsPort = loaded.config.procs.filter(usesOwnPort);
47322
47347
  const observedSpecs = wantsPort.filter((spec) => spec.observe !== false);
47348
+ const unpinned = wantsPort.filter((spec) => spec.port === void 0);
47323
47349
  const freePorts = yield* Effect_exports.promise(
47324
- () => findFreePorts(wantsPort.length + observedSpecs.length)
47350
+ () => findFreePorts(unpinned.length + observedSpecs.length)
47325
47351
  );
47326
- const assigned = new Map(wantsPort.map((spec, index2) => [spec.id, freePorts[index2]]));
47352
+ const assigned = new Map([
47353
+ ...wantsPort.flatMap(
47354
+ (spec) => spec.port === void 0 ? [] : [[spec.id, spec.port]]
47355
+ ),
47356
+ ...unpinned.map((spec, index2) => [spec.id, freePorts[index2]])
47357
+ ]);
47327
47358
  const internal = new Map(
47328
- observedSpecs.map((spec, index2) => [spec.id, freePorts[wantsPort.length + index2]])
47359
+ observedSpecs.map((spec, index2) => [spec.id, freePorts[unpinned.length + index2]])
47329
47360
  );
47330
47361
  const runtimes = /* @__PURE__ */ new Map();
47331
47362
  for (const spec of loaded.config.procs) {
@@ -47340,6 +47371,7 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
47340
47371
  ),
47341
47372
  assignedPort,
47342
47373
  internalPort,
47374
+ observerClose: void 0,
47343
47375
  status: { id: spec.id, state: "stopped" },
47344
47376
  proc: void 0,
47345
47377
  ready: Latch_exports.makeUnsafe(false),
@@ -47356,19 +47388,14 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
47356
47388
  rows: DEFAULT_ROWS
47357
47389
  });
47358
47390
  }
47359
- for (const [id2, internalPort] of internal) {
47360
- const runtime = runtimes.get(id2);
47361
- yield* Effect_exports.acquireRelease(
47362
- Effect_exports.promise(
47363
- () => startObserver({
47364
- publicPort: assigned.get(id2),
47365
- internalPort,
47366
- record: (capture) => runtime.http.record(capture)
47367
- })
47368
- ),
47369
- (close4) => Effect_exports.promise(close4)
47370
- );
47371
- }
47391
+ yield* Effect_exports.acquireRelease(
47392
+ Effect_exports.void,
47393
+ () => Effect_exports.promise(
47394
+ () => Promise.all(
47395
+ [...runtimes.values()].map((runtime) => runtime.observerClose?.() ?? Promise.resolve())
47396
+ ).then(() => void 0)
47397
+ )
47398
+ );
47372
47399
  const emit = (event) => {
47373
47400
  PubSub_exports.publishUnsafe(live, event);
47374
47401
  };
@@ -47431,6 +47458,28 @@ var make63 = Effect_exports.fn("procdeck.supervisor")(function* (loaded) {
47431
47458
  }
47432
47459
  }
47433
47460
  };
47461
+ const bindObserver = (runtime) => startObserver({
47462
+ publicPort: runtime.assignedPort,
47463
+ internalPort: runtime.internalPort,
47464
+ record: (capture) => runtime.http.record(capture)
47465
+ }).then(
47466
+ (close4) => {
47467
+ runtime.observerClose = close4;
47468
+ return true;
47469
+ },
47470
+ () => false
47471
+ );
47472
+ const parkOnTakenPort = (runtime) => {
47473
+ const hint = `free :${runtime.assignedPort} (is "${runtime.spec.id}" already running outside procdeck?), then Start`;
47474
+ say(
47475
+ runtime,
47476
+ `\r
47477
+ procdeck: pinned port :${runtime.assignedPort} is taken \u2014 cannot observe "${runtime.spec.id}"\r
47478
+ procdeck: hint: ${hint}\r
47479
+ `
47480
+ );
47481
+ setStatus2(runtime, { id: runtime.spec.id, state: "blocked", hint });
47482
+ };
47434
47483
  const startSync = (runtime) => {
47435
47484
  if (runtime.proc !== void 0) return;
47436
47485
  setStatus2(runtime, { id: runtime.spec.id, state: "starting" });
@@ -47506,6 +47555,13 @@ ${lines2.join("\r\n")}\r
47506
47555
  const start = Effect_exports.fn("procdeck.start")(function* (id2) {
47507
47556
  const runtime = require_(id2);
47508
47557
  if (runtime.proc !== void 0 || runtime.waiter !== void 0) return;
47558
+ if (runtime.internalPort !== void 0 && runtime.observerClose === void 0) {
47559
+ const bound = yield* Effect_exports.promise(() => bindObserver(runtime));
47560
+ if (!bound) {
47561
+ parkOnTakenPort(runtime);
47562
+ return;
47563
+ }
47564
+ }
47509
47565
  const needs = runtime.spec.needs ?? [];
47510
47566
  if (needs.length === 0 && runtime.spec.preflight === void 0) {
47511
47567
  return yield* Effect_exports.sync(() => startSync(runtime));
@@ -47581,7 +47637,9 @@ ${lines2.join("\r\n")}\r
47581
47637
  list: () => [...runtimes.values()].map((runtime) => ({
47582
47638
  id: runtime.spec.id,
47583
47639
  command: describeCommand(runtime.resolved),
47584
- ...runtime.resolved.url === void 0 ? {} : { url: runtime.resolved.url },
47640
+ // A pinned proc has a stable public address even with no `url` in
47641
+ // the config — derive the link instead of making the user repeat it.
47642
+ ...runtime.resolved.url !== void 0 ? { url: runtime.resolved.url } : runtime.spec.port === void 0 ? {} : { url: `http://localhost:${runtime.spec.port}` },
47585
47643
  ...runtime.assignedPort === void 0 ? {} : { assignedPort: runtime.assignedPort },
47586
47644
  proxyUrl: `http://${runtime.spec.id}.localhost:${uiPort}`,
47587
47645
  status: runtime.status
@@ -47636,7 +47694,14 @@ ${lines2.join("\r\n")}\r
47636
47694
  discard: true
47637
47695
  });
47638
47696
  for (const runtime of runtimes.values()) {
47639
- if (runtime.spec.autostart !== false) yield* start(runtime.spec.id);
47697
+ if (runtime.internalPort === void 0) continue;
47698
+ const bound = yield* Effect_exports.promise(() => bindObserver(runtime));
47699
+ if (!bound) parkOnTakenPort(runtime);
47700
+ }
47701
+ for (const runtime of runtimes.values()) {
47702
+ if (runtime.spec.autostart !== false && runtime.status.state !== "blocked") {
47703
+ yield* start(runtime.spec.id);
47704
+ }
47640
47705
  }
47641
47706
  return { supervisor, stopAll };
47642
47707
  });
package/dist/public.d.ts CHANGED
@@ -42,6 +42,13 @@ export type ProcSpec = {
42
42
  readonly autostart?: boolean;
43
43
  /** URL this process serves, shown in the UI as a link. */
44
44
  readonly url?: string;
45
+ /**
46
+ * Pin this proc's assigned `${port}` to a fixed public number instead of a
47
+ * random free one — dependents outside the deck keep their hardcoded
48
+ * addresses, and an observed proc still binds a hidden internal port.
49
+ * Requires `${port}` somewhere in the spec.
50
+ */
51
+ readonly port?: number;
45
52
  /** Ids of procs that must be ready before this one spawns. */
46
53
  readonly needs?: ReadonlyArray<string>;
47
54
  /** What "ready" means for dependents. Defaults to "listening". */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "procdeck",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Interactive dev-process multiplexer with a web UI: terminal panes in a browser tab, declarative dependencies, assigned ports and a *.localhost reverse proxy.",
5
5
  "keywords": [
6
6
  "cli",
package/readme.md CHANGED
@@ -52,7 +52,9 @@ pnpm add -D procdeck # npm i -D procdeck · bun add -d procdeck
52
52
  - **Ports that never collide.** Write `${port}` in a command, env value or url
53
53
  and procdeck assigns a free port before spawning. `${port:api}` is another
54
54
  proc's port, so dependents get wired — `API_URL=http://localhost:${port:api}`
55
- — with no hardcoded numbers anywhere.
55
+ — with no hardcoded numbers anywhere. And when the world already knows the
56
+ address, `port: 8787` pins the public side to that exact number — dotenv
57
+ files and simulators keep working, the observer stays in front.
56
58
  - **A memorable address for each service.** `api.localhost:4820`,
57
59
  `web.localhost:4820` — whatever port the process actually got. Browsers
58
60
  resolve `*.localhost` themselves, so there is zero system setup, and
package/schema.json CHANGED
@@ -118,6 +118,19 @@
118
118
  "http://localhost:${port}"
119
119
  ]
120
120
  },
121
+ "port": {
122
+ "description": "Pin this proc's assigned `${port}` to a fixed public number instead of a random free one — dependents outside the deck (dotenv files, mobile simulators, teammates' scripts) keep their hardcoded addresses, and an observed proc still binds a hidden internal port behind the HTTP observer. Requires `${port}` somewhere in the spec.",
123
+ "examples": [
124
+ 8787
125
+ ],
126
+ "type": "integer",
127
+ "allOf": [
128
+ {
129
+ "minimum": 1,
130
+ "maximum": 65535
131
+ }
132
+ ]
133
+ },
121
134
  "needs": {
122
135
  "type": "array",
123
136
  "items": {