afterimage-cli 0.1.0 → 0.2.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.
@@ -1,194 +1,376 @@
1
- import fs from 'node:fs';
2
- import http from 'node:http';
3
- import net from 'node:net';
4
- import path from 'node:path';
5
- import { parseArgs } from 'node:util';
6
- import { readConfig } from '../config.js';
7
- import { assembleSite } from '../site.js';
8
- import { fileFor, requestPath, sendFile } from '../static-files.js';
9
-
10
- export const usage = 'afterimage dev [--port N]';
11
- export const summary = 'Serve the saved history at /afterimage/ while your app runs on its own dev server.';
12
- // What both "afterimage dev --help" and "afterimage help dev" print.
13
- export const help = `Usage: ${usage}\n\n${summary}\n\n --port N the port to start from, 4321 by default; a busy one moves to the next free port\n`;
14
- // Read by the docs tests, which check every flag the agent files name.
15
- export const options = { port: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
16
-
17
- const defaultPort = 4321;
18
- const portAttempts = 20;
19
- // Its own folder, never config.out, so dev and a running build can never share a staging folder.
20
- const devOut = '.afterimage/dev';
21
- const servedPath = '/afterimage/';
22
-
23
- const count = versions => `${versions} ${versions === 1 ? 'version' : 'versions'}`;
24
-
25
- // Windows lets a bind on 127.0.0.1 succeed even while another server holds 0.0.0.0, :: or ::1 on the same port (Astro's
26
- // default is 4321 too), so ask whether anything answers on either loopback address before binding. A refused connect or
27
- // a missing address (no IPv6) means nothing is there; a connect that hangs is treated as taken.
28
- const answers = (port, host) => new Promise(resolve => {
29
- const socket = net.connect({ port, host });
30
- const done = taken => { socket.destroy(); resolve(taken); };
31
- socket.setTimeout(300, () => done(true));
32
- socket.once('connect', () => done(true));
33
- socket.once('error', () => done(false));
34
- });
35
- const isTaken = async port => (await Promise.all(['127.0.0.1', '::1'].map(host => answers(port, host)))).some(Boolean);
36
-
37
- // Listens on the first free port from `start`. Port 0 asks the system for any free one.
38
- async function listen(server, start) {
39
- for (let port = start; port < start + (start === 0 ? 1 : portAttempts) && port <= 65535; port++) {
40
- if (port !== 0 && await isTaken(port)) continue;
41
- try {
42
- await new Promise((resolve, reject) => {
43
- server.once('error', reject);
44
- // Loopback only: the history is for this machine.
45
- server.listen(port, '127.0.0.1', () => {
46
- server.off('error', reject);
47
- resolve();
48
- });
49
- });
50
- return server.address().port;
51
- } catch (error) {
52
- if (error.code !== 'EADDRINUSE') throw error;
53
- }
54
- }
55
- throw new Error(`No free port from ${start} to ${start + portAttempts - 1}. Pass one with --port.`);
56
- }
57
-
58
- // Assembles the viewer and the saved history (not the app) into .afterimage/dev, serves it, and re-assembles when
59
- // versions.json changes. A failed refresh goes to onError and the last good site keeps serving.
60
- // Returns { url, versions, warnings, close }: warnings are from the first assemble, and close stops the server and
61
- // the watcher and waits for a refresh in flight.
62
- export async function startDev({ projectDir, config, port = defaultPort, viewerDir, runtimeDir, debounceMs = 150, onUpdate = () => {}, onError = () => {} }) {
63
- const devConfig = { ...config, out: devOut };
64
- const assemble = () => assembleSite({ projectDir, config: devConfig, viewerDir, runtimeDir, includeApp: false });
65
- const first = await assemble();
66
- const root = first.outDir;
67
-
68
- const server = http.createServer(async (req, res) => {
69
- const pathname = requestPath(req.url);
70
- if (pathname === null) { res.writeHead(400).end(); return; }
71
- if (pathname === '/' || pathname === servedPath.slice(0, -1)) { res.writeHead(302, { Location: servedPath }).end(); return; }
72
- const file = fileFor(root, pathname);
73
- if (!file) { res.writeHead(403).end(); return; }
74
- await sendFile(res, file);
75
- });
76
- const boundPort = await listen(server, port);
77
-
78
- let closed = false;
79
- const stopServer = () => new Promise(resolve => {
80
- server.close(resolve);
81
- // A browser's keep-alive connection would otherwise hold the server open.
82
- server.closeAllConnections();
83
- });
84
- let timer = null;
85
- let refreshing = null;
86
- let again = false;
87
- // One refresh at a time. A change that lands during one asks for another straight after it.
88
- const refresh = () => {
89
- if (refreshing) { again = true; return refreshing; }
90
- refreshing = (async () => {
91
- try {
92
- do {
93
- again = false;
94
- try {
95
- const result = await assemble();
96
- if (!closed) onUpdate(result);
97
- } catch (error) {
98
- if (!closed) onError(error);
99
- }
100
- } while (again && !closed);
101
- } finally {
102
- refreshing = null;
103
- }
104
- })();
105
- return refreshing;
106
- };
107
-
108
- // A save publishes versions.json last, so it is the one file whose change means "the history is complete".
109
- let watcher;
110
- try {
111
- watcher = fs.watch(path.resolve(projectDir, config.history), (event, name) => {
112
- if (closed || (name && String(name) !== 'versions.json')) return;
113
- clearTimeout(timer);
114
- timer = setTimeout(refresh, debounceMs);
115
- });
116
- } catch (error) {
117
- // The server is already listening; do not leave its port held.
118
- await stopServer();
119
- throw error;
120
- }
121
- watcher.on('error', error => { if (!closed) onError(error); });
122
-
123
- const close = async () => {
124
- if (closed) return;
125
- closed = true;
126
- clearTimeout(timer);
127
- watcher.close();
128
- await stopServer();
129
- await refreshing;
130
- };
131
-
132
- return { url: `http://localhost:${boundPort}${servedPath}`, versions: first.versions, warnings: first.warnings, close };
133
- }
134
-
135
- // Resolves on Ctrl-C (or a terminate), or when a caller's signal aborts, which is how tests stop it.
136
- function untilStopped(signal) {
137
- return new Promise(resolve => {
138
- if (signal) {
139
- if (signal.aborted) resolve();
140
- else signal.addEventListener('abort', resolve, { once: true });
141
- return;
142
- }
143
- const stop = () => {
144
- process.off('SIGINT', stop);
145
- process.off('SIGTERM', stop);
146
- resolve();
147
- };
148
- process.once('SIGINT', stop);
149
- process.once('SIGTERM', stop);
150
- });
151
- }
152
-
153
- export async function run(args, { cwd, stdout, stderr, viewerDir, runtimeDir, signal }) {
154
- let parsed;
155
- try {
156
- parsed = parseArgs({ args, options });
157
- } catch (error) {
158
- if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
159
- stderr.write(`${error.message}\nUsage: ${usage}\n`);
160
- return 2;
161
- }
162
- if (parsed.values.help) {
163
- stdout.write(help);
164
- return 0;
165
- }
166
- let port = defaultPort;
167
- if (parsed.values.port !== undefined) {
168
- port = Number(parsed.values.port);
169
- if (!/^\d+$/.test(parsed.values.port) || port < 1 || port > 65535) {
170
- stderr.write(`--port needs a number from 1 to 65535.\nUsage: ${usage}\n`);
171
- return 2;
172
- }
173
- }
174
-
175
- const config = await readConfig(cwd);
176
- const dev = await startDev({
177
- projectDir: path.resolve(cwd),
178
- config,
179
- port,
180
- viewerDir,
181
- runtimeDir,
182
- onUpdate: ({ versions, warnings }) => {
183
- for (const warning of warnings) stderr.write(`${warning}\n`);
184
- stdout.write(`Updated: ${count(versions)}.\n`);
185
- },
186
- onError: error => stderr.write(`Could not update the history: ${error.message}\nStill serving the last good copy.\n`)
187
- });
188
- for (const warning of dev.warnings) stderr.write(`${warning}\n`);
189
- stdout.write(`Afterimage is running at ${dev.url}\n`);
190
- stdout.write('Your app keeps running on its own dev server; this only serves the history.\n');
191
- await untilStopped(signal);
192
- await dev.close();
193
- return 0;
194
- }
1
+ import { spawn } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import http from 'node:http';
4
+ import net from 'node:net';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { parseArgs } from 'node:util';
8
+ import { readConfig } from '../config.js';
9
+ import { readHistory } from '../history.js';
10
+ import { assembleSite } from '../site.js';
11
+ import { fileFor, requestPath, sendFile } from '../static-files.js';
12
+ import { saveProject } from './save.js';
13
+
14
+ export const usage = 'afterimage dev [--port N] [-- <your dev command>]';
15
+ export const summary = 'Run your app\'s dev server with Afterimage beside it, so the pill on the prototype can save and show versions.';
16
+ // What both "afterimage dev --help" and "afterimage help dev" print.
17
+ export const help = `Usage: ${usage}\n\n${summary}\n\n --port N the port Afterimage starts from, 4321 by default; a busy one moves to the next free port\n -- <your dev command> the command that runs your app's own dev server, such as "vite" or "next dev"\n\nWith no dev command, a plain HTML prototype is served at / beside the history.\n`;
18
+ // Read by the docs tests, which check every flag the agent files name.
19
+ export const options = { port: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
20
+
21
+ const defaultPort = 4321;
22
+ const portAttempts = 20;
23
+ const servedPath = '/afterimage/';
24
+ const apiPath = '/afterimage/api/';
25
+ const overlayPath = '/afterimage/overlay.js';
26
+ // A save's request is a name and a note. Anything much bigger is not one.
27
+ const maxBody = 16_000;
28
+
29
+ const count = versions => `${versions} ${versions === 1 ? 'version' : 'versions'}`;
30
+
31
+ // The overlay calls the api from the app's own dev server, which is another port on this machine. Only pages served
32
+ // from this machine may, so a website open in the same browser can't start a build.
33
+ export function isLocalOrigin(origin) {
34
+ let url;
35
+ try {
36
+ url = new URL(origin);
37
+ } catch {
38
+ return false;
39
+ }
40
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
41
+ return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.hostname.endsWith('.localhost');
42
+ }
43
+
44
+ // Windows lets a bind on 127.0.0.1 succeed even while another server holds 0.0.0.0, :: or ::1 on the same port (Astro's
45
+ // default is 4321 too), so ask whether anything answers on either loopback address before binding. A refused connect or
46
+ // a missing address (no IPv6) means nothing is there; a connect that hangs is treated as taken.
47
+ const answers = (port, host) => new Promise(resolve => {
48
+ const socket = net.connect({ port, host });
49
+ const done = taken => { socket.destroy(); resolve(taken); };
50
+ socket.setTimeout(300, () => done(true));
51
+ socket.once('connect', () => done(true));
52
+ socket.once('error', () => done(false));
53
+ });
54
+ const isTaken = async port => (await Promise.all(['127.0.0.1', '::1'].map(host => answers(port, host)))).some(Boolean);
55
+
56
+ // Listens on the first free port from `start`. Port 0 asks the system for any free one.
57
+ async function listen(server, start) {
58
+ for (let port = start; port < start + (start === 0 ? 1 : portAttempts) && port <= 65535; port++) {
59
+ if (port !== 0 && await isTaken(port)) continue;
60
+ try {
61
+ await new Promise((resolve, reject) => {
62
+ server.once('error', reject);
63
+ // Loopback only: the history is for this machine.
64
+ server.listen(port, '127.0.0.1', () => {
65
+ server.off('error', reject);
66
+ resolve();
67
+ });
68
+ });
69
+ return server.address().port;
70
+ } catch (error) {
71
+ // EACCES: Windows refuses ports in the ranges Hyper-V, WSL or Docker reserve, which nothing answers on beforehand.
72
+ if (error.code !== 'EADDRINUSE' && error.code !== 'EACCES') throw error;
73
+ }
74
+ }
75
+ throw new Error(`No free port from ${start} to ${start + portAttempts - 1}. Pass one with --port.`);
76
+ }
77
+
78
+ const sendJson = (res, status, body) => {
79
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
80
+ res.end(JSON.stringify(body));
81
+ };
82
+
83
+ function readJson(req) {
84
+ return new Promise((resolve, reject) => {
85
+ let size = 0;
86
+ const chunks = [];
87
+ req.on('data', chunk => {
88
+ size += chunk.length;
89
+ if (size > maxBody) reject(Object.assign(new Error('The request is too large.'), { status: 413 }));
90
+ else chunks.push(chunk);
91
+ });
92
+ req.on('end', () => {
93
+ try {
94
+ const text = Buffer.concat(chunks).toString('utf8');
95
+ resolve(text ? JSON.parse(text) : {});
96
+ } catch {
97
+ reject(Object.assign(new Error('The request is not JSON.'), { status: 400 }));
98
+ }
99
+ });
100
+ req.on('error', reject);
101
+ });
102
+ }
103
+
104
+ // Assembles the viewer and the saved history into a temporary folder, serves it, and re-assembles when versions.json
105
+ // changes. A failed refresh goes to onError and the last good site keeps serving. It also answers the overlay's api:
106
+ // GET /afterimage/api/session (how many versions, the latest, and that saving works here) and
107
+ // POST /afterimage/api/save ({ name, note }, builds and saves). With app, the prototype's own folder is served at /,
108
+ // as it is on disk, for a plain HTML prototype that has no dev server of its own.
109
+ // Returns { url, versions, warnings, close }: warnings are from the first assemble, and close stops the server and
110
+ // the watcher and waits for a refresh in flight.
111
+ export async function startDev({ projectDir, config, port = defaultPort, viewerDir, runtimeDir, app = false, buildStdio, debounceMs = 150, onUpdate = () => {}, onError = () => {} }) {
112
+ // Outside the project, never config.out: a build running beside dev keeps its own folder, and the app's dev server
113
+ // (Vite watches the whole project) can't hold this one open on Windows, where that blocks replacing it after a save.
114
+ const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'afterimage-dev-'));
115
+ const removeTemp = () => fs.promises.rm(tempDir, { recursive: true, force: true, maxRetries: 3 }).catch(() => {});
116
+ const devConfig = { ...config, out: path.join(tempDir, 'site') };
117
+ const assemble = () => assembleSite({ projectDir, config: devConfig, viewerDir, runtimeDir, includeApp: false, allowEmpty: true });
118
+ let first;
119
+ try {
120
+ first = await assemble();
121
+ } catch (error) {
122
+ await removeTemp();
123
+ throw error;
124
+ }
125
+ const root = first.outDir;
126
+ const historyDir = path.resolve(projectDir, config.history);
127
+ const appRoot = path.resolve(projectDir, config.source);
128
+ let saving = null;
129
+
130
+ async function api(req, res, name) {
131
+ if (name === 'session' && req.method === 'GET') {
132
+ const manifest = await readHistory(historyDir);
133
+ const latest = manifest?.versions[0];
134
+ return sendJson(res, 200, { afterimage: 1, versions: manifest?.versions.length ?? 0, latest: latest ? { id: latest.id, name: latest.name } : null, canSave: true });
135
+ }
136
+ if (name === 'save' && req.method === 'POST') {
137
+ // One at a time: a second press while a build runs would only queue the same save again.
138
+ if (saving) return sendJson(res, 409, { error: 'A save is already running.' });
139
+ saving = (async () => {
140
+ const body = await readJson(req);
141
+ const manifest = await readHistory(historyDir);
142
+ const next = (manifest?.versions.length ?? 0) + 1;
143
+ const given = typeof body.name === 'string' ? body.name.trim() : '';
144
+ const notes = typeof body.note === 'string' && body.note.trim() ? [body.note.trim()] : [];
145
+ const { version, unchanged } = await saveProject({ projectDir, config, name: given || `Version ${next}`, notes, buildStdio });
146
+ return { saved: !unchanged, id: version.id, name: version.name };
147
+ })();
148
+ try {
149
+ const result = await saving;
150
+ // Answer once the served copy has the new version, so History opened right after shows it.
151
+ if (result.saved) await refresh();
152
+ return sendJson(res, 200, result);
153
+ } catch (error) {
154
+ return sendJson(res, error.status ?? 500, { error: error.message });
155
+ } finally {
156
+ saving = null;
157
+ }
158
+ }
159
+ if (name === 'session' || name === 'save') {
160
+ res.writeHead(405, { Allow: name === 'save' ? 'POST' : 'GET' }).end();
161
+ return;
162
+ }
163
+ res.writeHead(404).end();
164
+ }
165
+
166
+ const server = http.createServer(async (req, res) => {
167
+ const pathname = requestPath(req.url);
168
+ if (pathname === null) { res.writeHead(400).end(); return; }
169
+ if (pathname.startsWith(apiPath)) {
170
+ const origin = req.headers.origin;
171
+ if (origin !== undefined && !isLocalOrigin(origin)) { res.writeHead(403).end(); return; }
172
+ if (origin !== undefined) {
173
+ res.setHeader('Access-Control-Allow-Origin', origin);
174
+ res.setHeader('Vary', 'Origin');
175
+ }
176
+ if (req.method === 'OPTIONS') {
177
+ res.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, POST', 'Access-Control-Allow-Headers': 'Content-Type', 'Access-Control-Max-Age': '600' }).end();
178
+ return;
179
+ }
180
+ await api(req, res, pathname.slice(apiPath.length));
181
+ return;
182
+ }
183
+ const inHistory = pathname === servedPath.slice(0, -1) || pathname.startsWith(servedPath);
184
+ if (app && !inHistory) {
185
+ const file = fileFor(appRoot, pathname);
186
+ if (!file) { res.writeHead(403).end(); return; }
187
+ await sendFile(res, file);
188
+ return;
189
+ }
190
+ if (pathname === '/' || pathname === servedPath.slice(0, -1)) { res.writeHead(302, { Location: servedPath }).end(); return; }
191
+ const file = fileFor(root, pathname);
192
+ if (!file) { res.writeHead(403).end(); return; }
193
+ // The stamp tells a pill loaded by script tag that dev answers on this origin; a built site's copy has none.
194
+ if (pathname === overlayPath) {
195
+ const source = await fs.promises.readFile(file, 'utf8').catch(() => null);
196
+ if (source === null) { res.writeHead(404).end('Not found'); return; }
197
+ res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store' });
198
+ res.end(`window.__afterimageDev = true;\n${source}`);
199
+ return;
200
+ }
201
+ await sendFile(res, file);
202
+ });
203
+ let boundPort;
204
+ try {
205
+ boundPort = await listen(server, port);
206
+ } catch (error) {
207
+ await removeTemp();
208
+ throw error;
209
+ }
210
+
211
+ let closed = false;
212
+ const stopServer = () => new Promise(resolve => {
213
+ server.close(resolve);
214
+ // A browser's keep-alive connection would otherwise hold the server open.
215
+ server.closeAllConnections();
216
+ });
217
+ let timer = null;
218
+ let refreshing = null;
219
+ let again = false;
220
+ // One refresh at a time. A change that lands during one asks for another straight after it.
221
+ const refresh = () => {
222
+ if (refreshing) { again = true; return refreshing; }
223
+ refreshing = (async () => {
224
+ try {
225
+ do {
226
+ again = false;
227
+ try {
228
+ const result = await assemble();
229
+ if (!closed) onUpdate(result);
230
+ } catch (error) {
231
+ if (!closed) onError(error);
232
+ }
233
+ } while (again && !closed);
234
+ } finally {
235
+ refreshing = null;
236
+ }
237
+ })();
238
+ return refreshing;
239
+ };
240
+
241
+ // A save publishes versions.json last, so it is the one file whose change means "the history is complete". The
242
+ // history folder may not exist before the first save, so it is made here to be watched.
243
+ let watcher;
244
+ try {
245
+ fs.mkdirSync(historyDir, { recursive: true });
246
+ watcher = fs.watch(historyDir, (event, name) => {
247
+ if (closed || (name && String(name) !== 'versions.json')) return;
248
+ clearTimeout(timer);
249
+ timer = setTimeout(refresh, debounceMs);
250
+ });
251
+ } catch (error) {
252
+ // The server is already listening; do not leave its port held.
253
+ await stopServer();
254
+ await removeTemp();
255
+ throw error;
256
+ }
257
+ watcher.on('error', error => { if (!closed) onError(error); });
258
+
259
+ const close = async () => {
260
+ if (closed) return;
261
+ closed = true;
262
+ clearTimeout(timer);
263
+ watcher.close();
264
+ await stopServer();
265
+ await refreshing;
266
+ await saving?.catch(() => {});
267
+ await removeTemp();
268
+ };
269
+
270
+ return { url: `http://localhost:${boundPort}${servedPath}`, appUrl: app ? `http://localhost:${boundPort}/` : null, versions: first.versions, warnings: first.warnings, close };
271
+ }
272
+
273
+ // Resolves on Ctrl-C (or a terminate), or when a caller's signal aborts, which is how tests stop it.
274
+ function untilStopped(signal) {
275
+ return new Promise(resolve => {
276
+ if (signal) {
277
+ if (signal.aborted) resolve();
278
+ else signal.addEventListener('abort', resolve, { once: true });
279
+ return;
280
+ }
281
+ const stop = () => {
282
+ process.off('SIGINT', stop);
283
+ process.off('SIGTERM', stop);
284
+ resolve();
285
+ };
286
+ process.once('SIGINT', stop);
287
+ process.once('SIGTERM', stop);
288
+ });
289
+ }
290
+
291
+ // The app's own dev command, run through the shell like an npm script. Arguments with spaces keep their quotes.
292
+ const commandLine = parts => parts.map(part => (/[\s"]/.test(part) ? `"${part.replace(/"/g, '\\"')}"` : part)).join(' ');
293
+
294
+ function runApp(command, cwd, stdio) {
295
+ const child = spawn(command, { shell: true, cwd, stdio });
296
+ const exited = new Promise(resolve => {
297
+ child.on('error', error => resolve({ error }));
298
+ child.on('close', (code, signal) => resolve({ code, signal }));
299
+ });
300
+ return { child, exited };
301
+ }
302
+
303
+ export async function run(args, { cwd, stdout, stderr, viewerDir, runtimeDir, signal, buildStdio, appStdio = 'inherit' }) {
304
+ // Everything after -- is the app's own dev command, passed on as it is.
305
+ const split = args.indexOf('--');
306
+ const own = split === -1 ? args : args.slice(0, split);
307
+ const appCommand = split === -1 ? [] : args.slice(split + 1);
308
+ let parsed;
309
+ try {
310
+ parsed = parseArgs({ args: own, options });
311
+ } catch (error) {
312
+ if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
313
+ stderr.write(`${error.message}\nUsage: ${usage}\n`);
314
+ return 2;
315
+ }
316
+ if (parsed.values.help) {
317
+ stdout.write(help);
318
+ return 0;
319
+ }
320
+ let port = defaultPort;
321
+ if (parsed.values.port !== undefined) {
322
+ port = Number(parsed.values.port);
323
+ if (!/^\d+$/.test(parsed.values.port) || port < 1 || port > 65535) {
324
+ stderr.write(`--port needs a number from 1 to 65535.\nUsage: ${usage}\n`);
325
+ return 2;
326
+ }
327
+ }
328
+ if (split !== -1 && !appCommand.length) {
329
+ stderr.write(`Put your dev command after --.\nUsage: ${usage}\n`);
330
+ return 2;
331
+ }
332
+
333
+ const config = await readConfig(cwd);
334
+ // A plain HTML prototype has no dev server of its own, so Afterimage serves it, and the pill on it is same-origin.
335
+ const app = !appCommand.length && !config.build;
336
+ const dev = await startDev({
337
+ projectDir: path.resolve(cwd),
338
+ config,
339
+ port,
340
+ viewerDir,
341
+ runtimeDir,
342
+ app,
343
+ buildStdio,
344
+ onUpdate: ({ versions, warnings }) => {
345
+ for (const warning of warnings) stderr.write(`${warning}\n`);
346
+ stdout.write(`Updated: ${count(versions)}.\n`);
347
+ },
348
+ onError: error => stderr.write(`Could not update the history: ${error.message}\nStill serving the last good copy.\n`)
349
+ });
350
+ for (const warning of dev.warnings) stderr.write(`${warning}\n`);
351
+ stdout.write(`Afterimage is running at ${dev.url}\n`);
352
+
353
+ if (appCommand.length) {
354
+ const { child, exited } = runApp(commandLine(appCommand), cwd, appStdio);
355
+ const stopped = untilStopped(signal).then(() => null);
356
+ const outcome = await Promise.race([exited, stopped]);
357
+ if (outcome === null) {
358
+ child.kill();
359
+ await exited;
360
+ await dev.close();
361
+ return 0;
362
+ }
363
+ await dev.close();
364
+ if (outcome.error) {
365
+ stderr.write(`Your dev command could not start: ${outcome.error.message}\n`);
366
+ return 1;
367
+ }
368
+ return outcome.code ?? 1;
369
+ }
370
+
371
+ if (app) stdout.write(`Your prototype is at ${dev.appUrl}\n`);
372
+ else stdout.write('Your app keeps running on its own dev server; this only serves the history.\n');
373
+ await untilStopped(signal);
374
+ await dev.close();
375
+ return 0;
376
+ }