@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 +51 -33
- package/dist/jig/client.js +141 -139
- package/dist/registry/instance.js +12 -10
- package/dist/registry/projects.js +11 -7
- package/dist/source/local.js +29 -7
- package/dist/web/assets/CanvasView-4RWri2ak.js +1 -0
- package/dist/web/assets/index-D1V3u6PF.css +2 -0
- package/dist/web/assets/{index-CYfTN3Dj.js → index-DOnV4WX5.js} +2 -2
- package/dist/web/index.html +2 -2
- package/package.json +5 -3
- package/dist/web/assets/CanvasView-kgt4JQsV.js +0 -1
- package/dist/web/assets/index-hMczOUNS.css +0 -2
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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:
|
|
52
|
-
})
|
|
53
|
-
|
|
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:
|
|
62
|
-
})
|
|
63
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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 },
|
|
78
|
-
})
|
|
79
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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 }));
|
package/dist/jig/client.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
188
|
-
throw new JigShapeError();
|
|
189
|
-
return { revision, tasks: data.tasks.map(summary), hidden: data.hidden };
|
|
200
|
+
return { revision, ...parsed(TaskList, data) };
|
|
190
201
|
}
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
if (Array.isArray(projects) && projects.every(isProject))
|
|
26
|
-
return projects;
|
|
27
|
+
json = JSON.parse(text);
|
|
27
28
|
}
|
|
28
29
|
catch {
|
|
29
|
-
|
|
30
|
+
throw new RegistryUnreadable();
|
|
30
31
|
}
|
|
31
|
-
|
|
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(/
|
|
41
|
+
.replace(/\p{M}/gu, '')
|
|
38
42
|
.toLowerCase()
|
|
39
43
|
.replace(/[^a-z0-9]+/g, '-')
|
|
40
44
|
.replace(/^-+|-+$/g, '') || 'project';
|