@medeo-plugin/cli 0.7.0 → 0.8.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/app.mjs CHANGED
@@ -35,17 +35,23 @@ export async function locate({ channel } = {}) {
35
35
 
36
36
  /** One request, one reply; Medeo's errors are thrown with its own message. */
37
37
  export async function request(op, args = {}, options = {}) {
38
+ const signal = options.signal;
39
+ signal?.throwIfAborted();
38
40
  const control = options.control ?? await locate(options);
41
+ signal?.throwIfAborted();
39
42
  if (control.protocol !== PROTOCOL) throw new Error(`Medeo speaks control protocol ${control.protocol}; this toolchain speaks ${PROTOCOL}. Update one of them`);
40
43
  const line = await new Promise((resolve, reject) => {
41
44
  const socket = connect(control.socket);
42
45
  let buffer = '';
43
- const timer = setTimeout(() => { socket.destroy(); reject(new Error(`Medeo did not answer ${op} in ${TIMEOUT_MS / 1000}s`)); }, TIMEOUT_MS);
46
+ const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', abort); };
47
+ const abort = () => { cleanup(); socket.destroy(); reject(signal.reason); };
48
+ const timer = setTimeout(() => { cleanup(); socket.destroy(); reject(new Error(`Medeo did not answer ${op} in ${TIMEOUT_MS / 1000}s`)); }, TIMEOUT_MS);
49
+ signal?.addEventListener('abort', abort, { once: true });
44
50
  socket.setEncoding('utf8');
45
51
  socket.on('connect', () => socket.write(JSON.stringify({ protocol: PROTOCOL, op, ...args }) + '\n'));
46
52
  socket.on('data', chunk => { buffer += chunk; });
47
- socket.on('end', () => { clearTimeout(timer); resolve(buffer); });
48
- socket.on('error', error => { clearTimeout(timer); reject(error.code === 'ENOENT' || error.code === 'ECONNREFUSED' ? new Error('Medeo is not listening; is it still running in developer mode?') : error); });
53
+ socket.on('end', () => { cleanup(); resolve(buffer); });
54
+ socket.on('error', error => { cleanup(); reject(error.code === 'ENOENT' || error.code === 'ECONNREFUSED' ? new Error('Medeo is not listening; is it still running in developer mode?') : error); });
49
55
  });
50
56
  const reply = JSON.parse(line);
51
57
  if (!reply.ok) throw new Error(reply.error);
@@ -71,7 +77,8 @@ export async function main(args, { defaultId = false } = {}) {
71
77
  if (defaultId && ((op === 'logs' || op === 'open' || op === 'stop') && !operands.length || op === 'run' && operands.length === 1)) operands.unshift(await projectId());
72
78
  const [first, second] = operands;
73
79
  let result;
74
- if (op === 'status' && !operands.length) result = await request('status', {}, options);
80
+ if (op === 'open-center' && !operands.length) result = await request('open-center', {}, options);
81
+ else if (op === 'status' && !operands.length) result = await request('status', {}, options);
75
82
  else if (op === 'logs' && operands.length === 1) result = await request('logs', { id: first, ...(options.since ? { since: options.since } : {}) }, options);
76
83
  else if ((op === 'follow' || op === 'reload' || op === 'unfollow') && operands.length === 1)
77
84
  result = await request(op === 'unfollow' ? 'unfollow' : 'follow', { file: path.resolve(first) }, options);
package/cli.mjs CHANGED
@@ -17,6 +17,7 @@ const USAGE = `Usage: medeo-plugin <command>
17
17
  keygen KEY.pem create a release signing key (keep it outside the project)
18
18
  status | logs ID [--since MS] | run ID COMMAND | open ID | stop ID | follow PACKAGE | unfollow PACKAGE
19
19
  talk to the running Medeo in developer mode (ID defaults to ./manifest.json)
20
+ open-center [--channel stg|prd] open the plugin center (no project required)
20
21
  app <the same> older spelling of the commands above
21
22
  `;
22
23
 
@@ -30,7 +31,7 @@ export async function main(argv) {
30
31
  else if (command === 'check' && args.length === 1) console.log(JSON.stringify(await (await import('./tooling.mjs')).check(args[0]), null, 2));
31
32
  else if (command === 'dev') await (await import('./dev.mjs')).devCommand(args);
32
33
  else if (command === 'app') await (await import('./app.mjs')).main(args);
33
- else if (['status', 'logs', 'run', 'open', 'stop', 'follow', 'unfollow', 'reload'].includes(command)) await (await import('./app.mjs')).main([command, ...args], { defaultId: true });
34
+ else if (['status', 'logs', 'run', 'open-center', 'open', 'stop', 'follow', 'unfollow', 'reload'].includes(command)) await (await import('./app.mjs')).main([command, ...args], { defaultId: true });
34
35
  else { console.error(USAGE); process.exitCode = 1; }
35
36
  }
36
37
  // Compared through realpath: npm's bin links and macOS's /var → /private/var both point here.
package/dev.mjs CHANGED
@@ -39,43 +39,61 @@ export function parseDevArgs(args) {
39
39
  * and follows again when Medeo restarts (its watches live in memory).
40
40
  */
41
41
  export class Follower {
42
- constructor(file, { channel, log = console.log, warn = console.error, requestFn = request, locateFn = locate } = {}) {
43
- Object.assign(this, { file, channel, log, warn, requestFn, locateFn, pid: null, timer: null, hinted: false, pending: false, stopped: false });
42
+ constructor(file, { channel, log = console.log, warn = console.error, requestFn = request, locateFn = locate, retryMs = RETRY_MS } = {}) {
43
+ Object.assign(this, { file, channel, log, warn, requestFn, locateFn, retryMs, controller: new AbortController(), refused: false, pid: null, timer: null, hinted: false, pending: false, stopped: false });
44
44
  }
45
45
  async load() {
46
+ if (this.stopped) return;
47
+ if (this.inFlight) { this.reloadRequested = true; return this.inFlight; }
48
+ if (this.timer) clearTimeout(this.timer);
49
+ this.timer = null;
50
+ this.inFlight = (async () => {
51
+ do { this.reloadRequested = false; await this.loadOnce(); }
52
+ while (this.reloadRequested && !this.stopped);
53
+ })();
54
+ try { await this.inFlight; } finally { this.inFlight = null; }
55
+ }
56
+ async loadOnce() {
46
57
  if (this.stopped) return;
47
58
  this.pending = true;
48
59
  let control;
49
60
  try { control = await this.locateFn({ channel: this.channel }); }
50
61
  catch (error) {
62
+ if (this.stopped) return;
51
63
  if (!this.hinted) { this.warn(DEVELOPER_MODE_HINT); this.hinted = true; }
52
64
  return this.retry();
53
65
  }
66
+ if (this.stopped) return;
54
67
  try {
55
- const result = await this.requestFn('follow', { file: this.file }, { control });
68
+ const result = await this.requestFn('follow', { file: this.file }, { control, signal: this.controller.signal });
69
+ if (this.stopped) return;
70
+ this.refused = Boolean(result?.error);
56
71
  this.pid = control.pid; this.hinted = false; this.pending = false;
57
72
  if (result?.error) this.warn(JSON.stringify({ event: 'medeo_refused', error: result.error, version: result.version ?? null }));
58
73
  else this.log(JSON.stringify({ event: 'medeo_loaded', version: result?.version ?? null, channel: control.channel }));
74
+ this.retry();
59
75
  } catch (error) {
76
+ if (this.stopped) return;
60
77
  this.warn(JSON.stringify({ event: 'medeo_unreachable', error: error.message }));
61
78
  this.retry();
62
79
  }
63
80
  }
64
81
  retry() {
65
82
  if (this.stopped || this.timer) return;
66
- this.timer = setTimeout(() => { this.timer = null; void this.check(); }, RETRY_MS);
83
+ this.timer = setTimeout(() => { this.timer = null; void this.check(); }, this.retryMs);
67
84
  this.timer.unref?.();
68
85
  }
69
86
  /** Reloads when a load is still owed or Medeo was restarted since the last one. */
70
87
  async check() {
71
88
  if (this.stopped) return;
89
+ if (this.inFlight) { this.retry(); return; }
72
90
  if (this.pending) return this.load();
73
91
  const control = await this.locateFn({ channel: this.channel }).catch(() => null);
74
92
  if (control && control.pid !== this.pid) return this.load();
75
93
  if (!control) this.pending = true;
76
94
  this.retry();
77
95
  }
78
- stop() { this.stopped = true; if (this.timer) clearTimeout(this.timer); this.timer = null; }
96
+ stop() { this.stopped = true; this.controller.abort(); if (this.timer) clearTimeout(this.timer); this.timer = null; }
79
97
  }
80
98
 
81
99
  export async function devCommand(args) {
@@ -92,7 +110,10 @@ export async function devCommand(args) {
92
110
  console.error(`Building ${manifest.id} from ${source}\n key ${key}\n package ${output}${options.once ? '' : '\n watching for changes (Ctrl-C to stop)'}`);
93
111
  await develop(source, key, output, { once: options.once, signal: controller.signal, onBuild: () => follower?.load() });
94
112
  // `--once` returns after the build; give the follow a chance to finish.
95
- if (options.once && follower) { while (follower.pending && !follower.stopped) await new Promise(r => setTimeout(r, 200)); }
113
+ if (options.once && follower) {
114
+ while (follower.pending && !follower.stopped) await new Promise(r => setTimeout(r, 200));
115
+ if (follower.refused) process.exitCode = 2;
116
+ }
96
117
  } finally {
97
118
  process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
98
119
  follower?.stop();
package/locate.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // in this repository they are read from the checkout, so tests and the release
4
4
  // script need no staging step.
5
5
  import { createRequire } from 'node:module';
6
- import { existsSync } from 'node:fs';
6
+ import { existsSync, readFileSync } from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { fileURLToPath } from 'node:url';
9
9
 
@@ -21,17 +21,44 @@ export function templatePath(name) {
21
21
  for (const candidate of [path.join(repo, TEMPLATES[name]), path.join(here, 'templates', name)]) if (existsSync(candidate)) return candidate;
22
22
  throw new Error(`Template "${name}" is missing from this installation`);
23
23
  }
24
- /** The `@medeo-plugin/sdk` entry esbuild bundles for a plugin in `source`:
25
- * the plugin's own dependency first (so a project pins its SDK), then the CLI's. */
26
- export function sdkEntry(source) {
24
+ const SDK_ENTRIES = { '@medeo-plugin/sdk': 'index', '@medeo-plugin/sdk/ui': 'ui', '@medeo-plugin/sdk/ui/react': 'react', '@medeo-plugin/sdk/rpc': 'rpc', '@medeo-plugin/sdk/testing': 'testing' };
25
+ /** Resolve only public, known entry points. Select the project's SDK package once:
26
+ * a missing export in its pinned version must not fall back to a different SDK. */
27
+ export function sdkEntry(source, specifier = '@medeo-plugin/sdk') {
28
+ if (!Object.hasOwn(SDK_ENTRIES, specifier)) throw new Error(`Unsupported SDK entry: ${specifier}`);
27
29
  const candidates = [];
28
30
  for (const from of [path.join(source, 'package.json'), import.meta.url]) {
29
- try { candidates.push(path.dirname(createRequire(from).resolve('@medeo-plugin/sdk/package.json'))); } catch {}
31
+ // Reading package metadata must work even when package.json itself is not
32
+ // exported. Public entry permissions are checked below before choosing code.
33
+ const roots = createRequire(from).resolve.paths('@medeo-plugin/sdk') ?? [];
34
+ const installed = roots.map(root => path.join(root, '@medeo-plugin/sdk')).find(dir => existsSync(path.join(dir, 'package.json')));
35
+ if (installed) candidates.push(installed);
30
36
  }
31
37
  candidates.push(path.join(repo, 'packages/plugin-sdk'));
32
- for (const dir of candidates) {
33
- // The source ships inside the package; esbuild reads TypeScript directly.
34
- for (const entry of ['src/index.ts', 'dist/index.js']) if (existsSync(path.join(dir, entry))) return path.join(dir, entry);
38
+ const dir = candidates.find(dir => existsSync(path.join(dir, 'package.json')));
39
+ if (!dir) throw new Error('@medeo-plugin/sdk was not found; run npm install in the plugin project');
40
+ const manifest = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
41
+ const subpath = specifier === '@medeo-plugin/sdk' ? '.' : '.' + specifier.slice('@medeo-plugin/sdk'.length);
42
+ const exported = manifest.exports?.[subpath];
43
+ if (!exported) throw new Error(`${specifier} is not exported by the plugin's SDK (${manifest.version}); update its dependency`);
44
+ const target = typeof exported === 'string' ? exported : exported.import ?? exported.default;
45
+ const name = SDK_ENTRIES[specifier];
46
+ // Source is shipped for bundling; dist-only installations work too.
47
+ for (const entry of [`src/${name}.ts`, ...(typeof target === 'string' && target.startsWith('./') ? [target] : [])]) {
48
+ const resolved = path.resolve(dir, entry);
49
+ if (resolved.startsWith(dir + path.sep) && existsSync(resolved)) return resolved;
35
50
  }
36
- throw new Error('@medeo-plugin/sdk was not found; run npm install in the plugin project');
51
+ throw new Error(`${specifier} is missing from the plugin's SDK installation`);
52
+ }
53
+
54
+ /** esbuild aliases match prefixes; an onResolve hook makes subpaths exact and
55
+ * prevents private paths such as sdk/src/index.ts from escaping exports. */
56
+ export function sdkResolver(source) {
57
+ const resolved = new Map();
58
+ return { name: 'medeo-sdk', setup(build) {
59
+ build.onResolve({ filter: /^@medeo-plugin\/sdk(?:\/.*)?$/ }, args => {
60
+ if (!resolved.has(args.path)) resolved.set(args.path, sdkEntry(source, args.path));
61
+ return { path: resolved.get(args.path) };
62
+ });
63
+ } };
37
64
  }
package/pack.mjs CHANGED
@@ -4,7 +4,7 @@ import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto
4
4
  import { readFile, writeFile, mkdir, readdir, lstat, access } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { zip } from './zip.mjs';
7
- import { sdkEntry } from './locate.mjs';
7
+ import { sdkResolver } from './locate.mjs';
8
8
  // Mirrors platform_core::mime_type: the Host refuses anything not listed there.
9
9
  // cjs/wasm/node: runtime "node" packages only.
10
10
  const TEXT=new Set(['html','js','mjs','cjs','css','json','svg','txt','md']),BINARY=new Set(['png','jpg','jpeg','gif','webp','ico','woff','woff2','ttf','otf','mp3','wav','mp4','webm','wasm','node']);
@@ -47,14 +47,14 @@ export function archive(manifest,files,privateKey){
47
47
  export async function pack(directory,keyFile,output,options={}){
48
48
  const source=path.resolve(directory),manifest=JSON.parse(await readFile(path.join(source,'manifest.json'),'utf8'));
49
49
  const {build}=await import('esbuild');
50
- const sdk=sdkEntry(source);
50
+ const sdk=sdkResolver(source);
51
51
  // runtime "node": bundled for Node (built-ins stay imports; ESM output gets a
52
52
  // `require` for bundled CommonJS dependencies). Native addons and other files
53
53
  // the code loads at run time go in `resources/`.
54
54
  const node=manifest.runtime==='node',cjs=node&&manifest.worker.endsWith('.cjs');
55
55
  const result=await build({entryPoints:[path.join(source,'worker.ts')],bundle:true,write:false,format:cjs?'cjs':'esm',platform:node?'node':'browser',target:node?'node22':'es2022',
56
56
  ...(node&&!cjs?{banner:{js:"import{createRequire as __medeoRequire}from'node:module';const require=__medeoRequire(import.meta.url);"}}:{}),
57
- alias:{'@medeo-plugin/sdk':sdk},logLevel:'silent',absWorkingDir:source});
57
+ plugins:[sdk],logLevel:'silent',absWorkingDir:source});
58
58
  const files={[manifest.worker]:Buffer.from(result.outputFiles[0].text)};
59
59
  async function collect(dir,prefix){for(const item of await readdir(dir)){const full=path.join(dir,item),stat=await lstat(full);if(stat.isSymbolicLink())throw Error('Symlinks are not permitted');const relative=prefix+item;if(stat.isDirectory())await collect(full,relative+'/');else {if(stat.size>LIMITS.size)throw Error('File too large');const kind=assetKind(relative);if(!kind)throw Error('Unsupported asset type: '+relative);files[relative]=kind==='binary'?await readFile(full):Buffer.from((await readFile(full,'utf8')).replace(/\r\n/g,'\n'))}}}
60
60
  // ui/ also holds the pages of app_windows windows, so it is packed even without a primary window.
@@ -65,7 +65,7 @@ export async function pack(directory,keyFile,output,options={}){
65
65
  if(entries.length>1)throw Error('Keep one UI entry: ui/entry.ts or ui/entry.tsx');
66
66
  if(entries.length){
67
67
  // Imported .css files arrive as text, so a shared module can inject its own styles.
68
- const ui=await build({entryPoints:[path.join(source,entries[0])],bundle:true,write:false,format:'esm',platform:'browser',loader:{'.css':'text'},jsx:'automatic',alias:{'@medeo-plugin/sdk':sdk},target:'es2022',logLevel:'silent',absWorkingDir:source});
68
+ const ui=await build({entryPoints:[path.join(source,entries[0])],bundle:true,write:false,format:'esm',platform:'browser',loader:{'.css':'text'},jsx:'automatic',plugins:[sdk],target:'es2022',logLevel:'silent',absWorkingDir:source});
69
69
  files['ui/index.js']=Buffer.from(ui.outputFiles[0].text);delete files[entries[0]];
70
70
  }
71
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medeo-plugin/cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Command line for Medeo desktop plugins: scaffold, build, sign, check and live-reload a plugin in a running Medeo.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "prepack": "node ../../scripts/stage-cli.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "@medeo-plugin/sdk": "0.7.0",
43
+ "@medeo-plugin/sdk": "0.8.0",
44
44
  "esbuild": "^0.25.0"
45
45
  },
46
46
  "engines": {
@@ -1,10 +1,12 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { commands, i18n, storage } from '@medeo-plugin/sdk';
3
+ import { Card, Pill, EmptyState, useUIStyles } from '@medeo-plugin/sdk/ui/react';
3
4
 
4
5
  interface Copy { title: string; count: string; increment: string }
5
6
 
6
7
  /** Reads the counter and the plugin's own wording; the worker's command changes the counter. */
7
8
  export function App() {
9
+ useUIStyles();
8
10
  const [count, setCount] = useState<number | null>(null);
9
11
  const [copy, setCopy] = useState<Copy | null>(null);
10
12
  const [error, setError] = useState('');
@@ -19,8 +21,9 @@ export function App() {
19
21
  setCount(current);
20
22
  }
21
23
  useEffect(() => {
24
+ const off = i18n.onDidChangeLocale(() => void load().catch(e => setError(String(e))));
22
25
  void load().catch(e => setError(String(e)));
23
- return i18n.onDidChangeLocale(() => void load().catch(e => setError(String(e))));
26
+ return off;
24
27
  }, []);
25
28
  async function increment() {
26
29
  setBusy(true);
@@ -30,11 +33,13 @@ export function App() {
30
33
  }
31
34
  return (
32
35
  <main>
33
- <small>Medeo · React</small>
34
- <h1 data-testid="drive.react.title">{copy?.title ?? ''}</h1>
35
- <output data-testid="drive.react.count" aria-live="polite">{copy?.count ?? ''}</output>
36
- <button className="primary" data-testid="drive.react.increment" disabled={busy || count === null} onClick={() => void increment()}>{copy?.increment ?? '+1'}</button>
37
- {error && <pre role="alert">{error}</pre>}
36
+ {copy && <Card title={copy.title} testid="drive.react.card">
37
+ <output data-testid="drive.react.count" aria-live="polite">
38
+ <Pill label={copy.count} testid="drive.react.count-pill" />
39
+ </output>
40
+ <button className="primary" data-testid="drive.react.increment" disabled={busy || count === null} onClick={() => void increment()}>{copy.increment}</button>
41
+ </Card>}
42
+ {error && <div role="alert"><EmptyState title={error} testid="drive.react.error" /></div>}
38
43
  </main>
39
44
  );
40
45
  }
@@ -1 +1,3 @@
1
- main{max-width:560px;margin:auto;padding:32px;display:grid;gap:20px}main small{color:var(--color-schemes-primary)}h1{margin:8px 0}output{font-size:36px}pre{white-space:pre-wrap;font-size:12px;color:var(--color-surface-on-surface-variant)}button{justify-self:start}
1
+ main{max-width:560px;margin:auto;padding:var(--space-s7);display:grid;gap:var(--space-s5)}
2
+ main button{justify-self:start}
3
+ main [role=alert]{overflow-wrap:anywhere}
package/tooling.mjs CHANGED
@@ -64,8 +64,8 @@ export async function init(directory, id, { template = 'vanilla' } = {}) {
64
64
  const name = id.split('.').pop();
65
65
  files['package.json'] = JSON.stringify({
66
66
  name, version: '1.0.0', private: true, type: 'module',
67
- medeo: { plugin: `dist/${id}-1.0.0.medeoplugin` },
68
- scripts: { dev: 'medeo-plugin dev', pack: 'medeo-plugin pack --key ../' + name + '-release-key.pem', check: `medeo-plugin check dist/${id}-1.0.0.medeoplugin`, typecheck: 'tsc -p tsconfig.json', test: 'node --test tests/*.test.mjs' },
67
+ medeo: { plugin: `dist/${id}.medeoplugin` },
68
+ scripts: { dev: 'medeo-plugin dev', pack: 'medeo-plugin pack --key ../' + name + `-release-key.pem --out dist/${id}.medeoplugin`, check: `medeo-plugin check dist/${id}.medeoplugin`, typecheck: 'tsc -p tsconfig.json', test: 'node --test tests/*.test.mjs' },
69
69
  devDependencies: { '@medeo-plugin/sdk': `^${PACKAGE_VERSION}`, '@medeo-plugin/cli': `^${PACKAGE_VERSION}`, typescript: '^5.6.0', '@types/node': '^22.0.0',
70
70
  ...(react ? { react: '^19.0.0', 'react-dom': '^19.0.0', '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0' } : {}) },
71
71
  }, null, 2) + '\n';
@@ -123,8 +123,11 @@ export async function develop(directory, key, output, { once = false, signal, on
123
123
  if (destination.startsWith(source + path.sep) || path.resolve(key).startsWith(source + path.sep)) throw new Error('Keep the output and signing key outside the source directory');
124
124
  await mkdir(path.dirname(destination), { recursive: true });
125
125
  const temporary = destination + `.pending-${process.pid}`;
126
- let previous = '', building = false, again = false;
126
+ if (signal?.aborted) return;
127
+ let previous = '', building = false, again = false, stopped = false, active;
128
+ let timer = null;
127
129
  const build = async () => {
130
+ if (stopped || signal?.aborted) return;
128
131
  if (building) { again = true; return; }
129
132
  building = true;
130
133
  try {
@@ -134,6 +137,7 @@ export async function develop(directory, key, output, { once = false, signal, on
134
137
  await pack(source, key, temporary, { development: true });
135
138
  // Invalid builds never replace the last installable package.
136
139
  const info = await check(temporary);
140
+ if (stopped || signal?.aborted) return;
137
141
  if (info.digest !== previous) {
138
142
  await rename(temporary, destination);
139
143
  previous = info.digest;
@@ -146,25 +150,35 @@ export async function develop(directory, key, output, { once = false, signal, on
146
150
  if (message !== previous) console.error(message);
147
151
  previous = message;
148
152
  } finally { await rm(temporary, { force: true }); }
149
- } while (again);
153
+ } while (again && !stopped && !signal?.aborted);
150
154
  } finally { building = false; }
151
155
  };
152
- await build();
153
- if (once) return;
154
- // Changes arrive in bursts (editors write several files, build tools rewrite
155
- // directories); one rebuild 150 ms after the last change covers them all.
156
- let timer = null;
156
+ if (once) { await build(); return; }
157
+ // Watch before the initial build: confirmation dialogs may take minutes.
157
158
  const IGNORED = new Set(['node_modules', '.git', 'dist']);
159
+ let resolveStopped;
160
+ const finished = new Promise(resolve => { resolveStopped = resolve; });
158
161
  const watcher = watch(source, { recursive: true }, (_event, name) => {
159
162
  const top = name?.toString().split(/[\\/]/)[0];
160
163
  if (top && IGNORED.has(top)) return;
164
+ if (building) again = true;
161
165
  if (timer) clearTimeout(timer);
162
- timer = setTimeout(() => { timer = null; void build(); }, 150);
163
- });
164
- await new Promise(resolve => {
165
- const stop = () => { watcher.close(); if (timer) clearTimeout(timer); resolve(); };
166
- watcher.on('error', stop);
167
- if (signal?.aborted) stop(); else signal?.addEventListener('abort', stop, { once: true });
168
- if (!signal) { process.once('SIGINT', stop); process.once('SIGTERM', stop); }
166
+ timer = setTimeout(() => { timer = null; if (building) again = true; else active = build(); }, 150);
169
167
  });
168
+ const stop = () => { stopped = true; watcher.close(); if (timer) clearTimeout(timer); resolveStopped(); };
169
+ watcher.on('error', error => { resolveStopped(error); stop(); });
170
+ signal?.addEventListener('abort', stop, { once: true });
171
+ if (!signal) { process.once('SIGINT', stop); process.once('SIGTERM', stop); }
172
+ try {
173
+ if (signal?.aborted) stop();
174
+ active = build();
175
+ await active;
176
+ const watchError = await finished;
177
+ if (watchError) throw watchError;
178
+ } finally {
179
+ stop();
180
+ signal?.removeEventListener('abort', stop);
181
+ process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
182
+ await active;
183
+ }
170
184
  }