procdeck 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.
@@ -0,0 +1,266 @@
1
+ // src/config.ts
2
+ import { existsSync } from "fs";
3
+ import { readFile } from "fs/promises";
4
+ import { pathToFileURL } from "url";
5
+ import * as path from "path";
6
+ import { Effect, Schema } from "effect";
7
+ var isRegex = Schema.makeFilter((pattern) => {
8
+ try {
9
+ new RegExp(pattern);
10
+ return void 0;
11
+ } catch {
12
+ return "expected a valid regular expression";
13
+ }
14
+ });
15
+ var regexSource = (annotations) => Schema.String.annotate(annotations).pipe(Schema.check(isRegex));
16
+ var PreflightSchema = Schema.Struct({
17
+ shell: Schema.String.annotate({
18
+ description: "Shell command; exit 0 lets the proc spawn.",
19
+ examples: ["wrangler whoami"]
20
+ }).pipe(Schema.check(Schema.isMinLength(1))),
21
+ expect: Schema.optionalKey(
22
+ regexSource({
23
+ description: 'Regex the check\'s output must also match. For checks that exit 0 either way \u2014 `wrangler whoami` reports "not authenticated" with exit 0 \u2014 so the config needs no `| grep` pipelines.',
24
+ examples: ["You are logged in"]
25
+ })
26
+ ),
27
+ hint: Schema.optionalKey(
28
+ Schema.String.annotate({
29
+ description: "What the human should do when the check fails; shown on the blocked pane.",
30
+ examples: ["run `wrangler login`, then Start"]
31
+ })
32
+ )
33
+ }).annotate({
34
+ description: "A gate that must pass before the process spawns \u2014 e.g. `wrangler whoami` before a proc whose tree needs a Cloudflare token. Keeps interactive auth flows out of supervised panes, where a restart would kill their callback servers mid-handshake."
35
+ });
36
+ var AlertSchema = Schema.Struct({
37
+ pattern: regexSource({
38
+ description: "Regex matched against a rolling tail of the pane's output.",
39
+ examples: ["ERROR", "not authenticated"]
40
+ }),
41
+ label: Schema.String.annotate({
42
+ description: "Badge text shown in the UI when the pattern matches.",
43
+ examples: ["needs login"]
44
+ }).pipe(Schema.check(Schema.isMinLength(1)))
45
+ }).annotate({
46
+ description: "A regex watched in the pane output; a match raises a badge in the UI."
47
+ });
48
+ var ProcSpecSchema = Schema.Struct({
49
+ id: Schema.String.annotate({
50
+ description: "Stable identifier: the pane label, and the proxy subdomain (`api` \u2192 http://api.localhost:4820).",
51
+ examples: ["api", "web"]
52
+ }).pipe(Schema.check(Schema.isMinLength(1))),
53
+ shell: Schema.optionalKey(
54
+ Schema.String.annotate({
55
+ description: "Command line handed to `$SHELL -c` \u2014 pipes, `&&` and globs work. Exactly one of `shell` or `cmd` must be set.",
56
+ examples: ["pnpm --filter api dev"]
57
+ })
58
+ ),
59
+ cmd: Schema.optionalKey(
60
+ Schema.Array(Schema.String).annotate({
61
+ description: "argv, executed directly without a shell. Exactly one of `shell` or `cmd` must be set.",
62
+ examples: [["node", "server.js"]]
63
+ }).pipe(Schema.check(Schema.isMinLength(1)))
64
+ ),
65
+ cwd: Schema.optionalKey(
66
+ Schema.String.annotate({
67
+ description: "Working directory; defaults to the config file's directory.",
68
+ examples: ["packages/api"]
69
+ })
70
+ ),
71
+ env: Schema.optionalKey(
72
+ Schema.Record(Schema.String, Schema.String).annotate({
73
+ description: "Extra environment on top of the inherited one. Values may use `${port}` / `${port:other}` templates.",
74
+ examples: [{ PORT: "${port}", API_URL: "http://localhost:${port:api}" }]
75
+ })
76
+ ),
77
+ autostart: Schema.optionalKey(
78
+ Schema.Boolean.annotate({
79
+ description: "Spawn on startup. Defaults to true; false leaves the pane idle until Start."
80
+ })
81
+ ),
82
+ url: Schema.optionalKey(
83
+ Schema.String.annotate({
84
+ description: "URL this process serves, shown in the UI as a link. Also pins readiness to that URL's port.",
85
+ examples: ["http://localhost:${port}"]
86
+ })
87
+ ),
88
+ needs: Schema.optionalKey(
89
+ Schema.Array(Schema.String).annotate({
90
+ description: "Ids of procs that must be ready before this one spawns. Declarative: readiness is auto-detected, no port numbers in the config.",
91
+ examples: [["api"]]
92
+ })
93
+ ),
94
+ readyWhen: Schema.optionalKey(
95
+ Schema.Literals(["listening", "started"]).annotate({
96
+ description: 'What "ready" means for dependents: "listening" (default) \u2014 the process tree opened a TCP port; "started" \u2014 it merely spawned, for procs that never listen.'
97
+ })
98
+ ),
99
+ preflight: Schema.optionalKey(PreflightSchema),
100
+ alerts: Schema.optionalKey(
101
+ Schema.Array(AlertSchema).annotate({
102
+ description: "Output patterns that raise a badge when they appear in the pane."
103
+ })
104
+ )
105
+ }).pipe(
106
+ Schema.check(
107
+ Schema.makeFilter(
108
+ (spec) => spec.shell === void 0 === (spec.cmd === void 0) ? "expected exactly one of `shell` or `cmd`" : void 0
109
+ )
110
+ )
111
+ ).annotate({
112
+ description: "A single supervised process. Exactly one of `shell` / `cmd` is used: `shell` goes through `sh -c` (pipes, `&&`, globs work, at the cost of one extra process in the tree), `cmd` is an argv array executed directly."
113
+ });
114
+ var DEFAULT_UI_PORT = 4820;
115
+ var PORT_TEMPLATE = /\$\{port(?::([^}]+))?\}/g;
116
+ var templatedStrings = (spec) => [spec.shell, ...spec.cmd ?? [], ...Object.values(spec.env ?? {}), spec.url].filter(
117
+ (text) => text !== void 0
118
+ );
119
+ var usesOwnPort = (spec) => templatedStrings(spec).some(
120
+ (text) => [...text.matchAll(PORT_TEMPLATE)].some((match) => match[1] === void 0)
121
+ );
122
+ var portRefs = (spec) => templatedStrings(spec).flatMap(
123
+ (text) => [...text.matchAll(PORT_TEMPLATE)].flatMap((match) => match[1] === void 0 ? [] : [match[1]])
124
+ );
125
+ var resolveSpec = (spec, assigned) => {
126
+ const sub = (text) => text.replace(PORT_TEMPLATE, (whole, ref) => {
127
+ const port = assigned.get(ref ?? spec.id);
128
+ return port === void 0 ? whole : String(port);
129
+ });
130
+ return {
131
+ ...spec,
132
+ ...spec.shell === void 0 ? {} : { shell: sub(spec.shell) },
133
+ ...spec.cmd === void 0 ? {} : { cmd: spec.cmd.map(sub) },
134
+ ...spec.env === void 0 ? {} : { env: Object.fromEntries(Object.entries(spec.env).map(([key, value]) => [key, sub(value)])) },
135
+ ...spec.url === void 0 ? {} : { url: sub(spec.url) }
136
+ };
137
+ };
138
+ var validateGraph = (procs) => {
139
+ const ids = new Set(procs.map((proc) => proc.id));
140
+ if (ids.size !== procs.length) return "duplicate proc ids";
141
+ for (const proc of procs) {
142
+ for (const need of proc.needs ?? []) {
143
+ if (!ids.has(need)) return `proc "${proc.id}" needs unknown proc "${need}"`;
144
+ }
145
+ }
146
+ const needsOf = new Map(procs.map((proc) => [proc.id, proc.needs ?? []]));
147
+ const done = /* @__PURE__ */ new Set();
148
+ const onPath = /* @__PURE__ */ new Set();
149
+ const visit = (id) => {
150
+ if (done.has(id)) return void 0;
151
+ if (onPath.has(id)) return `dependency cycle through "${id}"`;
152
+ onPath.add(id);
153
+ for (const need of needsOf.get(id) ?? []) {
154
+ const cycle = visit(need);
155
+ if (cycle !== void 0) return cycle;
156
+ }
157
+ onPath.delete(id);
158
+ done.add(id);
159
+ return void 0;
160
+ };
161
+ for (const proc of procs) {
162
+ const cycle = visit(proc.id);
163
+ if (cycle !== void 0) return cycle;
164
+ }
165
+ return void 0;
166
+ };
167
+ var validatePortTemplates = (procs) => {
168
+ const byId = new Map(procs.map((proc) => [proc.id, proc]));
169
+ for (const proc of procs) {
170
+ for (const ref of portRefs(proc)) {
171
+ const target = byId.get(ref);
172
+ if (target === void 0) {
173
+ return `proc "${proc.id}" references \${port:${ref}} but there is no proc "${ref}"`;
174
+ }
175
+ if (!usesOwnPort(target)) {
176
+ return `proc "${proc.id}" references \${port:${ref}} but "${ref}" does not use \${port} itself`;
177
+ }
178
+ }
179
+ }
180
+ return void 0;
181
+ };
182
+ var ProcdeckConfigSchema = Schema.Struct({
183
+ // JSON configs point at the published schema for editor completion; the key
184
+ // is meaningless at runtime but must be accepted, because Struct is exact.
185
+ $schema: Schema.optionalKey(
186
+ Schema.String.annotate({
187
+ description: "URL of the procdeck JSON schema, for editor completion and validation.",
188
+ examples: ["https://unpkg.com/procdeck/schema.json"]
189
+ })
190
+ ),
191
+ name: Schema.optionalKey(
192
+ Schema.String.annotate({
193
+ description: "Deck name \u2014 the tab title and the name of the installed web app. Defaults to the config directory's basename.",
194
+ examples: ["my-app"]
195
+ })
196
+ ),
197
+ port: Schema.optionalKey(
198
+ Schema.Number.annotate({
199
+ description: "Port for the web UI and the `*.localhost` proxy. Defaults to 4820.",
200
+ examples: [4820]
201
+ }).pipe(Schema.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 65535 })))
202
+ ),
203
+ procs: Schema.Array(ProcSpecSchema).annotate({
204
+ description: "The processes this deck supervises."
205
+ })
206
+ }).pipe(
207
+ Schema.check(
208
+ Schema.makeFilter(
209
+ (config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs)
210
+ )
211
+ )
212
+ ).annotate({
213
+ description: "A procdeck deck: the web UI's settings and the processes it supervises."
214
+ });
215
+ var defineConfig = (config) => config;
216
+ var ConfigError = class extends Schema.TaggedError()("ConfigError", {
217
+ file: Schema.String,
218
+ message: Schema.String
219
+ }) {
220
+ };
221
+ var CONFIG_FILENAMES = [
222
+ "procdeck.config.json",
223
+ "procdeck.config.ts",
224
+ "procdeck.config.js",
225
+ "procdeck.config.mjs"
226
+ ];
227
+ var discoverConfig = (dir) => CONFIG_FILENAMES.map((name) => path.join(dir, name)).find((candidate) => existsSync(candidate));
228
+ var readSource = async (absolute) => {
229
+ if (path.extname(absolute) === ".json") {
230
+ return JSON.parse(await readFile(absolute, "utf8"));
231
+ }
232
+ const module = await import(pathToFileURL(absolute).href);
233
+ return module.default;
234
+ };
235
+ var loadConfig = Effect.fn("procdeck.loadConfig")(function* (file) {
236
+ const absolute = path.resolve(file);
237
+ if (!existsSync(absolute)) {
238
+ return yield* new ConfigError({ file: absolute, message: "config file not found" });
239
+ }
240
+ const source = yield* Effect.tryPromise({
241
+ try: () => readSource(absolute),
242
+ catch: (cause) => new ConfigError({ file: absolute, message: String(cause) })
243
+ });
244
+ const config = yield* Effect.try({
245
+ try: () => Schema.decodeUnknownSync(ProcdeckConfigSchema)(source),
246
+ catch: (cause) => new ConfigError({ file: absolute, message: String(cause) })
247
+ });
248
+ const root = path.dirname(absolute);
249
+ const loaded = { config, root, name: config.name ?? path.basename(root) };
250
+ return loaded;
251
+ });
252
+
253
+ export {
254
+ PreflightSchema,
255
+ AlertSchema,
256
+ ProcSpecSchema,
257
+ DEFAULT_UI_PORT,
258
+ usesOwnPort,
259
+ resolveSpec,
260
+ ProcdeckConfigSchema,
261
+ defineConfig,
262
+ ConfigError,
263
+ CONFIG_FILENAMES,
264
+ discoverConfig,
265
+ loadConfig
266
+ };
package/dist/cli.js CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ CONFIG_FILENAMES,
3
4
  DEFAULT_UI_PORT,
5
+ discoverConfig,
4
6
  loadConfig,
5
7
  resolveSpec,
6
8
  usesOwnPort
7
- } from "./chunk-SBVZ6G4G.js";
9
+ } from "./chunk-LHJTDX2F.js";
8
10
 
9
11
  // src/cli.ts
10
12
  import { Effect as Effect3, Fiber as Fiber2 } from "effect";
@@ -657,7 +659,11 @@ var serve = (supervisor, port, deck) => Effect2.acquireRelease(
657
659
  ).pipe(Effect2.as(port));
658
660
 
659
661
  // src/cli.ts
660
- var configPath = process.argv[2] ?? "procdeck.config.ts";
662
+ var configPath = process.argv[2] ?? discoverConfig(process.cwd());
663
+ if (configPath === void 0) {
664
+ console.error(`procdeck: no config file here \u2014 expected one of ${CONFIG_FILENAMES.join(", ")}`);
665
+ process.exit(1);
666
+ }
661
667
  var main = Effect3.gen(function* () {
662
668
  const loaded = yield* loadConfig(configPath);
663
669
  const supervisor = yield* makeSupervisor(loaded);
package/dist/config.d.ts CHANGED
@@ -1,67 +1,32 @@
1
1
  import { Effect, Schema } from "effect";
2
2
  export declare const PreflightSchema: Schema.Struct<{
3
- /** Shell command; exit 0 lets the proc spawn. */
4
3
  readonly shell: Schema.String;
5
- /**
6
- * Regex that must also match the check's output. For checks that exit 0
7
- * either way — `wrangler whoami` reports "not authenticated" with exit 0 —
8
- * so the config doesn't need `| grep` pipelines.
9
- */
10
4
  readonly expect: Schema.optionalKey<Schema.String>;
11
- /** What the human should do when the check fails. */
12
5
  readonly hint: Schema.optionalKey<Schema.String>;
13
6
  }>;
14
7
  export type Preflight = typeof PreflightSchema.Type;
15
- /** A regex watched in the pane output; a match raises a badge in the UI. */
16
8
  export declare const AlertSchema: Schema.Struct<{
17
9
  readonly pattern: Schema.String;
18
- /** Badge text. */
19
10
  readonly label: Schema.String;
20
11
  }>;
21
12
  export type Alert = typeof AlertSchema.Type;
22
13
  export declare const ProcSpecSchema: Schema.Struct<{
23
- /** Stable identifier, also the tab label. */
24
14
  readonly id: Schema.String;
25
- /** Command line handed to `$SHELL -c`. */
26
15
  readonly shell: Schema.optionalKey<Schema.String>;
27
- /** argv, executed without a shell. */
28
16
  readonly cmd: Schema.optionalKey<Schema.$Array<Schema.String>>;
29
- /** Working directory; defaults to the config file's directory. */
30
17
  readonly cwd: Schema.optionalKey<Schema.String>;
31
- /** Extra environment on top of the inherited one. */
32
18
  readonly env: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
33
- /** Spawn on startup. Defaults to true. */
34
19
  readonly autostart: Schema.optionalKey<Schema.Boolean>;
35
- /** URL this process serves, shown in the UI as a link. */
36
20
  readonly url: Schema.optionalKey<Schema.String>;
37
- /**
38
- * Names of procs that must be ready before this one spawns. Declarative:
39
- * readiness is auto-detected, no port numbers in the config.
40
- */
41
21
  readonly needs: Schema.optionalKey<Schema.$Array<Schema.String>>;
42
- /**
43
- * What "ready" means for dependents: "listening" (default) — the process
44
- * tree opened a TCP port; "started" — it merely spawned (for procs that
45
- * never listen).
46
- */
47
22
  readonly readyWhen: Schema.optionalKey<Schema.Literals<readonly ["listening", "started"]>>;
48
- /** Check that must pass before this proc spawns; failure shows as "blocked". */
49
23
  readonly preflight: Schema.optionalKey<Schema.Struct<{
50
- /** Shell command; exit 0 lets the proc spawn. */
51
24
  readonly shell: Schema.String;
52
- /**
53
- * Regex that must also match the check's output. For checks that exit 0
54
- * either way — `wrangler whoami` reports "not authenticated" with exit 0 —
55
- * so the config doesn't need `| grep` pipelines.
56
- */
57
25
  readonly expect: Schema.optionalKey<Schema.String>;
58
- /** What the human should do when the check fails. */
59
26
  readonly hint: Schema.optionalKey<Schema.String>;
60
27
  }>>;
61
- /** Output patterns that raise a badge when they appear in the pane. */
62
28
  readonly alerts: Schema.optionalKey<Schema.$Array<Schema.Struct<{
63
29
  readonly pattern: Schema.String;
64
- /** Badge text. */
65
30
  readonly label: Schema.String;
66
31
  }>>>;
67
32
  }>;
@@ -73,56 +38,26 @@ export declare const usesOwnPort: (spec: ProcSpec) => boolean;
73
38
  /** Substitute `${port}` / `${port:id}` with the ports assigned at startup. */
74
39
  export declare const resolveSpec: (spec: ProcSpec, assigned: Map<string, number>) => ProcSpec;
75
40
  export declare const ProcdeckConfigSchema: Schema.Struct<{
76
- /**
77
- * Deck name — the tab title and the name of the installed web app.
78
- * Defaults to the config directory's basename.
79
- */
41
+ readonly $schema: Schema.optionalKey<Schema.String>;
80
42
  readonly name: Schema.optionalKey<Schema.String>;
81
- /** Port for the web UI. Defaults to 4820. */
82
43
  readonly port: Schema.optionalKey<Schema.Number>;
83
44
  readonly procs: Schema.$Array<Schema.Struct<{
84
- /** Stable identifier, also the tab label. */
85
45
  readonly id: Schema.String;
86
- /** Command line handed to `$SHELL -c`. */
87
46
  readonly shell: Schema.optionalKey<Schema.String>;
88
- /** argv, executed without a shell. */
89
47
  readonly cmd: Schema.optionalKey<Schema.$Array<Schema.String>>;
90
- /** Working directory; defaults to the config file's directory. */
91
48
  readonly cwd: Schema.optionalKey<Schema.String>;
92
- /** Extra environment on top of the inherited one. */
93
49
  readonly env: Schema.optionalKey<Schema.$Record<Schema.String, Schema.String>>;
94
- /** Spawn on startup. Defaults to true. */
95
50
  readonly autostart: Schema.optionalKey<Schema.Boolean>;
96
- /** URL this process serves, shown in the UI as a link. */
97
51
  readonly url: Schema.optionalKey<Schema.String>;
98
- /**
99
- * Names of procs that must be ready before this one spawns. Declarative:
100
- * readiness is auto-detected, no port numbers in the config.
101
- */
102
52
  readonly needs: Schema.optionalKey<Schema.$Array<Schema.String>>;
103
- /**
104
- * What "ready" means for dependents: "listening" (default) — the process
105
- * tree opened a TCP port; "started" — it merely spawned (for procs that
106
- * never listen).
107
- */
108
53
  readonly readyWhen: Schema.optionalKey<Schema.Literals<readonly ["listening", "started"]>>;
109
- /** Check that must pass before this proc spawns; failure shows as "blocked". */
110
54
  readonly preflight: Schema.optionalKey<Schema.Struct<{
111
- /** Shell command; exit 0 lets the proc spawn. */
112
55
  readonly shell: Schema.String;
113
- /**
114
- * Regex that must also match the check's output. For checks that exit 0
115
- * either way — `wrangler whoami` reports "not authenticated" with exit 0 —
116
- * so the config doesn't need `| grep` pipelines.
117
- */
118
56
  readonly expect: Schema.optionalKey<Schema.String>;
119
- /** What the human should do when the check fails. */
120
57
  readonly hint: Schema.optionalKey<Schema.String>;
121
58
  }>>;
122
- /** Output patterns that raise a badge when they appear in the pane. */
123
59
  readonly alerts: Schema.optionalKey<Schema.$Array<Schema.Struct<{
124
60
  readonly pattern: Schema.String;
125
- /** Badge text. */
126
61
  readonly label: Schema.String;
127
62
  }>>>;
128
63
  }>>;
@@ -143,5 +78,13 @@ declare const ConfigError_base: Schema.Class<ConfigError, Schema.TaggedStruct<"C
143
78
  }>, import("effect/Cause").YieldableError>;
144
79
  export declare class ConfigError extends ConfigError_base {
145
80
  }
81
+ /**
82
+ * Config file names the CLI looks for when started without an argument, in
83
+ * order. JSON comes first because it needs nothing from the toolchain: no
84
+ * TypeScript, and no procdeck in the project's dependencies.
85
+ */
86
+ export declare const CONFIG_FILENAMES: readonly ["procdeck.config.json", "procdeck.config.ts", "procdeck.config.js", "procdeck.config.mjs"];
87
+ /** The first config file present in `dir`, if any. */
88
+ export declare const discoverConfig: (dir: string) => string | undefined;
146
89
  export declare const loadConfig: (file: string) => Effect.Effect<LoadedConfig, ConfigError, never>;
147
90
  export {};
package/dist/config.js CHANGED
@@ -1,23 +1,27 @@
1
1
  import {
2
2
  AlertSchema,
3
+ CONFIG_FILENAMES,
3
4
  ConfigError,
4
5
  DEFAULT_UI_PORT,
5
6
  PreflightSchema,
6
7
  ProcSpecSchema,
7
8
  ProcdeckConfigSchema,
8
9
  defineConfig,
10
+ discoverConfig,
9
11
  loadConfig,
10
12
  resolveSpec,
11
13
  usesOwnPort
12
- } from "./chunk-SBVZ6G4G.js";
14
+ } from "./chunk-LHJTDX2F.js";
13
15
  export {
14
16
  AlertSchema,
17
+ CONFIG_FILENAMES,
15
18
  ConfigError,
16
19
  DEFAULT_UI_PORT,
17
20
  PreflightSchema,
18
21
  ProcSpecSchema,
19
22
  ProcdeckConfigSchema,
20
23
  defineConfig,
24
+ discoverConfig,
21
25
  loadConfig,
22
26
  resolveSpec,
23
27
  usesOwnPort
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "procdeck",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -26,10 +26,11 @@
26
26
  },
27
27
  "type": "module",
28
28
  "engines": {
29
- "node": ">=22.18"
29
+ "node": ">=22"
30
30
  },
31
31
  "files": [
32
32
  "dist",
33
+ "schema.json",
33
34
  "ui/dist"
34
35
  ],
35
36
  "bin": {
@@ -62,8 +63,9 @@
62
63
  },
63
64
  "scripts": {
64
65
  "ui:dev": "vite ui",
65
- "build": "pnpm run build:meta && pnpm run build:ui && pnpm run build:js && pnpm run build:types",
66
+ "build": "pnpm run build:meta && pnpm run build:schema && pnpm run build:ui && pnpm run build:js && pnpm run build:types",
66
67
  "build:meta": "cp ../../readme.md ../../license .",
68
+ "build:schema": "node scripts/gen-schema.ts",
67
69
  "build:ui": "vite build ui",
68
70
  "build:js": "tsup",
69
71
  "build:types": "tsc -p tsconfig.build.json",
package/readme.md CHANGED
@@ -56,22 +56,22 @@ which port is which**.
56
56
 
57
57
  ## Install
58
58
 
59
+ Nothing to install, if you don't want to — write a `procdeck.config.json` (see
60
+ [Config](#config)) and run:
61
+
59
62
  ```sh
60
- pnpm add -D procdeck # npm i -D procdeck · bun add -d procdeck
63
+ npx procdeck # picks up ./procdeck.config.json
64
+ npx procdeck decks/backend.json # or point it at any config file
61
65
  ```
62
66
 
63
- Requires **Node ≥ 22.18**: `procdeck.config.ts` is a TypeScript file, loaded through
64
- Node's built-in type stripping.
67
+ Open <http://localhost:4820> for the panes. Requires **Node ≥ 22**.
65
68
 
66
- Write a `procdeck.config.ts` next to your `package.json` (see [Config](#config)), then:
69
+ Or add it to the project, which is what you want if you keep the config in TypeScript:
67
70
 
68
71
  ```sh
69
- npx procdeck # defaults to ./procdeck.config.ts
70
- npx procdeck decks/backend.ts # or point it at any config file
72
+ pnpm add -D procdeck # npm i -D procdeck · bun add -d procdeck
71
73
  ```
72
74
 
73
- Open <http://localhost:4820> for the panes.
74
-
75
75
  ## Try the example
76
76
 
77
77
  In a clone of this repo:
@@ -96,7 +96,31 @@ ports) entirely through `${port}` templates. See [`example/`](example/).
96
96
 
97
97
  ## Config
98
98
 
99
- `procdeck.config.ts` — a TypeScript file, validated by an Effect schema:
99
+ Two formats, one schema. **JSON** needs nothing from your toolchain — no TypeScript, and
100
+ procdeck itself doesn't have to be a dependency. Point `$schema` at the published schema
101
+ and the editor completes and validates every field:
102
+
103
+ ```jsonc
104
+ {
105
+ "$schema": "https://unpkg.com/procdeck/schema.json",
106
+ "port": 4820,
107
+ "procs": [
108
+ { "id": "api", "shell": "pnpm --filter api dev", "env": { "PORT": "${port}" } },
109
+ {
110
+ "id": "web",
111
+ "shell": "pnpm --filter web dev",
112
+ "env": { "PORT": "${port}", "API_URL": "http://localhost:${port:api}" },
113
+ "needs": ["api"]
114
+ }
115
+ ]
116
+ }
117
+ ```
118
+
119
+ (Installed locally, `"./node_modules/procdeck/schema.json"` works too and needs no
120
+ network.)
121
+
122
+ **TypeScript** buys comments and computed configs, at the price of Node ≥ 22.18 (native
123
+ type stripping) and procdeck in your dependencies:
100
124
 
101
125
  ```ts
102
126
  import { defineConfig } from "procdeck"
@@ -131,7 +155,9 @@ export default defineConfig({
131
155
  ```
132
156
 
133
157
  Full field reference: [`packages/procdeck/src/config.ts`](packages/procdeck/src/config.ts)
134
- (the schema is the documentation).
158
+ (the schema is the documentation — `schema.json` is generated from it at build time, so
159
+ the two formats validate identically). Config files are looked up in this order when no
160
+ path is given: `procdeck.config.json`, `.ts`, `.js`, `.mjs`.
135
161
 
136
162
  Tips:
137
163
 
package/schema.json ADDED
@@ -0,0 +1,212 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://unpkg.com/procdeck/schema.json",
4
+ "title": "procdeck config",
5
+ "type": "object",
6
+ "properties": {
7
+ "$schema": {
8
+ "type": "string",
9
+ "description": "URL of the procdeck JSON schema, for editor completion and validation.",
10
+ "examples": [
11
+ "https://unpkg.com/procdeck/schema.json"
12
+ ]
13
+ },
14
+ "name": {
15
+ "type": "string",
16
+ "description": "Deck name — the tab title and the name of the installed web app. Defaults to the config directory's basename.",
17
+ "examples": [
18
+ "my-app"
19
+ ]
20
+ },
21
+ "port": {
22
+ "description": "Port for the web UI and the `*.localhost` proxy. Defaults to 4820.",
23
+ "examples": [
24
+ 4820
25
+ ],
26
+ "type": "integer",
27
+ "allOf": [
28
+ {
29
+ "minimum": 1,
30
+ "maximum": 65535
31
+ }
32
+ ]
33
+ },
34
+ "procs": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "properties": {
39
+ "id": {
40
+ "type": "string",
41
+ "description": "Stable identifier: the pane label, and the proxy subdomain (`api` → http://api.localhost:4820).",
42
+ "examples": [
43
+ "api",
44
+ "web"
45
+ ],
46
+ "allOf": [
47
+ {
48
+ "minLength": 1
49
+ }
50
+ ]
51
+ },
52
+ "shell": {
53
+ "type": "string",
54
+ "description": "Command line handed to `$SHELL -c` — pipes, `&&` and globs work. Exactly one of `shell` or `cmd` must be set.",
55
+ "examples": [
56
+ "pnpm --filter api dev"
57
+ ]
58
+ },
59
+ "cmd": {
60
+ "type": "array",
61
+ "items": {
62
+ "type": "string"
63
+ },
64
+ "description": "argv, executed directly without a shell. Exactly one of `shell` or `cmd` must be set.",
65
+ "examples": [
66
+ [
67
+ "node",
68
+ "server.js"
69
+ ]
70
+ ],
71
+ "allOf": [
72
+ {
73
+ "minItems": 1
74
+ }
75
+ ]
76
+ },
77
+ "cwd": {
78
+ "type": "string",
79
+ "description": "Working directory; defaults to the config file's directory.",
80
+ "examples": [
81
+ "packages/api"
82
+ ]
83
+ },
84
+ "env": {
85
+ "type": "object",
86
+ "additionalProperties": {
87
+ "type": "string"
88
+ },
89
+ "description": "Extra environment on top of the inherited one. Values may use `${port}` / `${port:other}` templates.",
90
+ "examples": [
91
+ {
92
+ "PORT": "${port}",
93
+ "API_URL": "http://localhost:${port:api}"
94
+ }
95
+ ]
96
+ },
97
+ "autostart": {
98
+ "type": "boolean",
99
+ "description": "Spawn on startup. Defaults to true; false leaves the pane idle until Start."
100
+ },
101
+ "url": {
102
+ "type": "string",
103
+ "description": "URL this process serves, shown in the UI as a link. Also pins readiness to that URL's port.",
104
+ "examples": [
105
+ "http://localhost:${port}"
106
+ ]
107
+ },
108
+ "needs": {
109
+ "type": "array",
110
+ "items": {
111
+ "type": "string"
112
+ },
113
+ "description": "Ids of procs that must be ready before this one spawns. Declarative: readiness is auto-detected, no port numbers in the config.",
114
+ "examples": [
115
+ [
116
+ "api"
117
+ ]
118
+ ]
119
+ },
120
+ "readyWhen": {
121
+ "type": "string",
122
+ "enum": [
123
+ "listening",
124
+ "started"
125
+ ],
126
+ "description": "What \"ready\" means for dependents: \"listening\" (default) — the process tree opened a TCP port; \"started\" — it merely spawned, for procs that never listen."
127
+ },
128
+ "preflight": {
129
+ "type": "object",
130
+ "properties": {
131
+ "shell": {
132
+ "type": "string",
133
+ "description": "Shell command; exit 0 lets the proc spawn.",
134
+ "examples": [
135
+ "wrangler whoami"
136
+ ],
137
+ "allOf": [
138
+ {
139
+ "minLength": 1
140
+ }
141
+ ]
142
+ },
143
+ "expect": {
144
+ "type": "string",
145
+ "description": "Regex the check's output must also match. For checks that exit 0 either way — `wrangler whoami` reports \"not authenticated\" with exit 0 — so the config needs no `| grep` pipelines.",
146
+ "examples": [
147
+ "You are logged in"
148
+ ]
149
+ },
150
+ "hint": {
151
+ "type": "string",
152
+ "description": "What the human should do when the check fails; shown on the blocked pane.",
153
+ "examples": [
154
+ "run `wrangler login`, then Start"
155
+ ]
156
+ }
157
+ },
158
+ "required": [
159
+ "shell"
160
+ ],
161
+ "additionalProperties": false,
162
+ "description": "A gate that must pass before the process spawns — e.g. `wrangler whoami` before a proc whose tree needs a Cloudflare token. Keeps interactive auth flows out of supervised panes, where a restart would kill their callback servers mid-handshake."
163
+ },
164
+ "alerts": {
165
+ "type": "array",
166
+ "items": {
167
+ "type": "object",
168
+ "properties": {
169
+ "pattern": {
170
+ "type": "string",
171
+ "description": "Regex matched against a rolling tail of the pane's output.",
172
+ "examples": [
173
+ "ERROR",
174
+ "not authenticated"
175
+ ]
176
+ },
177
+ "label": {
178
+ "type": "string",
179
+ "description": "Badge text shown in the UI when the pattern matches.",
180
+ "examples": [
181
+ "needs login"
182
+ ],
183
+ "allOf": [
184
+ {
185
+ "minLength": 1
186
+ }
187
+ ]
188
+ }
189
+ },
190
+ "required": [
191
+ "pattern",
192
+ "label"
193
+ ],
194
+ "additionalProperties": false,
195
+ "description": "A regex watched in the pane output; a match raises a badge in the UI."
196
+ },
197
+ "description": "Output patterns that raise a badge when they appear in the pane."
198
+ }
199
+ },
200
+ "required": [
201
+ "id"
202
+ ],
203
+ "additionalProperties": false
204
+ },
205
+ "description": "The processes this deck supervises."
206
+ }
207
+ },
208
+ "required": [
209
+ "procs"
210
+ ],
211
+ "additionalProperties": false
212
+ }
@@ -1,187 +0,0 @@
1
- // src/config.ts
2
- import { pathToFileURL } from "url";
3
- import * as path from "path";
4
- import { Effect, Schema } from "effect";
5
- var regexSource = Schema.String.pipe(
6
- Schema.check(
7
- Schema.makeFilter((pattern) => {
8
- try {
9
- new RegExp(pattern);
10
- return void 0;
11
- } catch {
12
- return "expected a valid regular expression";
13
- }
14
- })
15
- )
16
- );
17
- var PreflightSchema = Schema.Struct({
18
- /** Shell command; exit 0 lets the proc spawn. */
19
- shell: Schema.String.pipe(Schema.check(Schema.isMinLength(1))),
20
- /**
21
- * Regex that must also match the check's output. For checks that exit 0
22
- * either way — `wrangler whoami` reports "not authenticated" with exit 0 —
23
- * so the config doesn't need `| grep` pipelines.
24
- */
25
- expect: Schema.optionalKey(regexSource),
26
- /** What the human should do when the check fails. */
27
- hint: Schema.optionalKey(Schema.String)
28
- });
29
- var AlertSchema = Schema.Struct({
30
- pattern: regexSource,
31
- /** Badge text. */
32
- label: Schema.String.pipe(Schema.check(Schema.isMinLength(1)))
33
- });
34
- var ProcSpecSchema = Schema.Struct({
35
- /** Stable identifier, also the tab label. */
36
- id: Schema.String.pipe(Schema.check(Schema.isMinLength(1))),
37
- /** Command line handed to `$SHELL -c`. */
38
- shell: Schema.optionalKey(Schema.String),
39
- /** argv, executed without a shell. */
40
- cmd: Schema.optionalKey(Schema.Array(Schema.String).pipe(Schema.check(Schema.isMinLength(1)))),
41
- /** Working directory; defaults to the config file's directory. */
42
- cwd: Schema.optionalKey(Schema.String),
43
- /** Extra environment on top of the inherited one. */
44
- env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
45
- /** Spawn on startup. Defaults to true. */
46
- autostart: Schema.optionalKey(Schema.Boolean),
47
- /** URL this process serves, shown in the UI as a link. */
48
- url: Schema.optionalKey(Schema.String),
49
- /**
50
- * Names of procs that must be ready before this one spawns. Declarative:
51
- * readiness is auto-detected, no port numbers in the config.
52
- */
53
- needs: Schema.optionalKey(Schema.Array(Schema.String)),
54
- /**
55
- * What "ready" means for dependents: "listening" (default) — the process
56
- * tree opened a TCP port; "started" — it merely spawned (for procs that
57
- * never listen).
58
- */
59
- readyWhen: Schema.optionalKey(Schema.Literals(["listening", "started"])),
60
- /** Check that must pass before this proc spawns; failure shows as "blocked". */
61
- preflight: Schema.optionalKey(PreflightSchema),
62
- /** Output patterns that raise a badge when they appear in the pane. */
63
- alerts: Schema.optionalKey(Schema.Array(AlertSchema))
64
- }).pipe(
65
- Schema.check(
66
- Schema.makeFilter(
67
- (spec) => spec.shell === void 0 === (spec.cmd === void 0) ? "expected exactly one of `shell` or `cmd`" : void 0
68
- )
69
- )
70
- );
71
- var DEFAULT_UI_PORT = 4820;
72
- var PORT_TEMPLATE = /\$\{port(?::([^}]+))?\}/g;
73
- var templatedStrings = (spec) => [spec.shell, ...spec.cmd ?? [], ...Object.values(spec.env ?? {}), spec.url].filter(
74
- (text) => text !== void 0
75
- );
76
- var usesOwnPort = (spec) => templatedStrings(spec).some(
77
- (text) => [...text.matchAll(PORT_TEMPLATE)].some((match) => match[1] === void 0)
78
- );
79
- var portRefs = (spec) => templatedStrings(spec).flatMap(
80
- (text) => [...text.matchAll(PORT_TEMPLATE)].flatMap((match) => match[1] === void 0 ? [] : [match[1]])
81
- );
82
- var resolveSpec = (spec, assigned) => {
83
- const sub = (text) => text.replace(PORT_TEMPLATE, (whole, ref) => {
84
- const port = assigned.get(ref ?? spec.id);
85
- return port === void 0 ? whole : String(port);
86
- });
87
- return {
88
- ...spec,
89
- ...spec.shell === void 0 ? {} : { shell: sub(spec.shell) },
90
- ...spec.cmd === void 0 ? {} : { cmd: spec.cmd.map(sub) },
91
- ...spec.env === void 0 ? {} : { env: Object.fromEntries(Object.entries(spec.env).map(([key, value]) => [key, sub(value)])) },
92
- ...spec.url === void 0 ? {} : { url: sub(spec.url) }
93
- };
94
- };
95
- var validateGraph = (procs) => {
96
- const ids = new Set(procs.map((proc) => proc.id));
97
- if (ids.size !== procs.length) return "duplicate proc ids";
98
- for (const proc of procs) {
99
- for (const need of proc.needs ?? []) {
100
- if (!ids.has(need)) return `proc "${proc.id}" needs unknown proc "${need}"`;
101
- }
102
- }
103
- const needsOf = new Map(procs.map((proc) => [proc.id, proc.needs ?? []]));
104
- const done = /* @__PURE__ */ new Set();
105
- const onPath = /* @__PURE__ */ new Set();
106
- const visit = (id) => {
107
- if (done.has(id)) return void 0;
108
- if (onPath.has(id)) return `dependency cycle through "${id}"`;
109
- onPath.add(id);
110
- for (const need of needsOf.get(id) ?? []) {
111
- const cycle = visit(need);
112
- if (cycle !== void 0) return cycle;
113
- }
114
- onPath.delete(id);
115
- done.add(id);
116
- return void 0;
117
- };
118
- for (const proc of procs) {
119
- const cycle = visit(proc.id);
120
- if (cycle !== void 0) return cycle;
121
- }
122
- return void 0;
123
- };
124
- var validatePortTemplates = (procs) => {
125
- const byId = new Map(procs.map((proc) => [proc.id, proc]));
126
- for (const proc of procs) {
127
- for (const ref of portRefs(proc)) {
128
- const target = byId.get(ref);
129
- if (target === void 0) {
130
- return `proc "${proc.id}" references \${port:${ref}} but there is no proc "${ref}"`;
131
- }
132
- if (!usesOwnPort(target)) {
133
- return `proc "${proc.id}" references \${port:${ref}} but "${ref}" does not use \${port} itself`;
134
- }
135
- }
136
- }
137
- return void 0;
138
- };
139
- var ProcdeckConfigSchema = Schema.Struct({
140
- /**
141
- * Deck name — the tab title and the name of the installed web app.
142
- * Defaults to the config directory's basename.
143
- */
144
- name: Schema.optionalKey(Schema.String),
145
- /** Port for the web UI. Defaults to 4820. */
146
- port: Schema.optionalKey(Schema.Number),
147
- procs: Schema.Array(ProcSpecSchema)
148
- }).pipe(
149
- Schema.check(
150
- Schema.makeFilter(
151
- (config) => validateGraph(config.procs) ?? validatePortTemplates(config.procs)
152
- )
153
- )
154
- );
155
- var defineConfig = (config) => config;
156
- var ConfigError = class extends Schema.TaggedError()("ConfigError", {
157
- file: Schema.String,
158
- message: Schema.String
159
- }) {
160
- };
161
- var loadConfig = Effect.fn("procdeck.loadConfig")(function* (file) {
162
- const absolute = path.resolve(file);
163
- const module = yield* Effect.tryPromise({
164
- try: () => import(pathToFileURL(absolute).href),
165
- catch: (cause) => new ConfigError({ file: absolute, message: String(cause) })
166
- });
167
- const config = yield* Effect.try({
168
- try: () => Schema.decodeUnknownSync(ProcdeckConfigSchema)(module.default),
169
- catch: (cause) => new ConfigError({ file: absolute, message: String(cause) })
170
- });
171
- const root = path.dirname(absolute);
172
- const loaded = { config, root, name: config.name ?? path.basename(root) };
173
- return loaded;
174
- });
175
-
176
- export {
177
- PreflightSchema,
178
- AlertSchema,
179
- ProcSpecSchema,
180
- DEFAULT_UI_PORT,
181
- usesOwnPort,
182
- resolveSpec,
183
- ProcdeckConfigSchema,
184
- defineConfig,
185
- ConfigError,
186
- loadConfig
187
- };