@fapost/jig-ui 0.2.0-next.20 → 0.2.0-next.22

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/http/app.js CHANGED
@@ -4,7 +4,10 @@ import { isAbsolute, join } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { serveStatic } from '@hono/node-server/serve-static';
6
6
  import { Hono } from 'hono';
7
+ import { HTTPException } from 'hono/http-exception';
8
+ import { secureHeaders } from 'hono/secure-headers';
7
9
  import { streamSSE } from 'hono/streaming';
10
+ import { validator } from 'hono/validator';
8
11
  import { columns, placeOf } from '../board/columns.js';
9
12
  import { RegistryUnreadable } from '../source/project-source.js';
10
13
  import { localOnly, tokenAccess } from './access.js';
@@ -29,61 +32,58 @@ function absolute(path) {
29
32
  const expanded = path.trim().replace(/^~(?=$|[\\/])/, homedir());
30
33
  return isAbsolute(expanded) ? expanded : undefined;
31
34
  }
32
- export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
33
- const app = new Hono();
34
- app.use(localOnly(port));
35
- app.use(tokenAccess({ port, token }));
36
- app.onError((error, c) => {
37
- if (error instanceof RegistryUnreadable)
38
- return refuse(c, 500, 'registry-unreadable', error.message);
39
- console.error(error);
40
- return c.text('Internal Server Error', 500);
41
- });
42
- app.get('/api/instance', (c) => c.json({ version }));
43
- app.get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }));
44
- app.get('/api/projects/:id/board', async (c) => {
35
+ /**
36
+ * The API, chained so that its type carries every route's answers: the page derives its types
37
+ * from `AppType` and never declares them a second time.
38
+ */
39
+ function apiRoutes(source, version) {
40
+ return (new Hono()
41
+ .get('/api/instance', (c) => c.json({ version }, 200))
42
+ .get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }, 200))
43
+ .get('/api/projects/:id/board', async (c) => {
45
44
  const id = c.req.param('id');
46
45
  const read = await source.tasks(id);
47
46
  if (read === undefined)
48
47
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
49
48
  if (read.state !== 'ready')
50
- return c.json({ board: read });
51
- return c.json({ board: { id, state: 'ready', columns: columns(read.tasks), finished: read.finished } });
52
- });
53
- app.get('/api/projects/:id/tasks/:task', async (c) => {
49
+ return c.json({ board: read }, 200);
50
+ return c.json({ board: { id, state: read.state, columns: columns(read.tasks), finished: read.finished } }, 200);
51
+ })
52
+ .get('/api/projects/:id/tasks/:task', async (c) => {
54
53
  const read = await source.task(c.req.param('id'), c.req.param('task'));
55
54
  if (read === undefined)
56
55
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
57
56
  if (read.state !== 'ready')
58
- return c.json({ task: read });
57
+ return c.json({ task: read }, 200);
59
58
  if (read.task === null)
60
59
  return refuse(c, 404, 'not-found', 'This project has no live task with this id.');
61
- return c.json({ task: { state: 'ready', ...read.task, column: placeOf(read.task) } });
62
- });
63
- app.get('/api/projects/:id/specs', async (c) => {
60
+ return c.json({ task: { state: read.state, ...read.task, column: placeOf(read.task) } }, 200);
61
+ })
62
+ .get('/api/projects/:id/specs', async (c) => {
64
63
  const read = await source.specs(c.req.param('id'));
65
64
  if (read === undefined)
66
65
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
67
- return c.json({ specs: read });
68
- });
69
- app.post('/api/projects', async (c) => {
70
- const body = await c.req.json().catch(() => undefined);
71
- const path = absolute(body?.path);
66
+ return c.json({ specs: read }, 200);
67
+ })
68
+ .post('/api/projects', validator('json', (body) => ({
69
+ path: absolute(body?.path),
70
+ })), async (c) => {
71
+ const { path } = c.req.valid('json');
72
72
  if (path === undefined)
73
73
  return refuse(c, 422, 'usage', 'Give the absolute path of a project.');
74
74
  const result = await source.connect(path);
75
75
  if ('code' in result)
76
76
  return refuse(c, 422, result.code, result.message);
77
- return c.json({ project: result.project }, result.created ? 201 : 200);
78
- });
79
- app.delete('/api/projects/:id', async (c) => {
77
+ return result.created ? c.json({ project: result.project }, 201) : c.json({ project: result.project }, 200);
78
+ })
79
+ .delete('/api/projects/:id', async (c) => {
80
80
  if (!(await source.disconnect(c.req.param('id')))) {
81
81
  return refuse(c, 404, 'not-connected', 'No connected project has this id.');
82
82
  }
83
83
  return c.body(null, 204);
84
- });
85
- // A signal, not data: the page re-fetches what it shows (adr-20261014-project-source-port-keeps-hosting-open).
86
- app.get('/api/events', (c) => streamSSE(c, async (stream) => {
84
+ })
85
+ // A signal, not data: the page re-fetches what it shows (adr-20261014-project-source-port-keeps-hosting-open).
86
+ .get('/api/events', (c) => streamSSE(c, async (stream) => {
87
87
  const unsubscribe = source.subscribe(({ id, revision }) => {
88
88
  void stream.writeSSE({ event: 'changed', data: JSON.stringify({ project: id, revision }) });
89
89
  });
@@ -93,7 +93,25 @@ export function createApp({ port, token, source, version = readPackageVersion(),
93
93
  if (!stream.aborted)
94
94
  await stream.write(': ping\n\n');
95
95
  }
96
- }));
96
+ })));
97
+ }
98
+ export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
99
+ const app = new Hono();
100
+ // Before the guard, so its refusals carry them too; HSTS means nothing on http://127.0.0.1.
101
+ app.use(secureHeaders({ strictTransportSecurity: false }));
102
+ app.use(localOnly(port));
103
+ app.use(tokenAccess({ port, token }));
104
+ app.onError((error, c) => {
105
+ if (error instanceof RegistryUnreadable)
106
+ return refuse(c, 500, 'registry-unreadable', error.message);
107
+ // Hono's own refusals, such as a body that is no JSON: their status, in the API's form.
108
+ if (error instanceof HTTPException)
109
+ return c.json({ error: { code: 'usage', message: error.message } }, error.status);
110
+ // Only the terminal hears what failed: the message may name a path.
111
+ console.error(error);
112
+ return refuse(c, 500, 'internal', 'Jig UI hit an error it did not expect; its terminal says what.');
113
+ });
114
+ app.route('/', apiRoutes(source, version));
97
115
  app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
98
116
  if (existsSync(join(webRoot, 'index.html'))) {
99
117
  app.use('/*', serveStatic({ root: webRoot }));
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
3
  import { join, resolve } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
+ import { z } from 'zod';
5
6
  const run = promisify(execFile);
6
7
  /** The oldest Jig whose reads Jig UI understands: 0.29.0 is the first that prints JSON. */
7
8
  export const MIN_JIG_VERSION = '0.29.0';
@@ -17,6 +18,7 @@ export const watchDir = (root) => join(root, '.ai');
17
18
  * (Jig 0.29.0, found by watching which files its reads touch).
18
19
  */
19
20
  export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('runtime/')) && !path.startsWith('runtime/revision');
21
+ let gitBash;
20
22
  /**
21
23
  * The bash that runs `.ai/scripts/jig`. On Windows it is Git's, found next to git itself
22
24
  * (`<git>/mingw64/libexec/git-core` → `<git>/bin/bash.exe`): a `bash` on PATH may be WSL's.
@@ -24,8 +26,12 @@ export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('
24
26
  async function bash(platform) {
25
27
  if (platform !== 'win32')
26
28
  return 'bash';
27
- const { stdout } = await run('git', ['--exec-path'], { timeout: TIMEOUT_MS });
28
- return resolve(stdout.trim(), '..', '..', '..', 'bin', 'bash.exe');
29
+ // Asked once per process; a failed ask is forgotten, so the next run asks again.
30
+ gitBash ??= run('git', ['--exec-path'], { timeout: TIMEOUT_MS }).then(({ stdout }) => resolve(stdout.trim(), '..', '..', '..', 'bin', 'bash.exe'), (error) => {
31
+ gitBash = undefined;
32
+ throw error;
33
+ });
34
+ return gitBash;
29
35
  }
30
36
  /** Runs the project's own Jig in its main checkout, without a shell. */
31
37
  async function jig(root, args, platform) {
@@ -60,8 +66,6 @@ export class JigShapeError extends Error {
60
66
  }
61
67
  }
62
68
  const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
63
- const isString = (v) => typeof v === 'string';
64
- const isNullableString = (v) => v === null || typeof v === 'string';
65
69
  /**
66
70
  * Runs a `--json` read and returns its `data` and the clone's revision. Jig's refusal throws
67
71
  * JigRefusal, as does output that is no JSON at all (a crash); an envelope of another shape throws
@@ -87,159 +91,157 @@ async function readJson(root, args, platform) {
87
91
  throw new JigShapeError();
88
92
  if (isObject(envelope.error)) {
89
93
  const code = envelope.error.code;
90
- throw new JigRefusal(isString(code) && CODES.has(code) ? code : 'failed');
94
+ throw new JigRefusal(typeof code === 'string' && CODES.has(code) ? code : 'failed');
91
95
  }
92
96
  if (!isObject(envelope.data))
93
97
  throw new JigShapeError();
94
98
  return { revision: envelope.revision, data: envelope.data };
95
99
  }
96
- const isAttention = (v) => isObject(v) &&
97
- isString(v.kind) &&
98
- isObject(v.subject) &&
99
- isString(v.subject.type) &&
100
- isNullableString(v.subject.id) &&
101
- Array.isArray(v.tasks) &&
102
- v.tasks.every(isString) &&
103
- isString(v.title) &&
104
- isNullableString(v.detail) &&
105
- isString(v.todo) &&
106
- isNullableString(v.url);
100
+ /** `data` read through `schema`, the shape its contract test pins; anything else is a Jig this Jig UI does not read. */
101
+ function parsed(schema, data) {
102
+ const result = schema.safeParse(data);
103
+ if (!result.success)
104
+ throw new JigShapeError();
105
+ return result.data;
106
+ }
107
+ // Each shape of Jig's output once: what Jig prints (snake_case, every field Jig UI reads present —
108
+ // `null` where Jig has nothing, never left out), turned into what Jig UI's types say. Keys Jig adds
109
+ // without raising `schema` are dropped.
110
+ const text = z.string();
111
+ const maybe = z.string().nullable();
112
+ const count = z.int().nonnegative();
113
+ /** Something that needs the person, as `jig status --json` reports it; a kind this Jig UI does not know is shown by its texts. */
114
+ const Attention = z.object({
115
+ kind: text,
116
+ subject: z.object({ type: text, id: maybe }),
117
+ tasks: z.array(text),
118
+ title: text,
119
+ detail: maybe,
120
+ todo: text,
121
+ url: maybe,
122
+ });
123
+ const Status = z.object({
124
+ attention: z.array(Attention),
125
+ housekeeping: z.object({ stale: z.boolean() }).nullable(),
126
+ });
107
127
  /** What needs the person in a project, most urgent first, and whether the pull request facts behind it may be missing. */
108
128
  export async function readStatus(root, platform = process.platform) {
109
129
  const { revision, data } = await readJson(root, ['status'], platform);
110
- const { attention, housekeeping } = data;
111
- if (!Array.isArray(attention) || !attention.every(isAttention))
112
- throw new JigShapeError();
113
- if (housekeeping !== null && !(isObject(housekeeping) && typeof housekeeping.stale === 'boolean'))
114
- throw new JigShapeError();
115
- return {
116
- revision,
117
- attention: attention.map(({ kind, subject, tasks, title, detail, todo, url }) => ({
118
- kind,
119
- subject: { type: subject.type, id: subject.id },
120
- tasks: [...tasks],
121
- title,
122
- detail,
123
- todo,
124
- url,
125
- })),
126
- housekeepingStale: housekeeping?.stale === true,
127
- };
130
+ const { attention, housekeeping } = parsed(Status, data);
131
+ return { revision, attention, housekeepingStale: housekeeping?.stale === true };
128
132
  }
129
- const STATUSES = new Set(['active', 'ready', 'consolidated', 'abandoned']);
130
133
  /** Jig's action ids this Jig UI knows; Jig may add ids without raising `schema`, and a consumer ignores those. */
131
134
  const ACTIONS = new Set(['start', 'pause', 'resume', 'approve-gate', 'answer-stop', 'set-class', 'ship', 'close', 'abandon', 'reopen']);
132
- const nullableStrings = (v, keys) => keys.every((k) => isNullableString(v[k]));
133
- const isGate = (v) => v === null || (isObject(v) && isString(v.state) && isNullableString(v.by));
134
- const isAutopilot = (v) => v === null || (isObject(v) && isString(v.state) && nullableStrings(v, ['mode', 'stage', 'stage_at', 'stop_reason', 'stop_at', 'phase']));
135
- const isPullRequest = (v) => v === null || (isObject(v) && isString(v.url) && nullableStrings(v, ['state', 'ci', 'fetched_at']));
136
- const isWorktree = (v) => v === null || (isObject(v) && typeof v.uncommitted === 'number');
137
- const isAction = (v) => isObject(v) && isString(v.id) && isString(v.command) && typeof v.offered === 'boolean' && isNullableString(v.reason);
138
- const isTaskRow = (v) => isObject(v) &&
139
- isString(v.id) &&
140
- isString(v.status) &&
141
- STATUSES.has(v.status) &&
142
- nullableStrings(v, ['class', 'issue', 'goal', 'created_at', 'updated_at', 'branch']) &&
143
- typeof v.knowledge_consolidated === 'boolean' &&
144
- typeof v.paused === 'boolean' &&
145
- isString(v.base_branch) &&
146
- isWorktree(v.worktree) &&
147
- isGate(v.gate) &&
148
- isAutopilot(v.autopilot) &&
149
- isPullRequest(v.pull_request) &&
150
- Array.isArray(v.housekeeping_flags) &&
151
- v.housekeeping_flags.every(isString) &&
152
- Array.isArray(v.actions) &&
153
- v.actions.every(isAction);
154
- function summary(t) {
155
- const { gate, autopilot: run, pull_request: pr } = t;
156
- return {
157
- id: t.id,
158
- class: t.class,
159
- status: t.status,
160
- issue: t.issue,
161
- goal: t.goal,
162
- knowledgeConsolidated: t.knowledge_consolidated,
163
- paused: t.paused,
164
- createdAt: t.created_at,
165
- updatedAt: t.updated_at,
166
- branch: t.branch,
167
- baseBranch: t.base_branch,
168
- worktree: t.worktree && { uncommitted: t.worktree.uncommitted },
169
- gate: gate && { state: gate.state, by: gate.by },
170
- autopilot: run && {
171
- state: run.state,
172
- mode: run.mode,
173
- stage: run.stage,
174
- stageAt: run.stage_at,
175
- stopReason: run.stop_reason,
176
- stopAt: run.stop_at,
177
- phase: run.phase,
178
- },
179
- pullRequest: pr && { url: pr.url, state: pr.state, ci: pr.ci, fetchedAt: pr.fetched_at },
180
- housekeepingFlags: [...t.housekeeping_flags],
181
- actions: t.actions.filter((a) => ACTIONS.has(a.id)).map(({ id, command, offered, reason }) => ({ id, command, offered, reason })),
182
- };
183
- }
135
+ /** What a person may do next with a task, as Jig decides it from the task's state. */
136
+ const TaskAction = z.object({
137
+ id: text,
138
+ /** The Jig command that takes it; a word in `<…>` is a value the person supplies. */
139
+ command: text,
140
+ offered: z.boolean(),
141
+ /** Jig's refusal, word for word, when not offered; it may name a path. */
142
+ reason: maybe,
143
+ });
144
+ /** A task as `jig task list --json` reports it, as far as Jig UI reads it; the statuses are Jig's (schemas/state.md). */
145
+ const TaskRow = z
146
+ .object({
147
+ id: text,
148
+ class: maybe,
149
+ status: z.enum(['active', 'ready', 'consolidated', 'abandoned']),
150
+ issue: maybe,
151
+ goal: maybe,
152
+ knowledge_consolidated: z.boolean(),
153
+ paused: z.boolean(),
154
+ created_at: maybe,
155
+ updated_at: maybe,
156
+ /** `null` while the task has not been started. */
157
+ branch: maybe,
158
+ base_branch: text,
159
+ /** The worktree that has the task's branch checked out: only how many files differ there, never where it is. */
160
+ worktree: z.object({ uncommitted: z.number() }).nullable(),
161
+ gate: z.object({ state: text, by: maybe }).nullable(),
162
+ autopilot: z
163
+ .object({ state: text, mode: maybe, stage: maybe, stage_at: maybe, stop_reason: maybe, stop_at: maybe, phase: maybe })
164
+ .nullable(),
165
+ pull_request: z.object({ url: text, state: maybe, ci: maybe, fetched_at: maybe }).nullable(),
166
+ housekeeping_flags: z.array(text),
167
+ actions: z.array(TaskAction),
168
+ })
169
+ .transform(({ autopilot: run, pull_request: pr, ...t }) => ({
170
+ id: t.id,
171
+ class: t.class,
172
+ status: t.status,
173
+ issue: t.issue,
174
+ goal: t.goal,
175
+ knowledgeConsolidated: t.knowledge_consolidated,
176
+ paused: t.paused,
177
+ createdAt: t.created_at,
178
+ updatedAt: t.updated_at,
179
+ branch: t.branch,
180
+ baseBranch: t.base_branch,
181
+ worktree: t.worktree,
182
+ gate: t.gate,
183
+ autopilot: run && {
184
+ state: run.state,
185
+ mode: run.mode,
186
+ stage: run.stage,
187
+ stageAt: run.stage_at,
188
+ stopReason: run.stop_reason,
189
+ stopAt: run.stop_at,
190
+ phase: run.phase,
191
+ },
192
+ pullRequest: pr && { url: pr.url, state: pr.state, ci: pr.ci, fetchedAt: pr.fetched_at },
193
+ housekeepingFlags: t.housekeeping_flags,
194
+ actions: t.actions.filter((a) => ACTIONS.has(a.id)),
195
+ }));
196
+ const TaskList = z.object({ tasks: z.array(TaskRow), hidden: z.number() });
184
197
  /** The project's live tasks (`jig task list --json`) and how many finished ones it left out. */
185
198
  export async function readTasks(root, platform = process.platform) {
186
199
  const { revision, data } = await readJson(root, ['task', 'list'], platform);
187
- if (!Array.isArray(data.tasks) || !data.tasks.every(isTaskRow) || typeof data.hidden !== 'number')
188
- throw new JigShapeError();
189
- return { revision, tasks: data.tasks.map(summary), hidden: data.hidden };
200
+ return { revision, ...parsed(TaskList, data) };
190
201
  }
191
- const isCount = (v) => Number.isInteger(v) && v >= 0;
192
- const isProgress = (v) => isObject(v) && isCount(v.done) && isCount(v.total) && isCount(v.filed) && isCount(v.fog);
193
- const isItem = (v) => isObject(v) && isString(v.text) && typeof v.checked === 'boolean' && isNullableString(v.task) && typeof v.fog === 'boolean';
194
- const isPhase = (v) => isObject(v) &&
195
- isProgress(v) &&
196
- (v.phase === null || isCount(v.phase)) &&
197
- isNullableString(v.title) &&
198
- Array.isArray(v.items) &&
199
- v.items.every(isItem);
200
- const isEpic = (v) => v === null ||
201
- (isObject(v) &&
202
- isString(v.branch) &&
203
- (v.state === 'open' || v.state === 'finished') &&
204
- typeof v.checked_out === 'boolean' &&
205
- typeof v.missing === 'boolean' &&
206
- isNullableString(v.ref));
207
- const isSpecRow = (v) => isObject(v) &&
208
- isString(v.id) &&
209
- isNullableString(v.title) &&
210
- Array.isArray(v.missing_files) &&
211
- v.missing_files.every(isString) &&
212
- isNullableString(v.release) &&
213
- isEpic(v.epic) &&
214
- (v.progress === null || isProgress(v.progress)) &&
215
- Array.isArray(v.phases) &&
216
- v.phases.every(isPhase);
217
- const progress = ({ done, total, filed, fog }) => ({ done, total, filed, fog });
202
+ /** How far a roadmap or one of its phases is: items checked, all items, items with a task filed, fog items. */
203
+ const SpecProgress = z.object({ done: count, total: count, filed: count, fog: count });
204
+ /** A roadmap item: its words as the roadmap has them, and the task it names, if any. */
205
+ const SpecItem = z.object({ text, checked: z.boolean(), task: maybe, fog: z.boolean() });
206
+ /** A roadmap phase; `phase` and `title` are `null` for items before any phase heading. */
207
+ const SpecPhase = SpecProgress.extend({ phase: count.nullable(), title: maybe, items: z.array(SpecItem) });
208
+ /** A spec as `jig spec list --json` reports it; the roadmap is read from the epic while one is open. */
209
+ const SpecRow = z
210
+ .object({
211
+ id: text,
212
+ title: maybe,
213
+ /** The spec's own files that are not there: `spec.md`, `roadmap.md`. */
214
+ missing_files: z.array(text),
215
+ /** The version level the epic's release raises, as the roadmap records it. */
216
+ release: maybe,
217
+ epic: z
218
+ .object({ branch: text, state: z.enum(['open', 'finished']), checked_out: z.boolean(), missing: z.boolean(), ref: maybe })
219
+ .nullable(),
220
+ /** `null` when the roadmap could not be read. */
221
+ progress: SpecProgress.nullable(),
222
+ phases: z.array(SpecPhase),
223
+ })
224
+ .transform(({ missing_files, epic, ...s }) => ({
225
+ id: s.id,
226
+ title: s.title,
227
+ missingFiles: missing_files,
228
+ release: s.release,
229
+ // `fetched`: the roadmap was read from the remote branch as of the last fetch; `missing`: the branch is nowhere.
230
+ epic: epic && {
231
+ branch: epic.branch,
232
+ state: epic.state,
233
+ checkedOut: epic.checked_out,
234
+ missing: epic.missing,
235
+ fetched: epic.ref?.startsWith('refs/remotes/') === true,
236
+ },
237
+ progress: s.progress,
238
+ phases: s.phases,
239
+ }));
240
+ const SpecList = z.object({ specs: z.array(SpecRow) });
218
241
  /** The project's specs (`jig spec list --json`), in Jig's order. */
219
242
  export async function readSpecs(root, platform = process.platform) {
220
243
  const { data } = await readJson(root, ['spec', 'list'], platform);
221
- if (!Array.isArray(data.specs) || !data.specs.every(isSpecRow))
222
- throw new JigShapeError();
223
- return data.specs.map((s) => ({
224
- id: s.id,
225
- title: s.title,
226
- missingFiles: [...s.missing_files],
227
- release: s.release,
228
- epic: s.epic && {
229
- branch: s.epic.branch,
230
- state: s.epic.state,
231
- checkedOut: s.epic.checked_out,
232
- missing: s.epic.missing,
233
- fetched: s.epic.ref?.startsWith('refs/remotes/') === true,
234
- },
235
- progress: s.progress && progress(s.progress),
236
- phases: s.phases.map((p) => ({
237
- ...progress(p),
238
- phase: p.phase,
239
- title: p.title,
240
- items: p.items.map(({ text, checked, task, fog }) => ({ text, checked, task, fog })),
241
- })),
242
- }));
244
+ return parsed(SpecList, data).specs;
243
245
  }
244
246
  /** True when `version` is at least the minimum Jig UI reads. */
245
247
  export function supports(version, minimum = MIN_JIG_VERSION) {
@@ -1,6 +1,9 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
+ import { z } from 'zod';
5
+ /** What a running instance records so that a second start can find it. */
6
+ const InstanceInfo = z.object({ port: z.int(), token: z.string(), pid: z.int(), version: z.string() });
4
7
  const FILE = 'instance.json';
5
8
  /** The page that sends the browser on to the instance; it carries the token, so it lives here too. */
6
9
  export const OPEN_FILE = 'open.html';
@@ -8,10 +11,7 @@ export const OPEN_FILE = 'open.html';
8
11
  export const cookieName = (port) => `jig-ui-${port}`;
9
12
  export async function readInstance(dir) {
10
13
  try {
11
- const info = JSON.parse(await readFile(join(dir, FILE), 'utf8'));
12
- if (typeof info.port !== 'number' || typeof info.token !== 'string')
13
- return undefined;
14
- return info;
14
+ return InstanceInfo.safeParse(JSON.parse(await readFile(join(dir, FILE), 'utf8'))).data;
15
15
  }
16
16
  catch {
17
17
  return undefined;
@@ -48,6 +48,11 @@ export async function probeInstance(info, timeoutMs = 1000) {
48
48
  return false;
49
49
  }
50
50
  }
51
+ // An instance before 0.2.0 answers a failure it did not foresee in plain text: neither of these.
52
+ const Answer = z.union([
53
+ z.object({ project: z.object({ id: z.string() }) }),
54
+ z.object({ error: z.object({ code: z.string(), message: z.string() }) }),
55
+ ]);
51
56
  /** Asks the instance to connect `path`, as the page does: the instance owns the registry, so no second process writes it. */
52
57
  export async function connectThrough(info, path, timeoutMs = 15_000) {
53
58
  try {
@@ -57,13 +62,10 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
57
62
  body: JSON.stringify({ path }),
58
63
  signal: AbortSignal.timeout(timeoutMs),
59
64
  });
60
- // A failure the instance did not foresee answers in plain text.
61
- const body = (await res.json().catch(() => ({})));
62
- if (body.project)
65
+ const body = Answer.safeParse(await res.json().catch(() => undefined)).data;
66
+ if (body && 'project' in body)
63
67
  return { id: body.project.id, created: res.status === 201 };
64
- if (typeof body.error === 'object')
65
- return body.error;
66
- return { code: 'unexpected', message: `Jig UI answered ${res.status}.` };
68
+ return body?.error ?? { code: 'unexpected', message: `Jig UI answered ${res.status}.` };
67
69
  }
68
70
  catch (error) {
69
71
  return { code: 'unreachable', message: error instanceof Error ? error.message : String(error) };
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { basename, join } from 'node:path';
3
+ import { z } from 'zod';
3
4
  import { writeConfigFile } from './instance.js';
4
5
  const FILE = 'projects.json';
5
6
  /** The registry file exists but cannot be read; it is never overwritten then, so the list is not lost. */
@@ -8,7 +9,8 @@ export class RegistryUnreadable extends Error {
8
9
  super(`${FILE} in Jig UI's config directory cannot be read; fix or remove it.`);
9
10
  }
10
11
  }
11
- const isProject = (p) => typeof p === 'object' && p !== null && typeof p.id === 'string' && typeof p.path === 'string';
12
+ // A row keeps the fields it does not know, so a later version's additions survive this one's writes.
13
+ const Registry = z.object({ projects: z.array(z.looseObject({ id: z.string(), path: z.string() })) });
12
14
  /** The connected projects in the order they were connected; none when there is no registry yet. */
13
15
  export async function readProjects(dir) {
14
16
  let text;
@@ -20,21 +22,23 @@ export async function readProjects(dir) {
20
22
  return [];
21
23
  throw new RegistryUnreadable();
22
24
  }
25
+ let json;
23
26
  try {
24
- const { projects } = JSON.parse(text);
25
- if (Array.isArray(projects) && projects.every(isProject))
26
- return projects;
27
+ json = JSON.parse(text);
27
28
  }
28
29
  catch {
29
- // falls through
30
+ throw new RegistryUnreadable();
30
31
  }
31
- throw new RegistryUnreadable();
32
+ const registry = Registry.safeParse(json);
33
+ if (!registry.success)
34
+ throw new RegistryUnreadable();
35
+ return registry.data.projects;
32
36
  }
33
37
  /** The id for a checkout: its directory name as a slug, with `-2`, `-3`… when that is taken. */
34
38
  export function slugFor(root, taken) {
35
39
  const base = basename(root)
36
40
  .normalize('NFKD')
37
- .replace(/[̀-ͯ]/g, '')
41
+ .replace(/\p{M}/gu, '')
38
42
  .toLowerCase()
39
43
  .replace(/[^a-z0-9]+/g, '-')
40
44
  .replace(/^-+|-+$/g, '') || 'project';