@fapost/jig-ui 0.3.1-next.61 → 0.3.1-next.64

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/args.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { parseArgs } from 'node:util';
2
2
  export const DEFAULT_PORT = 4870;
3
+ const SUBCOMMANDS = ['start', 'stop', 'status', 'open', 'serve'];
4
+ /** Subcommands that listen on a port; the others only talk to the instance that does. */
5
+ const TAKES_PORT = new Set(['run', 'start', 'serve']);
3
6
  /** Parses the command line (without the node and script entries). Pure: it never exits or prints. */
4
7
  export function parseCliArgs(argv) {
8
+ let positionals;
5
9
  let values;
6
10
  try {
7
- ({ values } = parseArgs({
11
+ ({ values, positionals } = parseArgs({
8
12
  args: argv,
9
13
  strict: true,
10
- allowPositionals: false,
14
+ allowPositionals: true,
11
15
  options: {
12
16
  port: { type: 'string' },
13
17
  'no-open': { type: 'boolean' },
@@ -23,12 +27,35 @@ export function parseCliArgs(argv) {
23
27
  return { kind: 'help' };
24
28
  if (values.version)
25
29
  return { kind: 'version' };
26
- const open = !values['no-open'];
27
- if (values.port === undefined)
28
- return { kind: 'run', port: DEFAULT_PORT, open };
29
- const port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN;
30
- if (!Number.isInteger(port) || port < 1 || port > 65535) {
31
- return { kind: 'error', message: `Invalid --port value: ${values.port}. Use a number from 1 to 65535.` };
30
+ const [name, ...rest] = positionals;
31
+ if (rest.length > 0)
32
+ return { kind: 'error', message: `Unexpected argument: ${rest[0]}` };
33
+ if (name !== undefined && !SUBCOMMANDS.includes(name)) {
34
+ return { kind: 'error', message: `Unknown command: ${name}. Run jig-ui --help.` };
35
+ }
36
+ const command = name ?? 'run';
37
+ if (values.port !== undefined && !TAKES_PORT.has(command)) {
38
+ return { kind: 'error', message: `--port does not apply to ${command}.` };
39
+ }
40
+ if (values['no-open'] && command !== 'run') {
41
+ return { kind: 'error', message: `--no-open does not apply to ${command}.` };
42
+ }
43
+ let port = DEFAULT_PORT;
44
+ if (values.port !== undefined) {
45
+ port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN;
46
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
47
+ return { kind: 'error', message: `Invalid --port value: ${values.port}. Use a number from 1 to 65535.` };
48
+ }
49
+ }
50
+ switch (command) {
51
+ case 'start':
52
+ case 'serve':
53
+ return { kind: command, port };
54
+ case 'stop':
55
+ case 'status':
56
+ case 'open':
57
+ return { kind: command };
58
+ default:
59
+ return { kind: 'run', port, open: !values['no-open'] };
32
60
  }
33
- return { kind: 'run', port, open };
34
61
  }
package/dist/http/app.js CHANGED
@@ -79,9 +79,16 @@ function upgradeRefused(c, code) {
79
79
  * The API, chained so that its type carries every route's answers: the page derives its types
80
80
  * from `AppType` and never declares them a second time.
81
81
  */
82
- function apiRoutes(source, version) {
82
+ function apiRoutes(source, version, stop) {
83
83
  return (new Hono()
84
84
  .get('/api/instance', (c) => c.json({ version }, 200))
85
+ .post('/api/instance/stop', (c) => {
86
+ if (!stop)
87
+ return refuse(c, 404, 'not-found', 'This instance cannot be stopped from here.');
88
+ // After the reply is written, or the caller sees a cut connection instead of the 202.
89
+ setTimeout(stop, 50);
90
+ return c.json({ stopping: true }, 202);
91
+ })
85
92
  .get('/api/workspace', async (c) => c.json({ projects: await source.workspace(), machine: source.machine() }, 200))
86
93
  // Moves this machine's Jig to its newest release and answers once it has: a git fetch, not minutes.
87
94
  .post('/api/self-update', async (c) => {
@@ -283,7 +290,7 @@ function apiRoutes(source, version) {
283
290
  }
284
291
  })));
285
292
  }
286
- export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT }) {
293
+ export function createApp({ port, token, source, version = readPackageVersion(), webRoot = DEFAULT_WEB_ROOT, stop }) {
287
294
  const app = new Hono();
288
295
  // Before the guard, so its refusals carry them too; HSTS means nothing on http://127.0.0.1.
289
296
  app.use(secureHeaders({ strictTransportSecurity: false }));
@@ -299,7 +306,7 @@ export function createApp({ port, token, source, version = readPackageVersion(),
299
306
  console.error(error);
300
307
  return refuse(c, 500, 'internal', 'Jig UI hit an error it did not expect; its terminal says what.');
301
308
  });
302
- app.route('/', apiRoutes(source, version));
309
+ app.route('/', apiRoutes(source, version, stop));
303
310
  app.all('/api/*', (c) => c.json({ error: 'not found' }, 404));
304
311
  if (existsSync(join(webRoot, 'index.html'))) {
305
312
  app.use('/*', serveStatic({ root: webRoot }));
@@ -176,6 +176,16 @@ const Attention = z
176
176
  .transform((v) => v ?? null),
177
177
  })
178
178
  .refine((a) => a.kind !== 'upgrade-available' || a.versions !== null);
179
+ /**
180
+ * The release Jig's `release-available` item names, as `0.38.0`; none for another item. Jig gives it only in the title
181
+ * (`jig v0.38.0 is out`), and knows of it per project — from that project's last `jig housekeeping` — so one project
182
+ * may name a release another has not heard of yet.
183
+ */
184
+ export function releaseNamed(item) {
185
+ if (item.kind !== 'release-available')
186
+ return undefined;
187
+ return /\bv(\d+\.\d+\.\d+)\b/.exec(item.title)?.[1];
188
+ }
179
189
  const Status = z.object({
180
190
  attention: z.array(Attention),
181
191
  housekeeping: z.object({ stale: z.boolean() }).nullable(),
package/dist/main.js CHANGED
@@ -8,16 +8,32 @@ import { openBrowser } from './open-browser.js';
8
8
  import { configDir } from './registry/config-dir.js';
9
9
  import { connectThrough, probeInstance, readInstance, removeInstance, writeInstance } from './registry/instance.js';
10
10
  import { mainCheckout } from './source/checkout.js';
11
+ import { instanceStatus, startInstance, stopInstance } from './service.js';
11
12
  import { localSource } from './source/local.js';
12
13
  const HOST = '127.0.0.1';
13
14
  const HELP = `Usage: jig-ui [options]
15
+ jig-ui start [--port <n>]
16
+ jig-ui stop | status | open
17
+
18
+ Run without a command, Jig UI runs in this terminal and opens the browser.
19
+
20
+ Commands:
21
+ start Start Jig UI in the background
22
+ stop Stop the running Jig UI
23
+ status Say whether Jig UI is running (exit code 3 when it is not)
24
+ open Open the running Jig UI in the browser
25
+ serve Run in this terminal without a browser or a folder: what start runs
14
26
 
15
27
  Options:
16
- --port <n> Port to listen on (default 4870)
17
- --no-open Print the link instead of opening the browser
28
+ --port <n> Port to listen on (default 4870); run, start and serve
29
+ --no-open Print the link instead of opening the browser; run only
18
30
  -v, --version Print the version and exit
19
31
  -h, --help Print this help and exit
20
32
  `;
33
+ /** The exit code of `status` and `open` when Jig UI is not running. */
34
+ const NOT_RUNNING = 3;
35
+ /** An instance whose port stays silent: maybe busy, so nothing is removed or started in its place. */
36
+ const silent = ({ port, pid }) => `Jig UI on port ${port} (pid ${pid}) does not answer. Try again; if it stays silent, end that process yourself.`;
21
37
  function fail(message) {
22
38
  console.error(message);
23
39
  process.exit(1);
@@ -71,36 +87,40 @@ async function winnerOfRace(dir) {
71
87
  }
72
88
  return undefined;
73
89
  }
74
- export async function main() {
75
- const command = parseCliArgs(process.argv.slice(2));
76
- if (command.kind === 'error')
77
- fail(command.message);
78
- if (command.kind === 'help') {
79
- process.stdout.write(HELP);
80
- return;
81
- }
82
- if (command.kind === 'version') {
83
- console.log(readPackageVersion());
84
- return;
85
- }
86
- const { port, open } = command;
87
- const dir = configDir(process.env, process.platform, homedir());
88
- const place = await here();
90
+ /**
91
+ * Runs the server in this process. `run` also connects the folder and opens the browser; `serve`
92
+ * (what `start` runs in the background) does neither and keeps quiet about Ctrl+C.
93
+ */
94
+ async function runServer(command, dir) {
95
+ const { port } = command;
96
+ const foreground = command.kind === 'run';
97
+ const open = foreground && command.open;
98
+ const place = foreground ? await here() : undefined;
89
99
  const running = await runningInstance(dir);
90
100
  if (running) {
91
101
  console.log(`Jig UI is already running on port ${running.port}.`);
92
102
  // ponytail: a --port equal to the default is indistinguishable from no flag; no note then.
93
103
  if (port !== running.port && port !== DEFAULT_PORT)
94
104
  console.log(`--port ${port} is ignored.`);
95
- await show(running, open, dir, await connectHere(running, place));
105
+ if (foreground)
106
+ await show(running, open, dir, await connectHere(running, place));
96
107
  return;
97
108
  }
98
109
  const version = readPackageVersion();
99
110
  const token = randomBytes(32).toString('base64url');
100
- const server = serve({ fetch: createApp({ port, token, source: localSource(dir), version }).fetch, hostname: HOST, port }, () => {
111
+ const shutdown = () => {
112
+ void removeInstance(dir, token).finally(() => {
113
+ server.close(() => process.exit(0));
114
+ if ('closeAllConnections' in server)
115
+ server.closeAllConnections();
116
+ });
117
+ };
118
+ const server = serve({ fetch: createApp({ port, token, source: localSource(dir), version, stop: shutdown }).fetch, hostname: HOST, port }, () => {
101
119
  void (async () => {
102
120
  await writeInstance(dir, { port, token, pid: process.pid, version });
103
121
  console.log(`Jig UI ${version} is running at http://${HOST}:${port}/`);
122
+ if (!foreground)
123
+ return;
104
124
  await show({ port, token }, open, dir, await connectHere({ port, token }, place));
105
125
  console.log('Press Ctrl+C to stop.');
106
126
  })().catch((error) => fail(`Could not start: ${error instanceof Error ? error.message : String(error)}`));
@@ -113,17 +133,104 @@ export async function main() {
113
133
  if (!winner)
114
134
  fail(`Port ${port} is already in use. Stop the other process or choose another port with --port.`);
115
135
  console.log(`Jig UI is already running on port ${winner.port}.`);
116
- await show(winner, open, dir, await connectHere(winner, place));
136
+ if (foreground)
137
+ await show(winner, open, dir, await connectHere(winner, place));
117
138
  process.exit(0);
118
139
  });
119
140
  });
120
- const shutdown = () => {
121
- void removeInstance(dir, token).finally(() => {
122
- server.close(() => process.exit(0));
123
- if ('closeAllConnections' in server)
124
- server.closeAllConnections();
125
- });
126
- };
127
141
  process.on('SIGINT', shutdown);
128
142
  process.on('SIGTERM', shutdown);
129
143
  }
144
+ async function startBackground(port, dir) {
145
+ const result = await startInstance(dir, port);
146
+ if (result.kind === 'failed') {
147
+ console.error(`Could not start Jig UI. The end of ${result.log}:\n${result.tail}`);
148
+ process.exit(1);
149
+ }
150
+ if (result.kind === 'not-answering')
151
+ fail(silent(result.info));
152
+ if (result.kind === 'already-running') {
153
+ console.log(`Jig UI is already running on port ${result.info.port}.`);
154
+ if (port !== result.info.port && port !== DEFAULT_PORT)
155
+ console.log(`--port ${port} is ignored.`);
156
+ console.log(`Open it at ${link(result.info)}`);
157
+ return;
158
+ }
159
+ console.log(`Jig UI ${result.info.version} is running at http://${HOST}:${result.info.port}/ in the background.`);
160
+ console.log(`Open it at ${link(result.info)}`);
161
+ console.log(`Log: ${result.log}`);
162
+ }
163
+ async function stopBackground(dir) {
164
+ const result = await stopInstance(dir);
165
+ switch (result.kind) {
166
+ case 'not-running':
167
+ console.log('Jig UI is not running.');
168
+ return;
169
+ case 'stale':
170
+ console.log(`Jig UI is not running: the file of an instance on port ${result.info.port} was left behind and is removed. No process was signalled.`);
171
+ return;
172
+ case 'stopped':
173
+ console.log(`Stopped Jig UI on port ${result.info.port}.`);
174
+ return;
175
+ case 'not-answering':
176
+ console.error(silent(result.info));
177
+ process.exitCode = 1;
178
+ return;
179
+ case 'timeout':
180
+ console.error(`Jig UI on port ${result.info.port} (pid ${result.info.pid}) did not stop in time.`);
181
+ process.exitCode = 1;
182
+ }
183
+ }
184
+ async function statusOf(dir) {
185
+ const status = await instanceStatus(dir);
186
+ if (status.kind === 'running') {
187
+ const { info } = status;
188
+ console.log(`Jig UI ${info.version} is running at http://${HOST}:${info.port}/ (pid ${info.pid}).`);
189
+ console.log(`Open it at ${link(info)}`);
190
+ return;
191
+ }
192
+ if (status.kind === 'not-answering') {
193
+ console.error(silent(status.info));
194
+ process.exitCode = 1;
195
+ return;
196
+ }
197
+ console.log(status.kind === 'stale'
198
+ ? `Jig UI is not running; the file of an instance on port ${status.info.port} was left behind. jig-ui stop removes it.`
199
+ : 'Jig UI is not running. Start it with jig-ui start.');
200
+ process.exitCode = NOT_RUNNING;
201
+ }
202
+ async function openRunning(dir) {
203
+ const status = await instanceStatus(dir);
204
+ if (status.kind === 'running')
205
+ return show(status.info, true, dir);
206
+ if (status.kind === 'not-answering')
207
+ fail(silent(status.info));
208
+ console.log('Jig UI is not running. Start it with jig-ui start.');
209
+ process.exitCode = NOT_RUNNING;
210
+ }
211
+ export async function main() {
212
+ const command = parseCliArgs(process.argv.slice(2));
213
+ if (command.kind === 'error')
214
+ fail(command.message);
215
+ if (command.kind === 'help') {
216
+ process.stdout.write(HELP);
217
+ return;
218
+ }
219
+ if (command.kind === 'version') {
220
+ console.log(readPackageVersion());
221
+ return;
222
+ }
223
+ const dir = configDir(process.env, process.platform, homedir());
224
+ switch (command.kind) {
225
+ case 'start':
226
+ return startBackground(command.port, dir);
227
+ case 'stop':
228
+ return stopBackground(dir);
229
+ case 'status':
230
+ return statusOf(dir);
231
+ case 'open':
232
+ return openRunning(dir);
233
+ default:
234
+ return runServer(command, dir);
235
+ }
236
+ }
@@ -35,21 +35,37 @@ export async function removeInstance(dir, token) {
35
35
  await rm(join(dir, OPEN_FILE), { force: true });
36
36
  await rm(join(dir, FILE), { force: true });
37
37
  }
38
+ const causeCode = (error) => error.cause?.code;
39
+ /** A connection refused outright: nothing listens on the port. */
40
+ const refused = (error) => causeCode(error) === 'ECONNREFUSED';
41
+ export async function askInstance(info, timeoutMs = 1000) {
42
+ for (let attempt = 0;; attempt++) {
43
+ try {
44
+ const res = await fetch(`http://127.0.0.1:${info.port}/api/instance`, {
45
+ headers: { cookie: `${cookieName(info.port)}=${info.token}` },
46
+ signal: AbortSignal.timeout(timeoutMs),
47
+ });
48
+ // An unread body holds the connection, and the next probe of a stop's wait would queue behind it.
49
+ await res.body?.cancel();
50
+ return res.ok ? 'up' : 'gone';
51
+ }
52
+ catch (error) {
53
+ if (refused(error))
54
+ return 'gone';
55
+ // A kept-alive connection to a server that has since stopped breaks (reset, or closed as it
56
+ // is used, as on Linux); only a timeout means silence, and a new connection tells the rest.
57
+ if (error.name !== 'TimeoutError' && attempt === 0)
58
+ continue;
59
+ return 'silent';
60
+ }
61
+ }
62
+ }
38
63
  /** True when an instance answers on its port with its token; a crash leaves the file behind. */
39
64
  export async function probeInstance(info, timeoutMs = 1000) {
40
- try {
41
- const res = await fetch(`http://127.0.0.1:${info.port}/api/instance`, {
42
- headers: { cookie: `${cookieName(info.port)}=${info.token}` },
43
- signal: AbortSignal.timeout(timeoutMs),
44
- });
45
- return res.ok;
46
- }
47
- catch {
48
- return false;
49
- }
65
+ return (await askInstance(info, timeoutMs)) === 'up';
50
66
  }
51
67
  // An instance before 0.2.0 answers a failure it did not foresee in plain text: neither of these.
52
- const Answer = z.union([
68
+ const Reply = z.union([
53
69
  z.object({ project: z.object({ id: z.string() }) }),
54
70
  z.object({ error: z.object({ code: z.string(), message: z.string() }) }),
55
71
  ]);
@@ -62,7 +78,7 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
62
78
  body: JSON.stringify({ path }),
63
79
  signal: AbortSignal.timeout(timeoutMs),
64
80
  });
65
- const body = Answer.safeParse(await res.json().catch(() => undefined)).data;
81
+ const body = Reply.safeParse(await res.json().catch(() => undefined)).data;
66
82
  if (body && 'project' in body)
67
83
  return { id: body.project.id, created: res.status === 201 };
68
84
  return body?.error ?? { code: 'unexpected', message: `Jig UI answered ${res.status}.` };
@@ -71,3 +87,19 @@ export async function connectThrough(info, path, timeoutMs = 15_000) {
71
87
  return { code: 'unreachable', message: error instanceof Error ? error.message : String(error) };
72
88
  }
73
89
  }
90
+ /** Asks the instance to shut down, as `connectThrough` asks it to connect: the token proves the instance is ours. */
91
+ export async function requestStop(info, timeoutMs = 5000) {
92
+ try {
93
+ const res = await fetch(`http://127.0.0.1:${info.port}/api/instance/stop`, {
94
+ method: 'POST',
95
+ headers: { cookie: `${cookieName(info.port)}=${info.token}` },
96
+ signal: AbortSignal.timeout(timeoutMs),
97
+ });
98
+ await res.body?.cancel();
99
+ return res.status === 404 ? 'unsupported' : 'stopping';
100
+ }
101
+ catch (error) {
102
+ // A timeout or reset after the request was sent may still be a server going down; a refusal is not.
103
+ return refused(error) ? 'unreachable' : 'stopping';
104
+ }
105
+ }
@@ -0,0 +1,130 @@
1
+ import { spawn as spawnProcess } from 'node:child_process';
2
+ import { mkdir, open, readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { setTimeout as sleep } from 'node:timers/promises';
6
+ import { askInstance, readInstance, removeInstance, requestStop, } from './registry/instance.js';
7
+ /** The log of the background instance; overwritten at each start, but it grows while the instance runs (task server-log-rotation). */
8
+ export const LOG_FILE = 'server.log';
9
+ /** How long the instance gets to answer a probe; one that stays silent is busy, never stale. */
10
+ const PROBE_MS = 3000;
11
+ /** How long a start waits for the new instance, and a stop for the old one to go. */
12
+ const WAIT_MS = 10_000;
13
+ const POLL_MS = 100;
14
+ const TAIL_LINES = 20;
15
+ /** The program that runs the instance: this very CLI, next to this file in `dist` and in `server`. */
16
+ const CLI_PATH = fileURLToPath(new URL('./cli.js', import.meta.url));
17
+ export const realDeps = {
18
+ probe: askInstance,
19
+ requestStop: (info) => requestStop(info),
20
+ kill: (pid, signal) => process.kill(pid, signal),
21
+ spawn(command, args, { cwd, fd }) {
22
+ const child = spawnProcess(command, args, { detached: true, stdio: ['ignore', fd, fd], windowsHide: true, cwd });
23
+ const exit = new Promise((resolve) => {
24
+ // A process that cannot start never exits and writes no log: its error is all there is to say.
25
+ child.on('error', (error) => resolve({ code: -1, error: error.message }));
26
+ child.on('exit', (code) => resolve({ code }));
27
+ });
28
+ return { exit, unref: () => child.unref(), kill: () => child.kill() };
29
+ },
30
+ delay: (ms) => sleep(ms),
31
+ now: () => Date.now(),
32
+ };
33
+ /**
34
+ * Reads the instance file and asks the instance. A file whose port refuses, or answers as someone
35
+ * else, is stale; one whose port stays silent is `not-answering`: maybe alive and busy. It deletes nothing.
36
+ */
37
+ export async function instanceStatus(dir, deps = realDeps) {
38
+ const info = await readInstance(dir);
39
+ if (!info)
40
+ return { kind: 'not-running' };
41
+ const answer = await deps.probe(info, PROBE_MS);
42
+ return { kind: answer === 'up' ? 'running' : answer === 'gone' ? 'stale' : 'not-answering', info };
43
+ }
44
+ async function logTail(log) {
45
+ try {
46
+ return (await readFile(log, 'utf8')).trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
47
+ }
48
+ catch {
49
+ return '';
50
+ }
51
+ }
52
+ /**
53
+ * Starts the instance in the background and waits until it answers. The child is the CLI itself
54
+ * as `serve`: detached from this terminal, its output in the log, its working directory the
55
+ * config directory, so it holds no folder of the person's.
56
+ */
57
+ export async function startInstance(dir, port, deps = realDeps) {
58
+ const status = await instanceStatus(dir, deps);
59
+ if (status.kind === 'running')
60
+ return { kind: 'already-running', info: status.info };
61
+ // A second server would only lose the port to it, and the log would be overwritten for nothing.
62
+ if (status.kind === 'not-answering')
63
+ return status;
64
+ await mkdir(dir, { recursive: true, mode: 0o700 });
65
+ const log = join(dir, LOG_FILE);
66
+ const handle = await open(log, 'w', 0o600);
67
+ let child;
68
+ try {
69
+ child = deps.spawn(process.execPath, [CLI_PATH, 'serve', '--port', String(port)], { cwd: dir, fd: handle.fd });
70
+ }
71
+ finally {
72
+ // The child has its own copy of the descriptor.
73
+ await handle.close();
74
+ }
75
+ child.unref();
76
+ let ended;
77
+ void child.exit.then((value) => {
78
+ ended = value;
79
+ });
80
+ const deadline = deps.now() + WAIT_MS;
81
+ while (deps.now() < deadline) {
82
+ await deps.delay(POLL_MS);
83
+ const info = await readInstance(dir);
84
+ if (info && (await deps.probe(info, PROBE_MS)) === 'up')
85
+ return { kind: 'started', info, log };
86
+ // A child that exited with 0 found another instance already there: the next poll sees it.
87
+ if (ended && ended.code !== 0)
88
+ break;
89
+ }
90
+ // Too slow is a failure too: end the child rather than let it come up after we said it did not.
91
+ if (!ended)
92
+ child.kill();
93
+ return { kind: 'failed', log, tail: ended?.error ?? (await logTail(log)) };
94
+ }
95
+ /**
96
+ * Stops the instance the file names. An instance that answers is asked over HTTP, with its token:
97
+ * that works on every system and cannot hit a process that merely reused the pid. A stale file
98
+ * only goes away, and no signal is sent, because its pid may belong to anything by now. A silent
99
+ * port changes nothing: removing the file of a busy instance would leave it running unseen.
100
+ */
101
+ export async function stopInstance(dir, deps = realDeps) {
102
+ const status = await instanceStatus(dir, deps);
103
+ if (status.kind === 'not-running' || status.kind === 'not-answering')
104
+ return status;
105
+ const { info } = status;
106
+ if (status.kind === 'stale') {
107
+ await removeInstance(dir, info.token);
108
+ return status;
109
+ }
110
+ const requested = await deps.requestStop(info);
111
+ // An instance from before the endpoint existed: the probe just proved that its pid is the one that answers.
112
+ if (requested === 'unsupported') {
113
+ try {
114
+ deps.kill(info.pid, 'SIGTERM');
115
+ }
116
+ catch {
117
+ // Gone between the probe and the signal: the wait below sees it.
118
+ }
119
+ }
120
+ const deadline = deps.now() + WAIT_MS;
121
+ while (deps.now() < deadline) {
122
+ if ((await deps.probe(info, PROBE_MS)) === 'gone') {
123
+ // The server removes its files itself; this covers a hard exit.
124
+ await removeInstance(dir, info.token);
125
+ return { kind: 'stopped', info };
126
+ }
127
+ await deps.delay(POLL_MS);
128
+ }
129
+ return { kind: 'timeout', info };
130
+ }
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { cancelPause, fileTask, hasJig, JigRefusal, JigShapeError, jigVersion, MIN_JIG_VERSION, moveInQueue, planUpgrade, readQueue, readSpecs, readStatus, readTasks, requestPause, RESUME_JIG_VERSION, resumeTask, runUpgrade, selfUpdate as runSelfUpdate, setPriority, supports, watchDir, writtenByReads, } from '../jig/client.js';
2
+ import { cancelPause, fileTask, hasJig, JigRefusal, JigShapeError, jigVersion, MIN_JIG_VERSION, moveInQueue, planUpgrade, readQueue, readSpecs, readStatus, readTasks, releaseNamed, requestPause, RESUME_JIG_VERSION, resumeTask, runUpgrade, selfUpdate as runSelfUpdate, setPriority, supports, watchDir, writtenByReads, } from '../jig/client.js';
3
3
  import { addProject, readProjects, removeProject } from '../registry/projects.js';
4
4
  import { watchTree } from '../watch/watch.js';
5
5
  import { mainCheckout } from './checkout.js';
@@ -169,6 +169,22 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
169
169
  }
170
170
  /** The runs that change this machine's Jig or every project, kept until dismissed or the next one starts. */
171
171
  const machine = { selfUpdate: null, upgradeAll: null };
172
+ /** The newest release any connected project's last read names. */
173
+ function newestRelease() {
174
+ let newest = null;
175
+ for (const entry of entries.values()) {
176
+ const read = entry.shown;
177
+ if (read?.view.state !== 'ready')
178
+ continue;
179
+ for (const item of read.view.attention) {
180
+ const named = releaseNamed(item);
181
+ // `supports(a, b)` is a ≥ b.
182
+ if (named && (newest === null || !supports(newest, named)))
183
+ newest = named;
184
+ }
185
+ }
186
+ return newest;
187
+ }
172
188
  const listeners = new Set();
173
189
  let rescan;
174
190
  /** Counts the times following started; a read that finishes after a stop belongs to none. */
@@ -558,7 +574,7 @@ export function localSource(configDir, { rescanMs = 60_000, quietMs, maxWaitMs }
558
574
  return true;
559
575
  },
560
576
  machine() {
561
- return { ...machine };
577
+ return { ...machine, release: newestRelease() };
562
578
  },
563
579
  async selfUpdate() {
564
580
  // Started in turn with upgrade starts and disconnects, and awaited after it: a call meanwhile is refused, not queued.