@fapost/jig-ui 0.1.0-next.8 → 0.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/board/columns.js +29 -0
- package/dist/http/app.js +48 -12
- package/dist/jig/client.js +207 -0
- package/dist/main.js +40 -9
- package/dist/node-version.js +1 -2
- package/dist/registry/instance.js +21 -0
- package/dist/source/checkout.js +19 -1
- package/dist/source/local.js +245 -5
- package/dist/source/project-source.js +1 -0
- package/dist/watch/watch.js +56 -0
- package/dist/web/assets/CanvasView-kgt4JQsV.js +1 -0
- package/dist/web/assets/index-CYfTN3Dj.js +6 -0
- package/dist/web/assets/index-hMczOUNS.css +2 -0
- package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/web/index.html +4 -2
- package/package.json +20 -1
- package/dist/web/assets/index-B-WsHAHz.js +0 -2
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The board's order: what waits for the person first, then what moves, what is done, what is not begun, what is set aside. */
|
|
2
|
+
const COLUMNS = [
|
|
3
|
+
{ id: 'needs-you', title: 'Needs you' },
|
|
4
|
+
{ id: 'in-progress', title: 'In progress' },
|
|
5
|
+
{ id: 'ready', title: 'Ready' },
|
|
6
|
+
{ id: 'backlog', title: 'Backlog' },
|
|
7
|
+
{ id: 'paused', title: 'Paused' },
|
|
8
|
+
];
|
|
9
|
+
/** Where a task stands, from Jig's state alone; the first rule that fits wins. */
|
|
10
|
+
export function columnOf(task) {
|
|
11
|
+
if (task.paused)
|
|
12
|
+
return 'paused';
|
|
13
|
+
if (!task.started)
|
|
14
|
+
return 'backlog';
|
|
15
|
+
if (task.gate === 'waiting' || task.gate === 'changed' || task.autopilot === 'stopped')
|
|
16
|
+
return 'needs-you';
|
|
17
|
+
if (task.status === 'ready')
|
|
18
|
+
return 'ready';
|
|
19
|
+
return 'in-progress';
|
|
20
|
+
}
|
|
21
|
+
/** The column a task sits in, with its title. */
|
|
22
|
+
export function placeOf(task) {
|
|
23
|
+
const id = columnOf(task);
|
|
24
|
+
return { id, title: COLUMNS.find((c) => c.id === id)?.title ?? id };
|
|
25
|
+
}
|
|
26
|
+
/** The board: every column, in a fixed order, its cards in the order Jig listed them. Computed on every read, never stored. */
|
|
27
|
+
export function columns(tasks) {
|
|
28
|
+
return COLUMNS.map((c) => ({ ...c, cards: tasks.filter((t) => columnOf(t) === c.id) }));
|
|
29
|
+
}
|
package/dist/http/app.js
CHANGED
|
@@ -4,8 +4,9 @@ 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 {
|
|
8
|
-
import {
|
|
7
|
+
import { streamSSE } from 'hono/streaming';
|
|
8
|
+
import { columns, placeOf } from '../board/columns.js';
|
|
9
|
+
import { RegistryUnreadable } from '../source/project-source.js';
|
|
9
10
|
import { localOnly, tokenAccess } from './access.js';
|
|
10
11
|
/**
|
|
11
12
|
* Reads the version from the package's own package.json. The relative path is the same from
|
|
@@ -18,6 +19,8 @@ export function readPackageVersion() {
|
|
|
18
19
|
}
|
|
19
20
|
/** The built page: `dist/web`, next to `dist/http` where this file runs when published. */
|
|
20
21
|
export const DEFAULT_WEB_ROOT = fileURLToPath(new URL('../web', import.meta.url));
|
|
22
|
+
/** How often an idle stream sends a comment, so a closed tab is noticed and its subscription ends. */
|
|
23
|
+
const PING_MS = 30_000;
|
|
21
24
|
const refuse = (c, status, code, message) => c.json({ error: { code, message } }, status);
|
|
22
25
|
/** The path the person typed, absolute; `~/` stands for their home. */
|
|
23
26
|
function absolute(path) {
|
|
@@ -26,7 +29,7 @@ function absolute(path) {
|
|
|
26
29
|
const expanded = path.trim().replace(/^~(?=$|[\\/])/, homedir());
|
|
27
30
|
return isAbsolute(expanded) ? expanded : undefined;
|
|
28
31
|
}
|
|
29
|
-
export function createApp({ port, token,
|
|
32
|
+
export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
|
|
30
33
|
const app = new Hono();
|
|
31
34
|
app.use(localOnly(port));
|
|
32
35
|
app.use(tokenAccess({ port, token }));
|
|
@@ -37,27 +40,60 @@ export function createApp({ port, token, configDir, version = readPackageVersion
|
|
|
37
40
|
return c.text('Internal Server Error', 500);
|
|
38
41
|
});
|
|
39
42
|
app.get('/api/instance', (c) => c.json({ version }));
|
|
40
|
-
app.get('/api/
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
app.get('/api/workspace', async (c) => c.json({ projects: await source.workspace() }));
|
|
44
|
+
app.get('/api/projects/:id/board', async (c) => {
|
|
45
|
+
const id = c.req.param('id');
|
|
46
|
+
const read = await source.tasks(id);
|
|
47
|
+
if (read === undefined)
|
|
48
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
49
|
+
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) => {
|
|
54
|
+
const read = await source.task(c.req.param('id'), c.req.param('task'));
|
|
55
|
+
if (read === undefined)
|
|
56
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
57
|
+
if (read.state !== 'ready')
|
|
58
|
+
return c.json({ task: read });
|
|
59
|
+
if (read.task === null)
|
|
60
|
+
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) => {
|
|
64
|
+
const read = await source.specs(c.req.param('id'));
|
|
65
|
+
if (read === undefined)
|
|
66
|
+
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
67
|
+
return c.json({ specs: read });
|
|
43
68
|
});
|
|
44
69
|
app.post('/api/projects', async (c) => {
|
|
45
70
|
const body = await c.req.json().catch(() => undefined);
|
|
46
71
|
const path = absolute(body?.path);
|
|
47
72
|
if (path === undefined)
|
|
48
73
|
return refuse(c, 422, 'usage', 'Give the absolute path of a project.');
|
|
49
|
-
const
|
|
50
|
-
if ('code' in
|
|
51
|
-
return refuse(c, 422,
|
|
52
|
-
|
|
53
|
-
return c.json({ project: await projectState(project) }, created ? 201 : 200);
|
|
74
|
+
const result = await source.connect(path);
|
|
75
|
+
if ('code' in result)
|
|
76
|
+
return refuse(c, 422, result.code, result.message);
|
|
77
|
+
return c.json({ project: result.project }, result.created ? 201 : 200);
|
|
54
78
|
});
|
|
55
79
|
app.delete('/api/projects/:id', async (c) => {
|
|
56
|
-
if (!(await
|
|
80
|
+
if (!(await source.disconnect(c.req.param('id')))) {
|
|
57
81
|
return refuse(c, 404, 'not-connected', 'No connected project has this id.');
|
|
58
82
|
}
|
|
59
83
|
return c.body(null, 204);
|
|
60
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) => {
|
|
87
|
+
const unsubscribe = source.subscribe(({ id, revision }) => {
|
|
88
|
+
void stream.writeSSE({ event: 'changed', data: JSON.stringify({ project: id, revision }) });
|
|
89
|
+
});
|
|
90
|
+
stream.onAbort(unsubscribe);
|
|
91
|
+
while (!stream.aborted) {
|
|
92
|
+
await stream.sleep(PING_MS);
|
|
93
|
+
if (!stream.aborted)
|
|
94
|
+
await stream.write(': ping\n\n');
|
|
95
|
+
}
|
|
96
|
+
}));
|
|
61
97
|
app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
|
|
62
98
|
if (existsSync(join(webRoot, 'index.html'))) {
|
|
63
99
|
app.use('/*', serveStatic({ root: webRoot }));
|
package/dist/jig/client.js
CHANGED
|
@@ -7,6 +7,16 @@ const run = promisify(execFile);
|
|
|
7
7
|
export const MIN_JIG_VERSION = '0.29.0';
|
|
8
8
|
const SCRIPT = join('.ai', 'scripts', 'jig');
|
|
9
9
|
const TIMEOUT_MS = 10_000;
|
|
10
|
+
/** The directory whose changes may change what Jig reports: the main checkout's `.ai/`. */
|
|
11
|
+
export const watchDir = (root) => join(root, '.ai');
|
|
12
|
+
/**
|
|
13
|
+
* True for a path under `.ai/` (with `/` separators) that Jig writes while it only reads — its
|
|
14
|
+
* records of what it is doing under `runtime/` — so a change there is no sign of a change, and
|
|
15
|
+
* watching it would make every read trigger the next. `runtime/revision/` is where the clone's
|
|
16
|
+
* revision rises on each Jig write, and stays a sign. Jig names no such path in its contract
|
|
17
|
+
* (Jig 0.29.0, found by watching which files its reads touch).
|
|
18
|
+
*/
|
|
19
|
+
export const writtenByReads = (path) => (path === 'runtime' || path.startsWith('runtime/')) && !path.startsWith('runtime/revision');
|
|
10
20
|
/**
|
|
11
21
|
* The bash that runs `.ai/scripts/jig`. On Windows it is Git's, found next to git itself
|
|
12
22
|
* (`<git>/mingw64/libexec/git-core` → `<git>/bin/bash.exe`): a `bash` on PATH may be WSL's.
|
|
@@ -34,6 +44,203 @@ export async function jigVersion(root, platform = process.platform) {
|
|
|
34
44
|
throw new Error('jig --version did not print a version');
|
|
35
45
|
return match[1];
|
|
36
46
|
}
|
|
47
|
+
/** Why a JSON read was refused: Jig's failure code (`failed` for one this Jig UI does not know, or a shape it cannot read). */
|
|
48
|
+
export class JigRefusal extends Error {
|
|
49
|
+
code;
|
|
50
|
+
constructor(code) {
|
|
51
|
+
super(`jig refused: ${code}`);
|
|
52
|
+
this.code = code;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const CODES = new Set(['usage', 'not-a-repository', 'not-initialised', 'failed']);
|
|
56
|
+
/** Jig answered in a shape this Jig UI does not read: a Jig newer than the contract its tests pin. */
|
|
57
|
+
export class JigShapeError extends Error {
|
|
58
|
+
constructor() {
|
|
59
|
+
super('jig answered in a shape this Jig UI does not read');
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
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
|
+
/**
|
|
66
|
+
* Runs a `--json` read and returns its `data` and the clone's revision. Jig's refusal throws
|
|
67
|
+
* JigRefusal, as does output that is no JSON at all (a crash); an envelope of another shape throws
|
|
68
|
+
* JigShapeError.
|
|
69
|
+
*/
|
|
70
|
+
async function readJson(root, args, platform) {
|
|
71
|
+
let stdout;
|
|
72
|
+
try {
|
|
73
|
+
stdout = await jig(root, [...args, '--json'], platform);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
// A refusal is an envelope too, printed before the non-zero exit.
|
|
77
|
+
stdout = error.stdout ?? '';
|
|
78
|
+
}
|
|
79
|
+
let envelope;
|
|
80
|
+
try {
|
|
81
|
+
envelope = JSON.parse(stdout);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new JigRefusal('failed');
|
|
85
|
+
}
|
|
86
|
+
if (!isObject(envelope) || envelope.schema !== 1 || typeof envelope.revision !== 'number')
|
|
87
|
+
throw new JigShapeError();
|
|
88
|
+
if (isObject(envelope.error)) {
|
|
89
|
+
const code = envelope.error.code;
|
|
90
|
+
throw new JigRefusal(isString(code) && CODES.has(code) ? code : 'failed');
|
|
91
|
+
}
|
|
92
|
+
if (!isObject(envelope.data))
|
|
93
|
+
throw new JigShapeError();
|
|
94
|
+
return { revision: envelope.revision, data: envelope.data };
|
|
95
|
+
}
|
|
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);
|
|
107
|
+
/** What needs the person in a project, most urgent first, and whether the pull request facts behind it may be missing. */
|
|
108
|
+
export async function readStatus(root, platform = process.platform) {
|
|
109
|
+
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
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const STATUSES = new Set(['active', 'ready', 'consolidated', 'abandoned']);
|
|
130
|
+
/** Jig's action ids this Jig UI knows; Jig may add ids without raising `schema`, and a consumer ignores those. */
|
|
131
|
+
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
|
+
}
|
|
184
|
+
/** The project's live tasks (`jig task list --json`) and how many finished ones it left out. */
|
|
185
|
+
export async function readTasks(root, platform = process.platform) {
|
|
186
|
+
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 };
|
|
190
|
+
}
|
|
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 });
|
|
218
|
+
/** The project's specs (`jig spec list --json`), in Jig's order. */
|
|
219
|
+
export async function readSpecs(root, platform = process.platform) {
|
|
220
|
+
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
|
+
}));
|
|
243
|
+
}
|
|
37
244
|
/** True when `version` is at least the minimum Jig UI reads. */
|
|
38
245
|
export function supports(version, minimum = MIN_JIG_VERSION) {
|
|
39
246
|
const a = version.split('.').map(Number);
|
package/dist/main.js
CHANGED
|
@@ -6,7 +6,9 @@ import { DEFAULT_PORT, parseCliArgs } from './args.js';
|
|
|
6
6
|
import { createApp, readPackageVersion } from './http/app.js';
|
|
7
7
|
import { openBrowser } from './open-browser.js';
|
|
8
8
|
import { configDir } from './registry/config-dir.js';
|
|
9
|
-
import { probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
|
|
9
|
+
import { connectThrough, probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
|
|
10
|
+
import { mainCheckout } from './source/checkout.js';
|
|
11
|
+
import { localSource } from './source/local.js';
|
|
10
12
|
const HOST = '127.0.0.1';
|
|
11
13
|
const HELP = `Usage: jig-ui [options]
|
|
12
14
|
|
|
@@ -20,12 +22,40 @@ function fail(message) {
|
|
|
20
22
|
console.error(message);
|
|
21
23
|
process.exit(1);
|
|
22
24
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// The token swap redirects to `/` with no fragment, so the browser keeps this one: the board opens.
|
|
26
|
+
const link = ({ port, token }, project) => `http://${HOST}:${port}/?token=${token}${project ? `#/projects/${project}` : ''}`;
|
|
27
|
+
async function show(info, open, dir, project) {
|
|
28
|
+
console.log(`Open it at ${link(info, project)}`);
|
|
29
|
+
if (open && !(await openBrowser(link(info, project), dir)))
|
|
27
30
|
console.log('Could not open a browser; open the link above.');
|
|
28
31
|
}
|
|
32
|
+
/** The folder this command runs in, as a checkout to connect: a worktree stands for its clone's main checkout. */
|
|
33
|
+
async function here() {
|
|
34
|
+
const checkout = await mainCheckout(process.cwd());
|
|
35
|
+
if (checkout.kind === 'main')
|
|
36
|
+
return { path: checkout.root, worktree: false };
|
|
37
|
+
// A bare clone's worktree has no main checkout: nothing to connect, said as outside a project.
|
|
38
|
+
if (checkout.kind === 'worktree' && checkout.main)
|
|
39
|
+
return { path: checkout.main, worktree: true };
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Connects the folder through the instance and returns the project's id. Outside a Jig project it says
|
|
44
|
+
* nothing: the command then only starts, as it always did.
|
|
45
|
+
*/
|
|
46
|
+
async function connectHere(info, place) {
|
|
47
|
+
if (!place)
|
|
48
|
+
return undefined;
|
|
49
|
+
const result = await connectThrough(info, place.path);
|
|
50
|
+
if ('id' in result) {
|
|
51
|
+
const what = place.worktree ? `the main checkout of this worktree, ${place.path}` : place.path;
|
|
52
|
+
console.log(result.created ? `Connected ${result.id}: ${what}.` : `${result.id} is connected: ${what}.`);
|
|
53
|
+
return result.id;
|
|
54
|
+
}
|
|
55
|
+
if (result.code !== 'not-a-repository' && result.code !== 'no-jig')
|
|
56
|
+
console.log(`Could not connect this folder: ${result.message}`);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
29
59
|
/** The instance recorded in the config directory, if it still answers with its token. */
|
|
30
60
|
async function runningInstance(dir) {
|
|
31
61
|
const info = await readInstance(dir);
|
|
@@ -55,22 +85,23 @@ export async function main() {
|
|
|
55
85
|
}
|
|
56
86
|
const { port, open } = command;
|
|
57
87
|
const dir = configDir(process.env, process.platform, homedir());
|
|
88
|
+
const place = await here();
|
|
58
89
|
const running = await runningInstance(dir);
|
|
59
90
|
if (running) {
|
|
60
91
|
console.log(`Jig UI is already running on port ${running.port}.`);
|
|
61
92
|
// ponytail: a --port equal to the default is indistinguishable from no flag; no note then.
|
|
62
93
|
if (port !== running.port && port !== DEFAULT_PORT)
|
|
63
94
|
console.log(`--port ${port} is ignored.`);
|
|
64
|
-
await show(running, open, dir);
|
|
95
|
+
await show(running, open, dir, await connectHere(running, place));
|
|
65
96
|
return;
|
|
66
97
|
}
|
|
67
98
|
const version = readPackageVersion();
|
|
68
99
|
const token = randomBytes(32).toString('base64url');
|
|
69
|
-
const server = serve({ fetch: createApp({ port, token,
|
|
100
|
+
const server = serve({ fetch: createApp({ port, token, source: localSource(dir), version }).fetch, hostname: HOST, port }, () => {
|
|
70
101
|
void (async () => {
|
|
71
102
|
await writeInstance(dir, { port, token, pid: process.pid, version });
|
|
72
103
|
console.log(`Jig UI ${version} is running at http://${HOST}:${port}/`);
|
|
73
|
-
await show({ port, token }, open, dir);
|
|
104
|
+
await show({ port, token }, open, dir, await connectHere({ port, token }, place));
|
|
74
105
|
console.log('Press Ctrl+C to stop.');
|
|
75
106
|
})().catch((error) => fail(`Could not start: ${error instanceof Error ? error.message : String(error)}`));
|
|
76
107
|
});
|
|
@@ -82,7 +113,7 @@ export async function main() {
|
|
|
82
113
|
if (!winner)
|
|
83
114
|
fail(`Port ${port} is already in use. Stop the other process or choose another port with --port.`);
|
|
84
115
|
console.log(`Jig UI is already running on port ${winner.port}.`);
|
|
85
|
-
await show(winner, open, dir);
|
|
116
|
+
await show(winner, open, dir, await connectHere(winner, place));
|
|
86
117
|
process.exit(0);
|
|
87
118
|
});
|
|
88
119
|
});
|
package/dist/node-version.js
CHANGED
|
@@ -8,7 +8,6 @@ export function checkNodeVersion(version) {
|
|
|
8
8
|
}
|
|
9
9
|
return {
|
|
10
10
|
ok: false,
|
|
11
|
-
message: 'Jig UI needs Node.js ' + MINIMUM_NODE_MAJOR + ' or later, but this is ' + version +
|
|
12
|
-
'. Install a newer Node.js and run it again.',
|
|
11
|
+
message: 'Jig UI needs Node.js ' + MINIMUM_NODE_MAJOR + ' or later, but this is ' + version + '. Install a newer Node.js and run it again.',
|
|
13
12
|
};
|
|
14
13
|
}
|
|
@@ -48,3 +48,24 @@ export async function probeInstance(info, timeoutMs = 1000) {
|
|
|
48
48
|
return false;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
+
/** Asks the instance to connect `path`, as the page does: the instance owns the registry, so no second process writes it. */
|
|
52
|
+
export async function connectThrough(info, path, timeoutMs = 15_000) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/projects`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { cookie: `${cookieName(info.port)}=${info.token}`, 'content-type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ path }),
|
|
58
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
59
|
+
});
|
|
60
|
+
// A failure the instance did not foresee answers in plain text.
|
|
61
|
+
const body = (await res.json().catch(() => ({})));
|
|
62
|
+
if (body.project)
|
|
63
|
+
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}.` };
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return { code: 'unreachable', message: error instanceof Error ? error.message : String(error) };
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/source/checkout.js
CHANGED
|
@@ -21,5 +21,23 @@ export async function mainCheckout(path) {
|
|
|
21
21
|
const [top, own, common] = stdout.trim().split(/\r?\n/);
|
|
22
22
|
if (!top || !own || !common)
|
|
23
23
|
return { kind: 'not-a-repository' };
|
|
24
|
-
|
|
24
|
+
if ((await realpath(own)) === (await realpath(common)))
|
|
25
|
+
return { kind: 'main', root: await realpath(top) };
|
|
26
|
+
return { kind: 'worktree', main: await mainWorktree(path) };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The main working tree, which `git worktree list` names first — not `dirname(--git-common-dir)`, which
|
|
30
|
+
* misses for a worktree of a submodule. A bare clone's first entry is the repository itself.
|
|
31
|
+
*/
|
|
32
|
+
async function mainWorktree(path) {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await run('git', ['worktree', 'list', '--porcelain'], { cwd: path, timeout: 10_000 });
|
|
35
|
+
const [first, second] = stdout.split(/\r?\n/);
|
|
36
|
+
if (!first?.startsWith('worktree ') || second === 'bare')
|
|
37
|
+
return null;
|
|
38
|
+
return await realpath(first.slice('worktree '.length));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
25
43
|
}
|