@medeo-plugin/cli 0.0.0-stage → 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/README.md +19 -2
- package/app.mjs +91 -0
- package/cli.mjs +41 -0
- package/dev.mjs +121 -0
- package/keys.mjs +30 -0
- package/locate.mjs +64 -0
- package/pack.mjs +105 -0
- package/package.json +47 -4
- package/templates/react/README.md +4 -0
- package/templates/react/icon.svg +16 -0
- package/templates/react/locales/en.json +7 -0
- package/templates/react/locales/zh-CN.json +7 -0
- package/templates/react/manifest.json +26 -0
- package/templates/react/src/ui/App.tsx +45 -0
- package/templates/react/ui/entry.tsx +5 -0
- package/templates/react/ui/index.html +1 -0
- package/templates/react/ui/style.css +3 -0
- package/templates/react/worker.ts +12 -0
- package/templates/vanilla/icon.svg +16 -0
- package/templates/vanilla/locales/de.json +8 -0
- package/templates/vanilla/locales/en.json +8 -0
- package/templates/vanilla/locales/es.json +8 -0
- package/templates/vanilla/locales/fr.json +8 -0
- package/templates/vanilla/locales/ja.json +8 -0
- package/templates/vanilla/locales/ko.json +8 -0
- package/templates/vanilla/locales/pt-BR.json +8 -0
- package/templates/vanilla/locales/zh-CN.json +8 -0
- package/templates/vanilla/locales/zh-TW.json +8 -0
- package/templates/vanilla/manifest.json +33 -0
- package/templates/vanilla/ui/entry.ts +12 -0
- package/templates/vanilla/ui/index.html +1 -0
- package/templates/vanilla/ui/style.css +1 -0
- package/templates/vanilla/worker.ts +2 -0
- package/tooling.mjs +184 -0
- package/verifier/medeo-verify.wasm +0 -0
- package/zip.mjs +57 -0
package/README.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @medeo-plugin/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`medeo-plugin` builds, signs, checks and live-reloads [Medeo desktop plugins](https://github.com/one2x-ai/medeo-desktop-plugin-sdk).
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm create medeo-plugin@latest my-plugin -- --id com.example.my-plugin
|
|
7
|
+
cd my-plugin && npm install
|
|
8
|
+
npm run dev # build on every change and load it into a Medeo in developer mode
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Command | What it does |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `medeo-plugin init DIR --id ID [--template vanilla\|react]` | Scaffold a project (also `npm create medeo-plugin`) |
|
|
14
|
+
| `medeo-plugin dev [DIR] [--once] [--no-follow] [--channel stg\|prd]` | Rebuild a signed development package on change and have Medeo load it |
|
|
15
|
+
| `medeo-plugin pack [DIR] --key KEY.pem [--out FILE]` | Build and sign a release package |
|
|
16
|
+
| `medeo-plugin check PACKAGE` | Verify a package with Medeo's own rules (bundled WebAssembly verifier) |
|
|
17
|
+
| `medeo-plugin keygen KEY.pem` | Create a release signing key (keep it outside the project) |
|
|
18
|
+
| `medeo-plugin status \| logs \| run \| open \| stop [ID …]` | Talk to the running Medeo's developer channel |
|
|
19
|
+
|
|
20
|
+
Development builds are signed with a per-machine key in `~/.medeo/plugin-dev/dev-key.pem`; releases use your own key.
|
package/app.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { connect } from 'node:net';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
// Talks to a Medeo started with MEDEO_PLUGIN_DEV=1 (its developer-mode control
|
|
7
|
+
// channel): what it did with a development build, its logs, reloads and commands.
|
|
8
|
+
// Medeo writes <app data>/medeo-desktop-electron-<channel>/plugin-dev/control.json
|
|
9
|
+
// naming a socket only this user can open. MEDEO_PLUGIN_DEV_CONTROL names one directly.
|
|
10
|
+
export const PROTOCOL = 1;
|
|
11
|
+
const TIMEOUT_MS = 60_000;
|
|
12
|
+
|
|
13
|
+
function appData() {
|
|
14
|
+
if (process.platform === 'darwin') return path.join(homedir(), 'Library/Application Support');
|
|
15
|
+
if (process.platform === 'win32') return process.env.APPDATA ?? path.join(homedir(), 'AppData/Roaming');
|
|
16
|
+
return process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config');
|
|
17
|
+
}
|
|
18
|
+
const alive = pid => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
19
|
+
|
|
20
|
+
/** The running Medeo to talk to; `channel` picks one when STG and a local build both run. */
|
|
21
|
+
export async function locate({ channel } = {}) {
|
|
22
|
+
const read = async file => JSON.parse(await readFile(file, 'utf8'));
|
|
23
|
+
if (process.env.MEDEO_PLUGIN_DEV_CONTROL) return read(process.env.MEDEO_PLUGIN_DEV_CONTROL);
|
|
24
|
+
const root = appData();
|
|
25
|
+
const names = (await readdir(root).catch(() => [])).filter(n => n.startsWith('medeo-desktop-electron-'));
|
|
26
|
+
const found = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
const control = await read(path.join(root, name, 'plugin-dev/control.json')).catch(() => null);
|
|
29
|
+
if (control && alive(control.pid) && (!channel || control.channel === channel)) found.push(control);
|
|
30
|
+
}
|
|
31
|
+
if (found.length === 1) return found[0];
|
|
32
|
+
if (!found.length) throw new Error('No Medeo in developer mode is running. Turn on Developer mode on the Developers page of the plugin center, or start Medeo with MEDEO_PLUGIN_DEV=1' + (channel ? ` (channel ${channel})` : ''));
|
|
33
|
+
throw new Error(`Several Medeo apps are in developer mode (${found.map(c => c.channel).join(', ')}); pass --channel`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One request, one reply; Medeo's errors are thrown with its own message. */
|
|
37
|
+
export async function request(op, args = {}, options = {}) {
|
|
38
|
+
const signal = options.signal;
|
|
39
|
+
signal?.throwIfAborted();
|
|
40
|
+
const control = options.control ?? await locate(options);
|
|
41
|
+
signal?.throwIfAborted();
|
|
42
|
+
if (control.protocol !== PROTOCOL) throw new Error(`Medeo speaks control protocol ${control.protocol}; this toolchain speaks ${PROTOCOL}. Update one of them`);
|
|
43
|
+
const line = await new Promise((resolve, reject) => {
|
|
44
|
+
const socket = connect(control.socket);
|
|
45
|
+
let buffer = '';
|
|
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 });
|
|
50
|
+
socket.setEncoding('utf8');
|
|
51
|
+
socket.on('connect', () => socket.write(JSON.stringify({ protocol: PROTOCOL, op, ...args }) + '\n'));
|
|
52
|
+
socket.on('data', chunk => { buffer += chunk; });
|
|
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); });
|
|
55
|
+
});
|
|
56
|
+
const reply = JSON.parse(line);
|
|
57
|
+
if (!reply.ok) throw new Error(reply.error);
|
|
58
|
+
return reply.result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `cli.mjs app …`: prints the reply as JSON, the form a coding agent reads. */
|
|
62
|
+
/** The plugin ID of the project in the current directory, for the direct commands (`medeo-plugin logs`). */
|
|
63
|
+
async function projectId() {
|
|
64
|
+
try { return JSON.parse(await readFile(path.join(process.cwd(), 'manifest.json'), 'utf8')).id; }
|
|
65
|
+
catch { throw new Error('Pass the plugin ID, or run this inside a plugin project (no manifest.json here)'); }
|
|
66
|
+
}
|
|
67
|
+
export async function main(args, { defaultId = false } = {}) {
|
|
68
|
+
const options = {};
|
|
69
|
+
const rest = [];
|
|
70
|
+
for (let i = 0; i < args.length; i++) {
|
|
71
|
+
if (args[i] === '--channel') options.channel = args[++i];
|
|
72
|
+
else if (args[i] === '--since') options.since = Number(args[++i]);
|
|
73
|
+
else rest.push(args[i]);
|
|
74
|
+
}
|
|
75
|
+
const [op, ...operands] = rest;
|
|
76
|
+
// `medeo-plugin logs` / `run COMMAND` / `open` / `stop` inside a project name the plugin themselves.
|
|
77
|
+
if (defaultId && ((op === 'logs' || op === 'open' || op === 'stop') && !operands.length || op === 'run' && operands.length === 1)) operands.unshift(await projectId());
|
|
78
|
+
const [first, second] = operands;
|
|
79
|
+
let result;
|
|
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);
|
|
82
|
+
else if (op === 'logs' && operands.length === 1) result = await request('logs', { id: first, ...(options.since ? { since: options.since } : {}) }, options);
|
|
83
|
+
else if ((op === 'follow' || op === 'reload' || op === 'unfollow') && operands.length === 1)
|
|
84
|
+
result = await request(op === 'unfollow' ? 'unfollow' : 'follow', { file: path.resolve(first) }, options);
|
|
85
|
+
else if (op === 'run' && operands.length === 2) result = await request('run', { id: first, command: second }, options);
|
|
86
|
+
else if ((op === 'open' || op === 'stop') && operands.length === 1) result = await request(op, { id: first }, options);
|
|
87
|
+
else throw new Error('Usage: medeo-plugin status | logs [ID] [--since MS] | follow PACKAGE | reload PACKAGE | unfollow PACKAGE | run [ID] COMMAND | open [ID] | stop [ID] [--channel stg|prd]');
|
|
88
|
+
console.log(JSON.stringify(result, null, 2));
|
|
89
|
+
// A build Medeo refused is a failure the caller should see in the exit code.
|
|
90
|
+
if ((op === 'follow' || op === 'reload') && result?.error) process.exitCode = 2;
|
|
91
|
+
}
|
package/cli.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `medeo-plugin`: scaffold, build, sign, check and live-reload Medeo desktop plugins.
|
|
3
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { realpathSync } from 'node:fs';
|
|
8
|
+
export { pack } from './pack.mjs';
|
|
9
|
+
|
|
10
|
+
const USAGE = `Usage: medeo-plugin <command>
|
|
11
|
+
|
|
12
|
+
init DIR --id ID [--template vanilla|react] scaffold a plugin project (also: npm create medeo-plugin)
|
|
13
|
+
dev [DIR] [--once] [--no-follow] [--key K] [--out F] [--channel stg|prd]
|
|
14
|
+
build a signed development package on every change and load it into Medeo
|
|
15
|
+
pack [DIR] --key KEY.pem [--out FILE] build and sign a release package (default dist/<id>-<version>.medeoplugin)
|
|
16
|
+
check PACKAGE verify a package with Medeo's rules; prints its manifest, publisher and digest
|
|
17
|
+
keygen KEY.pem create a release signing key (keep it outside the project)
|
|
18
|
+
status | logs ID [--since MS] | run ID COMMAND | open ID | stop ID | follow PACKAGE | unfollow PACKAGE
|
|
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)
|
|
21
|
+
app <the same> older spelling of the commands above
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
export async function main(argv) {
|
|
25
|
+
const [command, ...args] = argv;
|
|
26
|
+
if (command === 'keygen' && args.length === 1) {
|
|
27
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
28
|
+
await writeFile(args[0], privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' });
|
|
29
|
+
} else if (command === 'pack') console.error(await (await import('./pack.mjs')).packCommand(args));
|
|
30
|
+
else if (command === 'init') await (await import('./tooling.mjs')).initCommand(args);
|
|
31
|
+
else if (command === 'check' && args.length === 1) console.log(JSON.stringify(await (await import('./tooling.mjs')).check(args[0]), null, 2));
|
|
32
|
+
else if (command === 'dev') await (await import('./dev.mjs')).devCommand(args);
|
|
33
|
+
else if (command === 'app') await (await import('./app.mjs')).main(args);
|
|
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 });
|
|
35
|
+
else { console.error(USAGE); process.exitCode = 1; }
|
|
36
|
+
}
|
|
37
|
+
// Compared through realpath: npm's bin links and macOS's /var → /private/var both point here.
|
|
38
|
+
const invoked = p => { try { return realpathSync(p); } catch { return path.resolve(p); } };
|
|
39
|
+
if (process.argv[1] && invoked(process.argv[1]) === invoked(fileURLToPath(import.meta.url))) {
|
|
40
|
+
await main(process.argv.slice(2)).catch(error => { console.error(error.message); process.exitCode = process.exitCode || 1; });
|
|
41
|
+
}
|
package/dev.mjs
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// `medeo-plugin dev [DIR]`: one command for the whole loop. Builds a signed
|
|
2
|
+
// development package whenever the source changes and asks the running Medeo
|
|
3
|
+
// (developer mode) to load it. Nothing here bypasses Medeo: a new plugin or
|
|
4
|
+
// changed permissions still wait for the user's confirmation in Medeo's dialog.
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { develop } from './tooling.mjs';
|
|
8
|
+
import { ensureDevKey, devBuildPath } from './keys.mjs';
|
|
9
|
+
import { locate, request } from './app.mjs';
|
|
10
|
+
|
|
11
|
+
const RETRY_MS = 5000;
|
|
12
|
+
export const DEVELOPER_MODE_HINT = 'Medeo is not in developer mode. In Medeo: menu bar icon → Plugin entry → Developers → turn on Developer mode (or start Medeo with MEDEO_PLUGIN_DEV=1). Waiting…';
|
|
13
|
+
|
|
14
|
+
/** `dev [DIR] [--once] [--no-follow] [--key K] [--out F] [--channel C]`; the older `dev DIR KEY OUT [--once]` still works. */
|
|
15
|
+
export function parseDevArgs(args) {
|
|
16
|
+
const options = { directory: process.cwd(), key: null, output: null, channel: undefined, once: false, follow: true };
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const arg = args[i];
|
|
20
|
+
if (arg === '--once') options.once = true;
|
|
21
|
+
else if (arg === '--no-follow') options.follow = false;
|
|
22
|
+
else if (arg === '--key' || arg === '--out' || arg === '--channel') {
|
|
23
|
+
const value = args[++i];
|
|
24
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
|
|
25
|
+
options[arg === '--key' ? 'key' : arg === '--out' ? 'output' : 'channel'] = value;
|
|
26
|
+
}
|
|
27
|
+
else if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}`);
|
|
28
|
+
else positional.push(arg);
|
|
29
|
+
}
|
|
30
|
+
if (positional.length === 3 && !options.key && !options.output) { [options.directory, options.key, options.output] = positional; options.follow = false; }
|
|
31
|
+
else if (positional.length === 1) options.directory = positional[0];
|
|
32
|
+
else if (positional.length) throw new Error('Usage: medeo-plugin dev [DIR] [--once] [--no-follow] [--key KEY.pem] [--out FILE] [--channel stg|prd]');
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Keeps one development package followed by Medeo. `load()` after each build;
|
|
38
|
+
* the follower retries every 5 s while no Medeo in developer mode is running,
|
|
39
|
+
* and follows again when Medeo restarts (its watches live in memory).
|
|
40
|
+
*/
|
|
41
|
+
export class Follower {
|
|
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
|
+
}
|
|
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() {
|
|
57
|
+
if (this.stopped) return;
|
|
58
|
+
this.pending = true;
|
|
59
|
+
let control;
|
|
60
|
+
try { control = await this.locateFn({ channel: this.channel }); }
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (this.stopped) return;
|
|
63
|
+
if (!this.hinted) { this.warn(DEVELOPER_MODE_HINT); this.hinted = true; }
|
|
64
|
+
return this.retry();
|
|
65
|
+
}
|
|
66
|
+
if (this.stopped) return;
|
|
67
|
+
try {
|
|
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);
|
|
71
|
+
this.pid = control.pid; this.hinted = false; this.pending = false;
|
|
72
|
+
if (result?.error) this.warn(JSON.stringify({ event: 'medeo_refused', error: result.error, version: result.version ?? null }));
|
|
73
|
+
else this.log(JSON.stringify({ event: 'medeo_loaded', version: result?.version ?? null, channel: control.channel }));
|
|
74
|
+
this.retry();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (this.stopped) return;
|
|
77
|
+
this.warn(JSON.stringify({ event: 'medeo_unreachable', error: error.message }));
|
|
78
|
+
this.retry();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
retry() {
|
|
82
|
+
if (this.stopped || this.timer) return;
|
|
83
|
+
this.timer = setTimeout(() => { this.timer = null; void this.check(); }, this.retryMs);
|
|
84
|
+
this.timer.unref?.();
|
|
85
|
+
}
|
|
86
|
+
/** Reloads when a load is still owed or Medeo was restarted since the last one. */
|
|
87
|
+
async check() {
|
|
88
|
+
if (this.stopped) return;
|
|
89
|
+
if (this.inFlight) { this.retry(); return; }
|
|
90
|
+
if (this.pending) return this.load();
|
|
91
|
+
const control = await this.locateFn({ channel: this.channel }).catch(() => null);
|
|
92
|
+
if (control && control.pid !== this.pid) return this.load();
|
|
93
|
+
if (!control) this.pending = true;
|
|
94
|
+
this.retry();
|
|
95
|
+
}
|
|
96
|
+
stop() { this.stopped = true; this.controller.abort(); if (this.timer) clearTimeout(this.timer); this.timer = null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function devCommand(args) {
|
|
100
|
+
const options = parseDevArgs(args);
|
|
101
|
+
const source = path.resolve(options.directory);
|
|
102
|
+
const manifest = JSON.parse(await readFile(path.join(source, 'manifest.json'), 'utf8').catch(() => { throw new Error(`No manifest.json in ${source}; run medeo-plugin dev inside a plugin project or pass its directory`); }));
|
|
103
|
+
const key = options.key ? path.resolve(options.key) : await ensureDevKey();
|
|
104
|
+
const output = path.resolve(options.output ?? devBuildPath(manifest.id));
|
|
105
|
+
const follower = options.follow ? new Follower(output, { channel: options.channel }) : null;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const stop = () => { controller.abort(); follower?.stop(); };
|
|
108
|
+
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
109
|
+
try {
|
|
110
|
+
console.error(`Building ${manifest.id} from ${source}\n key ${key}\n package ${output}${options.once ? '' : '\n watching for changes (Ctrl-C to stop)'}`);
|
|
111
|
+
await develop(source, key, output, { once: options.once, signal: controller.signal, onBuild: () => follower?.load() });
|
|
112
|
+
// `--once` returns after the build; give the follow a chance to finish.
|
|
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
|
+
}
|
|
117
|
+
} finally {
|
|
118
|
+
process.removeListener('SIGINT', stop); process.removeListener('SIGTERM', stop);
|
|
119
|
+
follower?.stop();
|
|
120
|
+
}
|
|
121
|
+
}
|
package/keys.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The per-machine development key. `dev` signs with it, and so does Medeo's
|
|
2
|
+
// "Install from folder", so a plugin keeps one publisher (and its data) however a
|
|
3
|
+
// development build reaches Medeo. Development packages are never distributed;
|
|
4
|
+
// releases are signed with a key the developer creates (`keygen`).
|
|
5
|
+
import { generateKeyPairSync } from 'node:crypto';
|
|
6
|
+
import { mkdir, writeFile, access, chmod } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** `~/.medeo` (MEDEO_HOME overrides it, for tests and unusual setups). */
|
|
11
|
+
export function medeoHome() { return process.env.MEDEO_HOME || path.join(homedir(), '.medeo'); }
|
|
12
|
+
export function devDirectory() { return path.join(medeoHome(), 'plugin-dev'); }
|
|
13
|
+
export function devKeyPath() { return path.join(devDirectory(), 'dev-key.pem'); }
|
|
14
|
+
/** Where `dev` writes its packages: `~/.medeo/plugin-dev/builds/<id>.medeoplugin`. */
|
|
15
|
+
export function devBuildPath(id) { return path.join(devDirectory(), 'builds', `${id}.medeoplugin`); }
|
|
16
|
+
|
|
17
|
+
/** Creates the development key on first use (0600, directory 0700) and returns its path. */
|
|
18
|
+
export async function ensureDevKey() {
|
|
19
|
+
const file = devKeyPath();
|
|
20
|
+
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
21
|
+
if (await access(file).then(() => true, () => false)) return file;
|
|
22
|
+
const { privateKey } = generateKeyPairSync('ed25519');
|
|
23
|
+
try {
|
|
24
|
+
await writeFile(file, privateKey.export({ type: 'pkcs8', format: 'pem' }), { mode: 0o600, flag: 'wx' });
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error.code !== 'EEXIST') throw error; // another process won the race; its key is as good
|
|
27
|
+
}
|
|
28
|
+
if (process.platform !== 'win32') await chmod(file, 0o600).catch(() => {});
|
|
29
|
+
return file;
|
|
30
|
+
}
|
package/locate.mjs
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
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.
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const repo = path.resolve(here, '../..');
|
|
12
|
+
const TEMPLATES = { vanilla: 'examples/i18n-demo', react: 'examples/react-demo' };
|
|
13
|
+
|
|
14
|
+
export function verifierPath() {
|
|
15
|
+
// 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;
|
|
17
|
+
throw new Error('The package verifier (verifier/medeo-verify.wasm) is missing from this installation');
|
|
18
|
+
}
|
|
19
|
+
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;
|
|
22
|
+
throw new Error(`Template "${name}" is missing from this installation`);
|
|
23
|
+
}
|
|
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}`);
|
|
29
|
+
const candidates = [];
|
|
30
|
+
for (const from of [path.join(source, 'package.json'), import.meta.url]) {
|
|
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);
|
|
36
|
+
}
|
|
37
|
+
candidates.push(path.join(repo, 'packages/plugin-sdk'));
|
|
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;
|
|
50
|
+
}
|
|
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
|
+
} };
|
|
64
|
+
}
|
package/pack.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Built artifacts only: no npm/install hooks ever execute during plugin installation.
|
|
3
|
+
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto';
|
|
4
|
+
import { readFile, writeFile, mkdir, readdir, lstat, access } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { zip } from './zip.mjs';
|
|
7
|
+
import { sdkResolver } from './locate.mjs';
|
|
8
|
+
// Mirrors platform_core::mime_type: the Host refuses anything not listed there.
|
|
9
|
+
// cjs/wasm/node: runtime "node" packages only.
|
|
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']);
|
|
11
|
+
// Already compressed; deflating them only costs time.
|
|
12
|
+
const STORED=new Set(['png','jpg','jpeg','gif','webp','woff','woff2','mp3','mp4','webm']);
|
|
13
|
+
// Mirrors platform_contract: archive and unpacked size, file count, index entry.
|
|
14
|
+
export const LIMITS={size:256*1024*1024,files:4096},INDEX_ENTRY='_package.json';
|
|
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]);
|
|
17
|
+
const UI_ENTRIES=['ui/entry.ts','ui/entry.tsx'];
|
|
18
|
+
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
|
+
const sha256=bytes=>createHash('sha256').update(bytes).digest('hex');
|
|
20
|
+
|
|
21
|
+
/** JSON with object keys sorted at every level: the same text Medeo's Host produces for the same manifest. */
|
|
22
|
+
export function canonicalJSON(value){
|
|
23
|
+
if(Array.isArray(value))return '['+value.map(canonicalJSON).join(',')+']';
|
|
24
|
+
if(value&&typeof value==='object')return '{'+Object.keys(value).sort().map(k=>JSON.stringify(k)+':'+canonicalJSON(value[k])).join(',')+'}';
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
/** `x.y.z-dev.h<16 hex>` from the manifest and every file; identical content gives an identical version, here and in Medeo's "Install from folder". */
|
|
28
|
+
export function developmentVersion(manifest,files){
|
|
29
|
+
const hash=createHash('sha256').update(canonicalJSON(manifest));
|
|
30
|
+
for(const p of Object.keys(files).sort())hash.update(p).update(sha256(files[p]));
|
|
31
|
+
return manifest.version.split(/[-+]/)[0]+`-dev.h${hash.digest('hex').slice(0,16)}`;
|
|
32
|
+
}
|
|
33
|
+
/** Signs `files` (path → Buffer) with `manifest` into a format-2 archive: `{bytes, digest}`. */
|
|
34
|
+
export function archive(manifest,files,privateKey){
|
|
35
|
+
if(privateKey.asymmetricKeyType!=='ed25519')throw Error('An Ed25519 key is required');
|
|
36
|
+
const paths=Object.keys(files).sort();
|
|
37
|
+
if(paths.length>LIMITS.files)throw Error('Too many files');
|
|
38
|
+
if(paths.reduce((total,p)=>total+files[p].length,0)>LIMITS.size)throw Error('Package too large');
|
|
39
|
+
const index=JSON.stringify({manifest,files:Object.fromEntries(paths.map(p=>[p,{size:files[p].length,sha256:sha256(files[p])}]))});
|
|
40
|
+
const der=createPublicKey(privateKey).export({type:'spki',format:'der'});
|
|
41
|
+
const signed=JSON.stringify({format:2,public_key:der.subarray(-32).toString('hex'),signature:sign(null,Buffer.from(index),privateKey).toString('hex'),index});
|
|
42
|
+
const bytes=zip([{name:INDEX_ENTRY,data:Buffer.from(signed),compress:true},...paths.map(p=>({name:p,data:files[p],compress:!STORED.has(p.split('.').pop().toLowerCase())}))]);
|
|
43
|
+
if(bytes.length>LIMITS.size)throw Error('Package too large');
|
|
44
|
+
return {bytes,digest:sha256(index)};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function pack(directory,keyFile,output,options={}){
|
|
48
|
+
const source=path.resolve(directory),manifest=JSON.parse(await readFile(path.join(source,'manifest.json'),'utf8'));
|
|
49
|
+
const {build}=await import('esbuild');
|
|
50
|
+
const sdk=sdkResolver(source);
|
|
51
|
+
// runtime "node": bundled for Node (built-ins stay imports; ESM output gets a
|
|
52
|
+
// `require` for bundled CommonJS dependencies). Native addons and other files
|
|
53
|
+
// the code loads at run time go in `resources/`.
|
|
54
|
+
const node=manifest.runtime==='node',cjs=node&&manifest.worker.endsWith('.cjs');
|
|
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
|
+
...(node&&!cjs?{banner:{js:"import{createRequire as __medeoRequire}from'node:module';const require=__medeoRequire(import.meta.url);"}}:{}),
|
|
57
|
+
plugins:[sdk],logLevel:'silent',absWorkingDir:source});
|
|
58
|
+
const files={[manifest.worker]:Buffer.from(result.outputFiles[0].text)};
|
|
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
|
+
// ui/ also holds the pages of app_windows windows, so it is packed even without a primary window.
|
|
61
|
+
if(manifest.ui||await access(path.join(source,'ui')).then(()=>true,()=>false)){
|
|
62
|
+
await collect(path.join(source,'ui'),'ui/');
|
|
63
|
+
// ui/entry.ts or ui/entry.tsx (React and other JSX, automatic runtime) is the one compiled UI entry.
|
|
64
|
+
const entries=UI_ENTRIES.filter(e=>e in files);
|
|
65
|
+
if(entries.length>1)throw Error('Keep one UI entry: ui/entry.ts or ui/entry.tsx');
|
|
66
|
+
if(entries.length){
|
|
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',plugins:[sdk],target:'es2022',logLevel:'silent',absWorkingDir:source});
|
|
69
|
+
files['ui/index.js']=Buffer.from(ui.outputFiles[0].text);delete files[entries[0]];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// The icon sits at the package root (`icon.svg` or `icon.png`), checked as Medeo will.
|
|
73
|
+
if(manifest.icon!==undefined){
|
|
74
|
+
if(!ICON_PATHS.includes(manifest.icon))throw Error('icon must be icon.svg or icon.png');
|
|
75
|
+
const full=path.join(source,manifest.icon),stat=await lstat(full).catch(()=>null);
|
|
76
|
+
if(!stat?.isFile()||stat.size>MAX_ICON)throw Error('Missing icon file or larger than 256 KiB: '+manifest.icon);
|
|
77
|
+
const bytes=await readFile(full);
|
|
78
|
+
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
|
+
files[manifest.icon]=bytes;
|
|
80
|
+
}
|
|
81
|
+
if(node&&await access(path.join(source,'resources')).then(()=>true,()=>false))await collect(path.join(source,'resources'),'resources/');
|
|
82
|
+
if(manifest.localization){
|
|
83
|
+
for(const relative of Object.values(manifest.localization.catalogs??{})){
|
|
84
|
+
if(typeof relative!=='string'||!/^locales\/[A-Za-z0-9_-]+\.json$/.test(relative))throw Error('Invalid catalog path');
|
|
85
|
+
const directory=path.join(source,'locales');if((await lstat(directory)).isSymbolicLink())throw Error('Symlinks are not permitted');
|
|
86
|
+
const full=path.join(source,relative),stat=await lstat(full);if(!stat.isFile()||stat.isSymbolicLink()||stat.size>256*1024)throw Error('Invalid catalog file');files[relative]=await readFile(full);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if(options.development)manifest.version=developmentVersion(manifest,files);
|
|
90
|
+
const {bytes}=archive(manifest,files,createPrivateKey(await readFile(keyFile)));
|
|
91
|
+
await mkdir(path.dirname(path.resolve(output)),{recursive:true});await writeFile(output,bytes);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `medeo-plugin pack [DIR] --key KEY.pem [--out FILE]` (or the older `pack DIR KEY.pem OUT`). Returns the output path. */
|
|
95
|
+
export async function packCommand(args){
|
|
96
|
+
const positional=[];let key,out;
|
|
97
|
+
for(let i=0;i<args.length;i++){const a=args[i];if(a==='--key')key=args[++i];else if(a==='--out')out=args[++i];else if(a.startsWith('--'))throw Error(`Unknown option ${a}`);else positional.push(a)}
|
|
98
|
+
if(positional.length===3&&!key&&!out)[,key,out]=positional;
|
|
99
|
+
else if(positional.length>1)throw Error('Usage: medeo-plugin pack [DIR] --key KEY.pem [--out FILE.medeoplugin]');
|
|
100
|
+
const directory=path.resolve(positional[0]??'.');
|
|
101
|
+
if(!key)throw Error('A signing key is required: --key KEY.pem (create one with `medeo-plugin keygen`). For development builds use `medeo-plugin dev`, which signs with the development key');
|
|
102
|
+
if(!out){const m=JSON.parse(await readFile(path.join(directory,'manifest.json'),'utf8'));out=path.join(directory,'dist',`${m.id}-${m.version}.medeoplugin`)}
|
|
103
|
+
await pack(directory,key,out);
|
|
104
|
+
return path.resolve(out);
|
|
105
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@medeo-plugin/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Command line for Medeo desktop plugins: scaffold, build, sign, check and live-reload a plugin in a running Medeo.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/one2x-ai/medeo-desktop-plugin-sdk.git",
|
|
9
|
+
"directory": "packages/plugin-devtools"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/one2x-ai/medeo-desktop-plugin-sdk#readme",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"medeo",
|
|
14
|
+
"plugin",
|
|
15
|
+
"cli"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"bin": {
|
|
19
|
+
"medeo-plugin": "./cli.mjs"
|
|
20
|
+
},
|
|
21
|
+
"exports": {
|
|
22
|
+
".": "./cli.mjs",
|
|
23
|
+
"./pack": "./pack.mjs",
|
|
24
|
+
"./tooling": "./tooling.mjs",
|
|
25
|
+
"./app": "./app.mjs",
|
|
26
|
+
"./dev": "./dev.mjs",
|
|
27
|
+
"./keys": "./keys.mjs",
|
|
28
|
+
"./zip": "./zip.mjs",
|
|
29
|
+
"./package.json": "./package.json"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"*.mjs",
|
|
33
|
+
"!*.test.mjs",
|
|
34
|
+
"verifier/",
|
|
35
|
+
"templates/",
|
|
36
|
+
"README.md"
|
|
37
|
+
],
|
|
38
|
+
"scripts": {
|
|
39
|
+
"stage": "node ../../scripts/stage-cli.mjs",
|
|
40
|
+
"prepack": "node ../../scripts/stage-cli.mjs"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@medeo-plugin/sdk": "0.8.0",
|
|
44
|
+
"esbuild": "^0.25.0"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=22"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#7d8bff"/>
|
|
5
|
+
<stop offset="1" stop-color="#4b50e0"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="256" height="256" fill="url(#bg)"/>
|
|
9
|
+
<circle cx="200" cy="52" r="70" fill="#ffffff" opacity="0.08"/>
|
|
10
|
+
<rect x="52" y="64" width="96" height="76" rx="18" fill="#ffffff"/>
|
|
11
|
+
<path d="M76 140v26l26-26z" fill="#ffffff"/>
|
|
12
|
+
<text x="100" y="117" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="44" font-weight="700" fill="#4b50e0">A</text>
|
|
13
|
+
<rect x="112" y="112" width="96" height="76" rx="18" fill="#ffffff" opacity="0.92"/>
|
|
14
|
+
<path d="M184 188v26l-26-26z" fill="#ffffff" opacity="0.92"/>
|
|
15
|
+
<text x="160" y="166" text-anchor="middle" font-family="PingFang SC, Hiragino Sans GB, sans-serif" font-size="42" font-weight="700" fill="#4b50e0">文</text>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "app.medeo.react-demo",
|
|
3
|
+
"name": "React counter",
|
|
4
|
+
"description": "The counter, written in React: a window, a command and storage.",
|
|
5
|
+
"icon": "icon.svg",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"api_version": 1,
|
|
8
|
+
"worker": "worker.js",
|
|
9
|
+
"ui": "ui/index.html",
|
|
10
|
+
"permissions": [
|
|
11
|
+
"storage"
|
|
12
|
+
],
|
|
13
|
+
"commands": [
|
|
14
|
+
{
|
|
15
|
+
"id": "counter.increment",
|
|
16
|
+
"title": "Increment"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"localization": {
|
|
20
|
+
"default_locale": "en",
|
|
21
|
+
"catalogs": {
|
|
22
|
+
"en": "locales/en.json",
|
|
23
|
+
"zh-CN": "locales/zh-CN.json"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { commands, i18n, storage } from '@medeo-plugin/sdk';
|
|
3
|
+
import { Card, Pill, EmptyState, useUIStyles } from '@medeo-plugin/sdk/ui/react';
|
|
4
|
+
|
|
5
|
+
interface Copy { title: string; count: string; increment: string }
|
|
6
|
+
|
|
7
|
+
/** Reads the counter and the plugin's own wording; the worker's command changes the counter. */
|
|
8
|
+
export function App() {
|
|
9
|
+
useUIStyles();
|
|
10
|
+
const [count, setCount] = useState<number | null>(null);
|
|
11
|
+
const [copy, setCopy] = useState<Copy | null>(null);
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
const [busy, setBusy] = useState(false);
|
|
14
|
+
async function load() {
|
|
15
|
+
const [state, value] = await Promise.all([i18n.state(), storage.get<number>('counter')]);
|
|
16
|
+
const current = value ?? 0;
|
|
17
|
+
const [countText, increment] = await Promise.all([i18n.t('count', { count: current }), i18n.t('increment')]);
|
|
18
|
+
document.documentElement.lang = state.catalog_locale ?? state.locale;
|
|
19
|
+
document.title = state.name;
|
|
20
|
+
setCopy({ title: state.name, count: countText, increment });
|
|
21
|
+
setCount(current);
|
|
22
|
+
}
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const off = i18n.onDidChangeLocale(() => void load().catch(e => setError(String(e))));
|
|
25
|
+
void load().catch(e => setError(String(e)));
|
|
26
|
+
return off;
|
|
27
|
+
}, []);
|
|
28
|
+
async function increment() {
|
|
29
|
+
setBusy(true);
|
|
30
|
+
try { await commands.run('counter.increment'); await load(); }
|
|
31
|
+
catch (e) { setError(String(e)); }
|
|
32
|
+
finally { setBusy(false); }
|
|
33
|
+
}
|
|
34
|
+
return (
|
|
35
|
+
<main>
|
|
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>}
|
|
43
|
+
</main>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `pack` bundles this file (and React) into ui/index.js. Components live in
|
|
2
|
+
// src/ui so that only built files are packaged.
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { App } from '../src/ui/App.tsx';
|
|
5
|
+
createRoot(document.getElementById('root')!).render(<App />);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/_medeo/theme.css"><link rel="stylesheet" href="style.css"><title>Medeo</title></head><body><div id="root"></div><script type="module" src="index.js"></script></body></html>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { definePlugin, storage, i18n } from '@medeo-plugin/sdk';
|
|
2
|
+
// The worker owns the data; the window only asks it to act (commands) and reads.
|
|
3
|
+
export const { activate, deactivate } = definePlugin({
|
|
4
|
+
activate(context) {
|
|
5
|
+
context.commands.register('counter.increment', async () => {
|
|
6
|
+
const previous = await storage.get<number>('counter');
|
|
7
|
+
const count = (typeof previous === 'number' ? previous : 0) + 1;
|
|
8
|
+
await storage.set('counter', count);
|
|
9
|
+
return { count, message: await i18n.t('count', { count }) };
|
|
10
|
+
});
|
|
11
|
+
},
|
|
12
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#7d8bff"/>
|
|
5
|
+
<stop offset="1" stop-color="#4b50e0"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="256" height="256" fill="url(#bg)"/>
|
|
9
|
+
<circle cx="200" cy="52" r="70" fill="#ffffff" opacity="0.08"/>
|
|
10
|
+
<rect x="52" y="64" width="96" height="76" rx="18" fill="#ffffff"/>
|
|
11
|
+
<path d="M76 140v26l26-26z" fill="#ffffff"/>
|
|
12
|
+
<text x="100" y="117" text-anchor="middle" font-family="Helvetica, Arial, sans-serif" font-size="44" font-weight="700" fill="#4b50e0">A</text>
|
|
13
|
+
<rect x="112" y="112" width="96" height="76" rx="18" fill="#ffffff" opacity="0.92"/>
|
|
14
|
+
<path d="M184 188v26l-26-26z" fill="#ffffff" opacity="0.92"/>
|
|
15
|
+
<text x="160" y="166" text-anchor="middle" font-family="PingFang SC, Hiragino Sans GB, sans-serif" font-size="42" font-weight="700" fill="#4b50e0">文</text>
|
|
16
|
+
</svg>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "app.medeo.i18n-demo",
|
|
3
|
+
"name": "Multilingual counter",
|
|
4
|
+
"description": "A counter that speaks nine languages.",
|
|
5
|
+
"icon": "icon.svg",
|
|
6
|
+
"version": "1.0.0",
|
|
7
|
+
"api_version": 1,
|
|
8
|
+
"worker": "worker.js",
|
|
9
|
+
"ui": "ui/index.html",
|
|
10
|
+
"permissions": [
|
|
11
|
+
"storage"
|
|
12
|
+
],
|
|
13
|
+
"commands": [
|
|
14
|
+
{
|
|
15
|
+
"id": "counter.increment",
|
|
16
|
+
"title": "Increment"
|
|
17
|
+
}
|
|
18
|
+
],
|
|
19
|
+
"localization": {
|
|
20
|
+
"default_locale": "en",
|
|
21
|
+
"catalogs": {
|
|
22
|
+
"en": "locales/en.json",
|
|
23
|
+
"zh-CN": "locales/zh-CN.json",
|
|
24
|
+
"zh-TW": "locales/zh-TW.json",
|
|
25
|
+
"ja": "locales/ja.json",
|
|
26
|
+
"ko": "locales/ko.json",
|
|
27
|
+
"pt-BR": "locales/pt-BR.json",
|
|
28
|
+
"es": "locales/es.json",
|
|
29
|
+
"de": "locales/de.json",
|
|
30
|
+
"fr": "locales/fr.json"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import {i18n,capabilities,commands,storage} from '@medeo-plugin/sdk';
|
|
2
|
+
const get=<T extends HTMLElement>(key:string)=>document.querySelector<T>(`[data-testid="drive.i18n.${key}"]`)!;
|
|
3
|
+
const language=get<HTMLSelectElement>('language'),name=get<HTMLInputElement>('name'),increment=get<HTMLButtonElement>('increment');let revision=0;
|
|
4
|
+
async function render(){document.body.setAttribute('aria-busy','true');const mine=++revision;const state=await i18n.state();const count=await storage.get<number>('counter')??0;const [hello,text,copy,nameLabel]=await Promise.all([i18n.t('hello',{name:name.value}),i18n.t('count',{count}),capabilities.labels(),i18n.t('name_label')]);if(mine!==revision)return;
|
|
5
|
+
document.documentElement.lang=state.catalog_locale??state.locale;document.documentElement.dir=state.direction;document.title=state.name;get('title').textContent=state.name;get('hello').textContent=hello;get('name-label').textContent=nameLabel;get('count').textContent=text;get('language-label').textContent=copy.plugin_language;
|
|
6
|
+
const options=['system',...state.supported_locales];language.replaceChildren(...options.map(value=>{const option=document.createElement('option');option.value=value;option.textContent=copy['language.'+value];return option}));language.value=state.preference;
|
|
7
|
+
increment.textContent=state.commands['counter.increment'];get('status').textContent=JSON.stringify({locale:state.locale,fallback:state.fallback_locale},null,2);document.body.setAttribute('aria-busy','false');}
|
|
8
|
+
function showError(error:unknown){get('status').textContent=String(error);document.body.setAttribute('aria-busy','false')}
|
|
9
|
+
const unsubscribe=i18n.onDidChangeLocale(()=>void render().catch(showError));window.addEventListener('pagehide',unsubscribe,{once:true});
|
|
10
|
+
language.onchange=()=>{const value=language.value;void i18n.setLocale(value).then(render).catch(showError)};name.oninput=()=>void render().catch(showError);
|
|
11
|
+
increment.onclick=async()=>{increment.disabled=true;try{await commands.run('counter.increment');await render()}catch(e){showError(e)}finally{increment.disabled=false}};
|
|
12
|
+
void render().catch(showError);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><link rel="stylesheet" href="/_medeo/theme.css"><link rel="stylesheet" href="style.css"><title>Medeo</title></head><body><main><header><small>Medeo · i18n</small><h1 data-testid="drive.i18n.title"></h1></header><label><span data-testid="drive.i18n.language-label"></span><select data-testid="drive.i18n.language"></select></label><label><span data-testid="drive.i18n.name-label"></span><input data-testid="drive.i18n.name" value="Medeo" autocomplete="off"></label><p data-testid="drive.i18n.hello"></p><output data-testid="drive.i18n.count" aria-live="polite"></output><button class="primary" data-testid="drive.i18n.increment"></button><pre data-testid="drive.i18n.status" aria-live="polite"></pre></main><script type="module" src="index.js"></script></body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
main{max-width:680px;margin:auto;padding:32px;display:grid;gap:20px}header small{color:var(--color-schemes-primary)}h1{margin:8px 0}label{display:grid;gap:8px}input,select{font:inherit;padding:12px;border:1px solid var(--color-surface-outline-variant);border-radius:10px;color:inherit;background:var(--color-surface-surface-container-lowest)}output{font-size:36px}pre{white-space:pre-wrap;font-size:12px;color:var(--color-surface-on-surface-variant)}button{justify-self:start}input:focus-visible,select:focus-visible{outline:2px solid var(--color-schemes-primary);outline-offset:2px}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import {definePlugin,storage,i18n} from '@medeo-plugin/sdk';
|
|
2
|
+
export const {activate,deactivate}=definePlugin({activate(context){context.commands.register('counter.increment',async()=>{const previous=await storage.get<unknown>('counter');const count=(typeof previous==='number'?previous:0)+1;await storage.set('counter',count);return {count,message:await i18n.t('count',{count})}})}});
|
package/tooling.mjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile, rename, rm, access } from 'node:fs/promises';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pack } from './pack.mjs';
|
|
5
|
+
import { watch } from 'node:fs';
|
|
6
|
+
import { verifierPath, templatePath } from './locate.mjs';
|
|
7
|
+
|
|
8
|
+
// The bundled WebAssembly verifier runs the installer's own rules, so checking
|
|
9
|
+
// needs neither Rust nor Medeo. Set MEDEO_PLATFORM_HOST to ask a built Host.
|
|
10
|
+
let verifier;
|
|
11
|
+
export async function inspect(bytes) {
|
|
12
|
+
verifier ??= (await WebAssembly.instantiate(await readFile(verifierPath()))).instance.exports;
|
|
13
|
+
const input = verifier.alloc(bytes.length);
|
|
14
|
+
new Uint8Array(verifier.memory.buffer, input, bytes.length).set(bytes);
|
|
15
|
+
const packed = BigInt(verifier.inspect_package(input, bytes.length));
|
|
16
|
+
verifier.dealloc(input, bytes.length);
|
|
17
|
+
const pointer = Number(packed >> 32n), length = Number(packed & 0xffffffffn);
|
|
18
|
+
const text = new TextDecoder().decode(new Uint8Array(verifier.memory.buffer, pointer, length));
|
|
19
|
+
verifier.dealloc(pointer, length);
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
}
|
|
22
|
+
export async function check(file) {
|
|
23
|
+
const host = process.env.MEDEO_PLATFORM_HOST;
|
|
24
|
+
if (!host) {
|
|
25
|
+
const result = await inspect(await readFile(path.resolve(file)));
|
|
26
|
+
if (!result.ok) throw new Error(result.error);
|
|
27
|
+
return result.package;
|
|
28
|
+
}
|
|
29
|
+
if (!await access(host).then(() => true, () => false)) throw new Error('MEDEO_PLATFORM_HOST does not exist');
|
|
30
|
+
const result = spawnSync(host, ['--inspect-package', path.resolve(file)], { encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024, windowsHide: true });
|
|
31
|
+
if (result.error) throw new Error('Package verifier could not run');
|
|
32
|
+
if (result.status !== 0) throw new Error(result.stderr.trim().replace(/^platform-host: /, '').slice(0, 200) || 'Package verification failed');
|
|
33
|
+
return JSON.parse(result.stdout);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const PACKAGE_VERSION = JSON.parse(await readFile(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
37
|
+
const PROJECT_TSCONFIG = {
|
|
38
|
+
compilerOptions: { target: 'ES2022', module: 'ESNext', moduleResolution: 'Bundler', strict: true, noEmit: true, allowImportingTsExtensions: true,
|
|
39
|
+
resolveJsonModule: true, skipLibCheck: true, jsx: 'react-jsx', lib: ['ES2022', 'DOM', 'DOM.Iterable'], types: ['node'] },
|
|
40
|
+
include: ['*.ts', 'ui/**/*.ts', 'ui/**/*.tsx', 'src/**/*.ts', 'src/**/*.tsx', 'tests/**/*.ts'],
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A complete plugin project in `directory`: the template's files, a manifest for
|
|
45
|
+
* `id`, and the `package.json` / `tsconfig.json` that give it the SDK, the CLI
|
|
46
|
+
* and editor types from npm. `template`: `vanilla` (nine-language counter) or `react`.
|
|
47
|
+
*/
|
|
48
|
+
export async function init(directory, id, { template = 'vanilla' } = {}) {
|
|
49
|
+
if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)*$/.test(id) || id.length < 3 || id.length > 96) throw new Error('Invalid plugin ID: lowercase letters, digits, "-" and ".", 3-96 characters, e.g. com.example.my-plugin');
|
|
50
|
+
const root = path.resolve(directory), source = templatePath(template);
|
|
51
|
+
const demoManifest = JSON.parse(await readFile(path.join(source, 'manifest.json'), 'utf8'));
|
|
52
|
+
// Never overwrite an existing project or generate a signing key into its UI assets.
|
|
53
|
+
await mkdir(root);
|
|
54
|
+
const manifest = { ...demoManifest, id, name: id, description: 'Describe what this plugin does in one sentence.', version: '1.0.0' };
|
|
55
|
+
const files = { 'manifest.json': JSON.stringify(manifest, null, 2) + '\n', '.gitignore': '*.pem\n*.medeoplugin\nnode_modules/\ndist/\n' };
|
|
56
|
+
// A generated app has its own signed catalogs; it does not need host copy edits.
|
|
57
|
+
for (const relative of Object.values(demoManifest.localization?.catalogs ?? {})) {
|
|
58
|
+
const catalog = JSON.parse(await readFile(path.join(source, relative), 'utf8'));
|
|
59
|
+
delete catalog['plugin.name']; delete catalog['plugin.description'];
|
|
60
|
+
files[relative] = JSON.stringify(catalog, null, 2) + '\n';
|
|
61
|
+
}
|
|
62
|
+
await copyTree(source, root, files, ['manifest.json', 'locales', 'README.md', 'tests', 'dist', 'node_modules', 'package.json', 'tsconfig.json']);
|
|
63
|
+
const react = template === 'react';
|
|
64
|
+
const name = id.split('.').pop();
|
|
65
|
+
files['package.json'] = JSON.stringify({
|
|
66
|
+
name, version: '1.0.0', private: true, type: 'module',
|
|
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
|
+
devDependencies: { '@medeo-plugin/sdk': `^${PACKAGE_VERSION}`, '@medeo-plugin/cli': `^${PACKAGE_VERSION}`, typescript: '^5.6.0', '@types/node': '^22.0.0',
|
|
70
|
+
...(react ? { react: '^19.0.0', 'react-dom': '^19.0.0', '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0' } : {}) },
|
|
71
|
+
}, null, 2) + '\n';
|
|
72
|
+
files['tsconfig.json'] = JSON.stringify(PROJECT_TSCONFIG, null, 2) + '\n';
|
|
73
|
+
files['README.md'] = `# ${id}\n\nA Medeo desktop plugin.\n\n\`\`\`sh\nnpm install\nnpm run dev # build on every change and load it into a Medeo in developer mode\nnpm run typecheck\n\`\`\`\n\nRelease: \`medeo-plugin keygen ../${name}-release-key.pem\` once (keep the key outside the project and out of version control; the same key must sign every later version), then \`npm run pack && npm run check\` and install \`dist/*.medeoplugin\` through Medeo's plugin center.\n\nAll wording lives in \`locales/\` and ships signed with the plugin; keep \`{placeholders}\` identical across languages. Replace \`icon.svg\` (or ship \`icon.png\`, 256×256). Docs: https://github.com/one2x-ai/medeo-desktop-plugin-sdk\n`;
|
|
74
|
+
for (const [relative, content] of Object.entries(files)) {
|
|
75
|
+
await mkdir(path.dirname(path.join(root, relative)), { recursive: true });
|
|
76
|
+
await writeFile(path.join(root, relative), content, { flag: 'wx' });
|
|
77
|
+
}
|
|
78
|
+
return root;
|
|
79
|
+
}
|
|
80
|
+
async function copyTree(source, root, files, skip) {
|
|
81
|
+
const { readdir, lstat } = await import('node:fs/promises');
|
|
82
|
+
const walk = async (dir, prefix) => {
|
|
83
|
+
for (const item of await readdir(dir)) {
|
|
84
|
+
if (!prefix && skip.includes(item)) continue;
|
|
85
|
+
const full = path.join(dir, item), stat = await lstat(full);
|
|
86
|
+
if (stat.isSymbolicLink()) continue;
|
|
87
|
+
if (stat.isDirectory()) await walk(full, prefix + item + '/');
|
|
88
|
+
else files[prefix + item] = await readFile(full);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
await walk(source, '');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `medeo-plugin init DIR --id ID [--template vanilla|react]`; asks for the ID on a terminal. */
|
|
95
|
+
export async function initCommand(args) {
|
|
96
|
+
let directory, id, template = 'vanilla';
|
|
97
|
+
for (let i = 0; i < args.length; i++) {
|
|
98
|
+
const a = args[i];
|
|
99
|
+
if (a === '--id') id = args[++i]; else if (a === '--template') template = args[++i];
|
|
100
|
+
else if (a.startsWith('--')) throw new Error(`Unknown option ${a}`);
|
|
101
|
+
else if (!directory) directory = a; else if (!id) id = a; else throw new Error(`Unexpected argument ${a}`);
|
|
102
|
+
}
|
|
103
|
+
if (!directory) throw new Error('Usage: medeo-plugin init DIR --id com.example.my-plugin [--template vanilla|react]');
|
|
104
|
+
if (!id && process.stdin.isTTY) {
|
|
105
|
+
const { createInterface } = await import('node:readline/promises');
|
|
106
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
107
|
+
try { id = (await rl.question('Plugin ID (reverse domain, e.g. com.example.my-plugin): ')).trim(); } finally { rl.close(); }
|
|
108
|
+
}
|
|
109
|
+
if (!id) throw new Error('Pass the plugin ID: --id com.example.my-plugin');
|
|
110
|
+
const root = await init(directory, id, { template });
|
|
111
|
+
console.error(`Created ${root}\n\n cd ${path.relative(process.cwd(), root) || '.'}\n npm install\n npm run dev\n`);
|
|
112
|
+
return root;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Builds a signed development package of `directory` into `output`, then again
|
|
117
|
+
* whenever a source file changes (`once`: build one and return). A build that
|
|
118
|
+
* fails to pack or verify never replaces the last good package. `onBuild(info)`
|
|
119
|
+
* runs after each new package; `signal` stops the watch.
|
|
120
|
+
*/
|
|
121
|
+
export async function develop(directory, key, output, { once = false, signal, onBuild } = {}) {
|
|
122
|
+
const source = path.resolve(directory), destination = path.resolve(output);
|
|
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
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
125
|
+
const temporary = destination + `.pending-${process.pid}`;
|
|
126
|
+
if (signal?.aborted) return;
|
|
127
|
+
let previous = '', building = false, again = false, stopped = false, active;
|
|
128
|
+
let timer = null;
|
|
129
|
+
const build = async () => {
|
|
130
|
+
if (stopped || signal?.aborted) return;
|
|
131
|
+
if (building) { again = true; return; }
|
|
132
|
+
building = true;
|
|
133
|
+
try {
|
|
134
|
+
do {
|
|
135
|
+
again = false;
|
|
136
|
+
try {
|
|
137
|
+
await pack(source, key, temporary, { development: true });
|
|
138
|
+
// Invalid builds never replace the last installable package.
|
|
139
|
+
const info = await check(temporary);
|
|
140
|
+
if (stopped || signal?.aborted) return;
|
|
141
|
+
if (info.digest !== previous) {
|
|
142
|
+
await rename(temporary, destination);
|
|
143
|
+
previous = info.digest;
|
|
144
|
+
console.log(JSON.stringify({ event: 'package_ready', id: info.manifest.id, version: info.manifest.version, digest: info.digest, output: destination }));
|
|
145
|
+
await onBuild?.(info);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
if (once) throw error;
|
|
149
|
+
const message = String(error);
|
|
150
|
+
if (message !== previous) console.error(message);
|
|
151
|
+
previous = message;
|
|
152
|
+
} finally { await rm(temporary, { force: true }); }
|
|
153
|
+
} while (again && !stopped && !signal?.aborted);
|
|
154
|
+
} finally { building = false; }
|
|
155
|
+
};
|
|
156
|
+
if (once) { await build(); return; }
|
|
157
|
+
// Watch before the initial build: confirmation dialogs may take minutes.
|
|
158
|
+
const IGNORED = new Set(['node_modules', '.git', 'dist']);
|
|
159
|
+
let resolveStopped;
|
|
160
|
+
const finished = new Promise(resolve => { resolveStopped = resolve; });
|
|
161
|
+
const watcher = watch(source, { recursive: true }, (_event, name) => {
|
|
162
|
+
const top = name?.toString().split(/[\\/]/)[0];
|
|
163
|
+
if (top && IGNORED.has(top)) return;
|
|
164
|
+
if (building) again = true;
|
|
165
|
+
if (timer) clearTimeout(timer);
|
|
166
|
+
timer = setTimeout(() => { timer = null; if (building) again = true; else active = build(); }, 150);
|
|
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
|
+
}
|
|
184
|
+
}
|
|
Binary file
|
package/zip.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Minimal ZIP writer for .medeoplugin archives: stored or deflated entries,
|
|
2
|
+
// UTF-8 names, fixed timestamps (the same input always gives the same bytes),
|
|
3
|
+
// no ZIP64 (packages are far below 4 GiB).
|
|
4
|
+
import { deflateRawSync } from 'node:zlib';
|
|
5
|
+
import * as zlib from 'node:zlib';
|
|
6
|
+
|
|
7
|
+
let table;
|
|
8
|
+
function crc32(bytes) {
|
|
9
|
+
if (zlib.crc32) return zlib.crc32(bytes);
|
|
10
|
+
table ??= Array.from({ length: 256 }, (_, n) => { for (let k = 0; k < 8; k++) n = n & 1 ? 0xedb88320 ^ (n >>> 1) : n >>> 1; return n >>> 0; });
|
|
11
|
+
let crc = 0xffffffff;
|
|
12
|
+
for (const byte of bytes) crc = table[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
|
13
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** entries: [{ name, data: Buffer, compress: boolean }] in archive order. */
|
|
17
|
+
export function zip(entries) {
|
|
18
|
+
const locals = [], centrals = [];
|
|
19
|
+
let offset = 0;
|
|
20
|
+
for (const { name, data, compress } of entries) {
|
|
21
|
+
const deflated = compress ? deflateRawSync(data, { level: 9 }) : null;
|
|
22
|
+
const method = deflated && deflated.length < data.length ? 8 : 0;
|
|
23
|
+
const body = method ? deflated : data, nameBytes = Buffer.from(name, 'utf8'), crc = crc32(data);
|
|
24
|
+
const local = Buffer.alloc(30);
|
|
25
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(method, 8);
|
|
26
|
+
local.writeUInt16LE(0, 10); local.writeUInt16LE(0x21, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(body.length, 18);
|
|
27
|
+
local.writeUInt32LE(data.length, 22); local.writeUInt16LE(nameBytes.length, 26); local.writeUInt16LE(0, 28);
|
|
28
|
+
const central = Buffer.alloc(46);
|
|
29
|
+
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8);
|
|
30
|
+
central.writeUInt16LE(method, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14); central.writeUInt32LE(crc, 16);
|
|
31
|
+
central.writeUInt32LE(body.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(nameBytes.length, 28);
|
|
32
|
+
central.writeUInt32LE(offset, 42);
|
|
33
|
+
locals.push(local, nameBytes, body); centrals.push(central, nameBytes);
|
|
34
|
+
offset += local.length + nameBytes.length + body.length;
|
|
35
|
+
}
|
|
36
|
+
const directory = Buffer.concat(centrals), end = Buffer.alloc(22);
|
|
37
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
|
|
38
|
+
end.writeUInt32LE(directory.length, 12); end.writeUInt32LE(offset, 16);
|
|
39
|
+
return Buffer.concat([...locals, directory, end]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Entries of an archive this module (or any plain ZIP) wrote: name → Buffer. */
|
|
43
|
+
export function unzip(archive) {
|
|
44
|
+
const at = archive.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
45
|
+
if (at < 0) throw Error('Not a ZIP archive');
|
|
46
|
+
const count = archive.readUInt16LE(at + 10), entries = new Map();
|
|
47
|
+
let position = archive.readUInt32LE(at + 16);
|
|
48
|
+
for (let i = 0; i < count; i++) {
|
|
49
|
+
const method = archive.readUInt16LE(position + 10), size = archive.readUInt32LE(position + 20);
|
|
50
|
+
const nameLength = archive.readUInt16LE(position + 28), extra = archive.readUInt16LE(position + 30), comment = archive.readUInt16LE(position + 32);
|
|
51
|
+
const local = archive.readUInt32LE(position + 42), name = archive.subarray(position + 46, position + 46 + nameLength).toString('utf8');
|
|
52
|
+
const start = local + 30 + archive.readUInt16LE(local + 26) + archive.readUInt16LE(local + 28), body = archive.subarray(start, start + size);
|
|
53
|
+
entries.set(name, method === 8 ? zlib.inflateRawSync(body) : Buffer.from(body));
|
|
54
|
+
position += 46 + nameLength + extra + comment;
|
|
55
|
+
}
|
|
56
|
+
return entries;
|
|
57
|
+
}
|