@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.
@@ -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 { addProject, readProjects, RegistryUnreadable, removeProject } from '../registry/projects.js';
8
- import { connectable, projectState } from '../source/local.js';
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, configDir, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
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/projects', async (c) => {
41
- const projects = await readProjects(configDir);
42
- return c.json({ projects: await Promise.all(projects.map(projectState)) });
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 found = await connectable(path);
50
- if ('code' in found)
51
- return refuse(c, 422, found.code, found.message);
52
- const { project, created } = await addProject(configDir, found.root);
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 removeProject(configDir, c.req.param('id')))) {
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 }));
@@ -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
- const link = ({ port, token }) => `http://${HOST}:${port}/?token=${token}`;
24
- async function show(info, open, dir) {
25
- console.log(`Open it at ${link(info)}`);
26
- if (open && !(await openBrowser(link(info), dir)))
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, configDir: dir, version }).fetch, hostname: HOST, port }, () => {
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
  });
@@ -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
+ }
@@ -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
- return (await realpath(own)) === (await realpath(common)) ? { kind: 'main', root: await realpath(top) } : { kind: 'worktree' };
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
  }