@medeo-plugin/cli 0.7.0 → 0.9.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
@@ -1,37 +1,63 @@
1
- // Where the CLI finds its own files. Installed from npm, the verifier and the
2
- // templates sit inside the package (`scripts/stage-cli.mjs` puts them there);
3
- // in this repository they are read from the checkout, so tests and the release
4
- // script need no staging step.
1
+ // Templates live beside the CLI in both source and published installations.
2
+ // The verifier is read from spec in the checkout, or the staged npm copy.
5
3
  import { createRequire } from 'node:module';
6
- import { existsSync } from 'node:fs';
4
+ import { existsSync, readFileSync } from 'node:fs';
7
5
  import path from 'node:path';
8
6
  import { fileURLToPath } from 'node:url';
9
7
 
10
8
  const here = path.dirname(fileURLToPath(import.meta.url));
11
9
  const repo = path.resolve(here, '../..');
12
- const TEMPLATES = { vanilla: 'examples/i18n-demo', react: 'examples/react-demo' };
10
+ const TEMPLATES = ['vanilla', 'react'];
13
11
 
14
12
  export function verifierPath() {
15
13
  // In this repository the checkout's copy is the current one; a published package only has its own.
16
- for (const candidate of [path.join(repo, 'verifier/medeo-verify.wasm'), path.join(here, 'verifier/medeo-verify.wasm')]) if (existsSync(candidate)) return candidate;
14
+ for (const candidate of [path.join(repo, 'spec/medeo-verify.wasm'), path.join(here, 'verifier/medeo-verify.wasm')]) if (existsSync(candidate)) return candidate;
17
15
  throw new Error('The package verifier (verifier/medeo-verify.wasm) is missing from this installation');
18
16
  }
19
17
  export function templatePath(name) {
20
- if (!Object.hasOwn(TEMPLATES, name)) throw new Error(`Unknown template "${name}"; use ${Object.keys(TEMPLATES).join(' or ')}`);
21
- for (const candidate of [path.join(repo, TEMPLATES[name]), path.join(here, 'templates', name)]) if (existsSync(candidate)) return candidate;
18
+ if (!TEMPLATES.includes(name)) throw new Error(`Unknown template "${name}"; use ${TEMPLATES.join(' or ')}`);
19
+ const candidate = path.join(here, 'templates', name);
20
+ if (existsSync(candidate)) return candidate;
22
21
  throw new Error(`Template "${name}" is missing from this installation`);
23
22
  }
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) {
23
+ 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' };
24
+ /** Resolve only public, known entry points. Select the project's SDK package once:
25
+ * a missing export in its pinned version must not fall back to a different SDK. */
26
+ export function sdkEntry(source, specifier = '@medeo-plugin/sdk') {
27
+ if (!Object.hasOwn(SDK_ENTRIES, specifier)) throw new Error(`Unsupported SDK entry: ${specifier}`);
27
28
  const candidates = [];
28
29
  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 {}
30
+ // Reading package metadata must work even when package.json itself is not
31
+ // exported. Public entry permissions are checked below before choosing code.
32
+ const roots = createRequire(from).resolve.paths('@medeo-plugin/sdk') ?? [];
33
+ const installed = roots.map(root => path.join(root, '@medeo-plugin/sdk')).find(dir => existsSync(path.join(dir, 'package.json')));
34
+ if (installed) candidates.push(installed);
30
35
  }
31
36
  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);
37
+ const dir = candidates.find(dir => existsSync(path.join(dir, 'package.json')));
38
+ if (!dir) throw new Error('@medeo-plugin/sdk was not found; run npm install in the plugin project');
39
+ const manifest = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
40
+ const subpath = specifier === '@medeo-plugin/sdk' ? '.' : '.' + specifier.slice('@medeo-plugin/sdk'.length);
41
+ const exported = manifest.exports?.[subpath];
42
+ if (!exported) throw new Error(`${specifier} is not exported by the plugin's SDK (${manifest.version}); update its dependency`);
43
+ const target = typeof exported === 'string' ? exported : exported.import ?? exported.default;
44
+ const name = SDK_ENTRIES[specifier];
45
+ // Source is shipped for bundling; dist-only installations work too.
46
+ for (const entry of [`src/${name}.ts`, ...(typeof target === 'string' && target.startsWith('./') ? [target] : [])]) {
47
+ const resolved = path.resolve(dir, entry);
48
+ if (resolved.startsWith(dir + path.sep) && existsSync(resolved)) return resolved;
35
49
  }
36
- throw new Error('@medeo-plugin/sdk was not found; run npm install in the plugin project');
50
+ throw new Error(`${specifier} is missing from the plugin's SDK installation`);
51
+ }
52
+
53
+ /** esbuild aliases match prefixes; an onResolve hook makes subpaths exact and
54
+ * prevents private paths such as sdk/src/index.ts from escaping exports. */
55
+ export function sdkResolver(source) {
56
+ const resolved = new Map();
57
+ return { name: 'medeo-sdk', setup(build) {
58
+ build.onResolve({ filter: /^@medeo-plugin\/sdk(?:\/.*)?$/ }, args => {
59
+ if (!resolved.has(args.path)) resolved.set(args.path, sdkEntry(source, args.path));
60
+ return { path: resolved.get(args.path) };
61
+ });
62
+ } };
37
63
  }
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']);
@@ -13,7 +13,23 @@ const STORED=new Set(['png','jpg','jpeg','gif','webp','woff','woff2','mp3','mp4'
13
13
  // Mirrors platform_contract: archive and unpacked size, file count, index entry.
14
14
  export const LIMITS={size:256*1024*1024,files:4096},INDEX_ENTRY='_package.json';
15
15
  // Mirrors platform_core::ICON_PATHS / MAX_ICON / validate_icon.
16
- export const ICON_PATHS=['icon.svg','icon.png'],MAX_ICON=256*1024;const PNG=Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
16
+ export const ICON_PATHS=['icon.svg','icon.png'],MAX_ICON=256*1024;
17
+ // Mirrors platform_core::parse_about; Medeo drops an invalid field silently, so pack refuses it here.
18
+ export const ABOUT_FILE='about.json',MAX_ABOUT=16*1024;
19
+ export function checkAbout(text){
20
+ let value;try{value=JSON.parse(text)}catch{return ['not valid JSON']}
21
+ if(!value||typeof value!=='object'||Array.isArray(value))return ['must be a JSON object'];
22
+ const problems=[],plain=(v,max)=>typeof v==='string'&&v.trim()!==''&&[...v].length<=max&&!/[\u0000-\u001f\u007f-\u009f]/.test(v);
23
+ const link=v=>{if(typeof v!=='string'||v.length>2048||/[\s\u0000-\u001f]/.test(v)||!v.startsWith('https://'))return false;const host=v.slice(8).split(/[/?#]/)[0];return host!==''&&!host.includes('@')};
24
+ for(const key of Object.keys(value))if(!['author','license','homepage','repository','bugs'].includes(key))problems.push(`unknown field ${key}`);
25
+ if('author' in value&&!plain(value.author,80))problems.push('author must be one line of at most 80 characters');
26
+ if('license' in value&&!plain(value.license,64))problems.push('license must be one line of at most 64 characters');
27
+ for(const key of ['homepage','repository','bugs'])if(key in value&&!link(value[key]))problems.push(`${key} must be an https:// link`);
28
+ return problems;
29
+ }
30
+ export const README=/^README(\.[A-Za-z0-9_-]{1,16})?\.md$/,MAX_README=256*1024;
31
+ // Screenshots a README shows, under media/: images only, each ≤ 2 MiB and ≤ 8 MiB together.
32
+ export const MEDIA=/^media\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+\.(?:png|jpe?g|gif|webp)$/i,MAX_MEDIA=2*1024*1024,MAX_MEDIA_TOTAL=8*1024*1024;const PNG=Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
17
33
  const UI_ENTRIES=['ui/entry.ts','ui/entry.tsx'];
18
34
  function assetKind(file){const ext=file.split('.').pop().toLowerCase();return TEXT.has(ext)?'text':BINARY.has(ext)?'binary':UI_ENTRIES.includes(file)?'text':null}
19
35
  const sha256=bytes=>createHash('sha256').update(bytes).digest('hex');
@@ -47,14 +63,14 @@ export function archive(manifest,files,privateKey){
47
63
  export async function pack(directory,keyFile,output,options={}){
48
64
  const source=path.resolve(directory),manifest=JSON.parse(await readFile(path.join(source,'manifest.json'),'utf8'));
49
65
  const {build}=await import('esbuild');
50
- const sdk=sdkEntry(source);
66
+ const sdk=sdkResolver(source);
51
67
  // runtime "node": bundled for Node (built-ins stay imports; ESM output gets a
52
68
  // `require` for bundled CommonJS dependencies). Native addons and other files
53
69
  // the code loads at run time go in `resources/`.
54
70
  const node=manifest.runtime==='node',cjs=node&&manifest.worker.endsWith('.cjs');
55
71
  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
72
  ...(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});
73
+ plugins:[sdk],logLevel:'silent',absWorkingDir:source});
58
74
  const files={[manifest.worker]:Buffer.from(result.outputFiles[0].text)};
59
75
  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
76
  // ui/ also holds the pages of app_windows windows, so it is packed even without a primary window.
@@ -65,7 +81,7 @@ export async function pack(directory,keyFile,output,options={}){
65
81
  if(entries.length>1)throw Error('Keep one UI entry: ui/entry.ts or ui/entry.tsx');
66
82
  if(entries.length){
67
83
  // 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});
84
+ 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
85
  files['ui/index.js']=Buffer.from(ui.outputFiles[0].text);delete files[entries[0]];
70
86
  }
71
87
  }
@@ -78,6 +94,31 @@ export async function pack(directory,keyFile,output,options={}){
78
94
  if(manifest.icon==='icon.png'?!bytes.subarray(0,8).equals(PNG):!bytes.toString('utf8').includes('<svg'))throw Error('icon does not match its type: '+manifest.icon);
79
95
  files[manifest.icon]=bytes;
80
96
  }
97
+ // The introduction Medeo's plugin center shows: README.md and README.<locale>.md at the root.
98
+ for(const item of (await readdir(source)).sort()){
99
+ if(!README.test(item))continue;
100
+ const full=path.join(source,item),stat=await lstat(full);
101
+ if(stat.isSymbolicLink()||!stat.isFile()||stat.size>MAX_README)throw Error(`${item} must be a regular file of at most 256 KiB`);
102
+ files[item]=Buffer.from((await readFile(full,'utf8')).replace(/\r\n/g,'\n'));
103
+ }
104
+ // About: author, license and https links for the plugin detail. A separate file rather than
105
+ // manifest fields, so every Medeo installs the package (older ones simply ignore it).
106
+ const aboutFile=path.join(source,ABOUT_FILE),aboutStat=await lstat(aboutFile).catch(()=>null);
107
+ if(aboutStat){
108
+ if(aboutStat.isSymbolicLink()||!aboutStat.isFile()||aboutStat.size>MAX_ABOUT)throw Error(`${ABOUT_FILE} must be a regular file of at most 16 KiB`);
109
+ const text=(await readFile(aboutFile,'utf8')).replace(/\r\n/g,'\n');
110
+ const problems=checkAbout(text);if(problems.length)throw Error(`${ABOUT_FILE}: ${problems.join('; ')}`);
111
+ files[ABOUT_FILE]=Buffer.from(text);
112
+ }
113
+ if(await access(path.join(source,'media')).then(()=>true,()=>false)){
114
+ await collect(path.join(source,'media'),'media/');
115
+ let total=0;
116
+ for(const p of Object.keys(files).filter(p=>p.startsWith('media/'))){
117
+ if(!MEDIA.test(p))throw Error('media/ holds only png, jpg, gif or webp images: '+p);
118
+ if(files[p].length>MAX_MEDIA)throw Error(p+' is larger than 2 MiB');total+=files[p].length;
119
+ }
120
+ if(total>MAX_MEDIA_TOTAL)throw Error('media/ is larger than 8 MiB in total');
121
+ }
81
122
  if(node&&await access(path.join(source,'resources')).then(()=>true,()=>false))await collect(path.join(source,'resources'),'resources/');
82
123
  if(manifest.localization){
83
124
  for(const relative of Object.values(manifest.localization.catalogs??{})){
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@medeo-plugin/cli",
3
- "version": "0.7.0",
3
+ "version": "0.9.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": {
@@ -33,6 +33,9 @@
33
33
  "!*.test.mjs",
34
34
  "verifier/",
35
35
  "templates/",
36
+ "!templates/**/tests/**",
37
+ "!templates/**/dist/**",
38
+ "!templates/**/node_modules/**",
36
39
  "README.md"
37
40
  ],
38
41
  "scripts": {
@@ -40,7 +43,7 @@
40
43
  "prepack": "node ../../scripts/stage-cli.mjs"
41
44
  },
42
45
  "dependencies": {
43
- "@medeo-plugin/sdk": "0.7.0",
46
+ "@medeo-plugin/sdk": "0.9.0",
44
47
  "esbuild": "^0.25.0"
45
48
  },
46
49
  "engines": {
@@ -1,4 +1,4 @@
1
1
  # React counter
2
2
 
3
- The `init --template react` template: the same counter as `i18n-demo`, with the window written in React.
3
+ The `init --template react` template: the same counter as the vanilla template, with the window written in React.
4
4
  `ui/entry.tsx` is the only compiled UI entry; components live in `src/ui/` so nothing unbuilt is packaged.
@@ -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
  }
Binary file