@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 +11 -4
- package/cli.mjs +2 -1
- package/dev.mjs +27 -6
- package/locate.mjs +43 -17
- package/pack.mjs +46 -5
- package/package.json +5 -2
- package/templates/react/README.md +1 -1
- package/templates/react/src/ui/App.tsx +11 -6
- package/templates/react/ui/style.css +3 -1
- package/tooling.mjs +30 -16
- package/verifier/medeo-verify.wasm +0 -0
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
|
|
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', () => {
|
|
48
|
-
socket.on('error', 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 === '
|
|
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(); },
|
|
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) {
|
|
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
|
-
//
|
|
2
|
-
//
|
|
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 =
|
|
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, '
|
|
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 (!
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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(
|
|
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 {
|
|
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;
|
|
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=
|
|
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
|
-
|
|
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',
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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:
|
|
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}
|
|
68
|
-
scripts: { dev: 'medeo-plugin dev', pack: 'medeo-plugin pack --key ../' + name +
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|