toolaby 1.4.4 → 1.5.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 +2 -0
- package/bin.mjs +122 -8
- package/lib/project.js +18 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
24
24
|
|
|
25
25
|
**`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
|
|
26
26
|
|
|
27
|
+
**`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. Nothing is sent anywhere.
|
|
28
|
+
|
|
27
29
|
**`toolaby tools`** lists your tools and their ids. **`toolaby whoami`** says who you are signed in as. **`toolaby logout`** signs the command out and revokes its session on the server.
|
|
28
30
|
|
|
29
31
|
## Where things live
|
package/bin.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// toolaby wire <tool-id> wire the extension in this folder to your tool — plain, WXT, CRXJS or Plasmo
|
|
6
6
|
// toolaby create <tool-id> make a new, minimal extension for your tool in <tool-id>/
|
|
7
7
|
// toolaby tools your tools, with the ids the commands want
|
|
8
|
+
// toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's key left out
|
|
8
9
|
// toolaby webhooks events the six events, with an example of each
|
|
9
10
|
// toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
|
|
10
11
|
// toolaby webhooks endpoints the workspace's endpoints
|
|
@@ -27,14 +28,15 @@
|
|
|
27
28
|
// platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
|
|
28
29
|
// so this and the dashboard's Wire button never disagree.
|
|
29
30
|
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
|
|
30
|
-
import { join, resolve, dirname } from 'node:path';
|
|
31
|
+
import { join, resolve, dirname, relative, basename } from 'node:path';
|
|
32
|
+
import { deflateRawSync } from 'node:zlib';
|
|
31
33
|
import { homedir } from 'node:os';
|
|
32
34
|
import { spawn } from 'node:child_process';
|
|
33
35
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
34
36
|
import { wireManifest, WORKER_LINES } from './lib/manifest.js';
|
|
35
|
-
import { detectProject, CODE_FILES, filesToWrite, fillPages, scaffoldManifest, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
|
|
37
|
+
import { detectProject, CODE_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
|
|
36
38
|
|
|
37
|
-
const VERSION = '1.
|
|
39
|
+
const VERSION = '1.5.0';
|
|
38
40
|
const CLIENT_ID = 'toolaby-cli';
|
|
39
41
|
const argv = process.argv.slice(2);
|
|
40
42
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -259,20 +261,25 @@ async function create() {
|
|
|
259
261
|
// The scaffold's own files as the Wall names them (older Walls did not, hence the list): a Wall that adds one to the
|
|
260
262
|
// minimal extension is written by this command too, instead of a manifest naming a file that never arrived.
|
|
261
263
|
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
|
|
262
|
-
|
|
264
|
+
// Which surfaces this extension is made with: both (the default), one of them, or none at all for a tool that works
|
|
265
|
+
// from the background. Asked for once here, rather than written and then deleted by hand.
|
|
266
|
+
const asked = opt('--surfaces');
|
|
267
|
+
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
268
|
+
const surfaces = surfacesOf(asked);
|
|
269
|
+
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold)];
|
|
263
270
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
264
271
|
if (absent.length) fail(`The Wall at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
|
|
265
272
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
266
273
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
267
274
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
268
275
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
269
|
-
|
|
270
|
-
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), { sidePanel: wroteSidePanel }), null, 2) + '\n');
|
|
276
|
+
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces) }), null, 2) + '\n');
|
|
271
277
|
for (const s of [16, 48, 128]) {
|
|
272
278
|
const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
|
|
273
279
|
write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
|
|
274
280
|
}
|
|
275
|
-
|
|
281
|
+
const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
|
|
282
|
+
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.site}/tools/${slug}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${plan.site}/docs/surfaces\n`);
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
// ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
|
|
@@ -359,6 +366,110 @@ async function webhooks() {
|
|
|
359
366
|
fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
|
|
360
367
|
}
|
|
361
368
|
|
|
369
|
+
// ── pack: the zip the Chrome Web Store takes ──────────────────────────────────────────────────────────────────────────
|
|
370
|
+
// The built extension, zipped, with the manifest's `key` left out: the store refuses a key in a new item's first
|
|
371
|
+
// package and any key but the item's own in an update, and it keeps the id it gave the item either way — the Wall
|
|
372
|
+
// admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
|
|
373
|
+
// or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
|
|
374
|
+
// can ship with. Nothing is sent anywhere: it reads the folder and writes one file.
|
|
375
|
+
const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
|
|
376
|
+
const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
377
|
+
const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
|
|
378
|
+
|
|
379
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
|
|
380
|
+
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
381
|
+
|
|
382
|
+
/** A zip of [name, bytes] pairs: deflated where that is smaller, stored otherwise, every entry dated 1 Jan 1980 so the same build packs to the same bytes. */
|
|
383
|
+
function zipOf(entries) {
|
|
384
|
+
const locals = []; const centrals = []; let offset = 0;
|
|
385
|
+
for (const [name, data] of entries) {
|
|
386
|
+
const nameBuf = Buffer.from(name, 'utf8');
|
|
387
|
+
const deflated = deflateRawSync(data, { level: 9 });
|
|
388
|
+
const method = deflated.length < data.length ? 8 : 0;
|
|
389
|
+
const body = method === 8 ? deflated : data;
|
|
390
|
+
const crc = crc32(data);
|
|
391
|
+
const head = Buffer.alloc(30);
|
|
392
|
+
head.writeUInt32LE(0x04034b50, 0); head.writeUInt16LE(20, 4); head.writeUInt16LE(0x0800, 6); head.writeUInt16LE(method, 8);
|
|
393
|
+
head.writeUInt16LE(0, 10); head.writeUInt16LE(0x21, 12); head.writeUInt32LE(crc, 14); head.writeUInt32LE(body.length, 18);
|
|
394
|
+
head.writeUInt32LE(data.length, 22); head.writeUInt16LE(nameBuf.length, 26); head.writeUInt16LE(0, 28);
|
|
395
|
+
const central = Buffer.alloc(46);
|
|
396
|
+
central.writeUInt32LE(0x02014b50, 0); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x0800, 8);
|
|
397
|
+
central.writeUInt16LE(method, 10); central.writeUInt16LE(0, 12); central.writeUInt16LE(0x21, 14); central.writeUInt32LE(crc, 16);
|
|
398
|
+
central.writeUInt32LE(body.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(nameBuf.length, 28);
|
|
399
|
+
central.writeUInt32LE(offset, 42);
|
|
400
|
+
locals.push(head, nameBuf, body); centrals.push(central, nameBuf);
|
|
401
|
+
offset += head.length + nameBuf.length + body.length;
|
|
402
|
+
}
|
|
403
|
+
const cd = Buffer.concat(centrals);
|
|
404
|
+
const end = Buffer.alloc(22);
|
|
405
|
+
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10);
|
|
406
|
+
end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16);
|
|
407
|
+
return Buffer.concat([...locals, cd, end]);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function filesIn(root) {
|
|
411
|
+
const found = [];
|
|
412
|
+
const walk = (dir) => {
|
|
413
|
+
for (const name of readdirSync(dir).sort()) {
|
|
414
|
+
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
415
|
+
const p = join(dir, name);
|
|
416
|
+
if (statSync(p).isDirectory()) walk(p); else found.push(p);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
walk(root);
|
|
420
|
+
return found;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function pack() {
|
|
424
|
+
const given = positional[0];
|
|
425
|
+
const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
426
|
+
if (!folder || !existsSync(join(folder, 'manifest.json'))) fail(given ? `No manifest.json in ${given}.` : `No manifest.json here or in ${BUILD_DIRS.slice(1).join(', ')}. Build first, or name the folder: toolaby pack <folder>.`);
|
|
427
|
+
let manifest;
|
|
428
|
+
try { manifest = JSON.parse(readFileSync(join(folder, 'manifest.json'), 'utf8')); } catch (e) { fail(`manifest.json does not parse: ${e.message}`); }
|
|
429
|
+
if (!manifest.version) fail('manifest.json has no "version".');
|
|
430
|
+
const files = filesIn(folder);
|
|
431
|
+
const problems = [];
|
|
432
|
+
|
|
433
|
+
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
434
|
+
const modes = new Set(); const sites = new Set();
|
|
435
|
+
for (const f of files) {
|
|
436
|
+
if (!/\.(m?js|html|json)$/.test(f)) continue;
|
|
437
|
+
for (const [k, mode] of readFileSync(f, 'utf8').matchAll(TOOL_KEY)) {
|
|
438
|
+
modes.add(mode);
|
|
439
|
+
try { const facts = JSON.parse(Buffer.from(k.split('_')[2], 'base64url').toString('utf8')); if (typeof facts.s === 'string') sites.add(facts.s.replace(/\/$/, '')); } catch { /* not a key after all */ }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development Wall'}. Run \`npx toolaby wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
|
|
443
|
+
if (modes.size === 0) out('\n No tool key found in the build: it is not wired to the Wall, or the key is somewhere this cannot read. Packing anyway.');
|
|
444
|
+
|
|
445
|
+
// Addresses of this machine have no place in a store build.
|
|
446
|
+
const local = [...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])].filter((m) => typeof m === 'string' && LOCAL.test(m));
|
|
447
|
+
if (local.length && !flag('--allow-local')) problems.push(`The manifest points at this machine: ${local.join(', ')}. A store build has no localhost in host_permissions or externally_connectable. (--allow-local packs it anyway.)`);
|
|
448
|
+
|
|
449
|
+
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
450
|
+
for (const site of sites) {
|
|
451
|
+
const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
|
|
452
|
+
if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${site}/* in both host_permissions and externally_connectable: sign-in and the purchase would not reach the extension. \`npx toolaby wire <tool-id> --live --force\` adds them.`);
|
|
453
|
+
}
|
|
454
|
+
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
455
|
+
|
|
456
|
+
const hadKey = typeof manifest.key === 'string';
|
|
457
|
+
const { key: _key, ...forStore } = manifest;
|
|
458
|
+
const entries = files.map((f) => {
|
|
459
|
+
const name = relative(folder, f).split('\\').join('/');
|
|
460
|
+
return [name, name === 'manifest.json' ? Buffer.from(JSON.stringify(forStore, null, 2) + '\n') : readFileSync(f)];
|
|
461
|
+
});
|
|
462
|
+
// Named after the extension, or after its folder when the name is a __MSG_…__ placeholder.
|
|
463
|
+
const named = typeof manifest.name === 'string' && !manifest.name.startsWith('__MSG_') ? manifest.name : basename(folder);
|
|
464
|
+
const safe = named.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'extension';
|
|
465
|
+
const target = resolve(opt('--out') ?? `${safe}-${manifest.version}.zip`);
|
|
466
|
+
writeFileSync(target, zipOf(entries));
|
|
467
|
+
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
468
|
+
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
469
|
+
if (hadKey) out(' The manifest\'s "key" is left out: the store keeps the id it gives the item. Your unpacked copy keeps the key and its own id; the Wall admits both once the listing\'s address is on the tool.');
|
|
470
|
+
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
471
|
+
}
|
|
472
|
+
|
|
362
473
|
function help() {
|
|
363
474
|
out(`
|
|
364
475
|
toolaby — the Toolaby command line (${VERSION})
|
|
@@ -368,7 +479,10 @@ function help() {
|
|
|
368
479
|
--into <folder> another folder --force replace the platform's files
|
|
369
480
|
toolaby upgrade <tool-id> the platform's files replaced with the current ones — the popup, the client — yours untouched
|
|
370
481
|
toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
|
|
482
|
+
--surfaces both|popup|side-panel|none what it opens (default: both)
|
|
371
483
|
toolaby tools your tools, with their ids
|
|
484
|
+
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
485
|
+
refuses a Test key or localhost inside --out <file.zip>
|
|
372
486
|
toolaby webhooks events the six events, with an example of each
|
|
373
487
|
toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
|
|
374
488
|
toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
|
|
@@ -379,7 +493,7 @@ function help() {
|
|
|
379
493
|
`);
|
|
380
494
|
}
|
|
381
495
|
|
|
382
|
-
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
496
|
+
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
383
497
|
const run = commands[command];
|
|
384
498
|
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
385
499
|
Promise.resolve().then(run).catch((e) => fail(e.message));
|
package/lib/project.js
CHANGED
|
@@ -19,18 +19,32 @@ export const filesToWrite = (p, appPopup, appSidePanel = null) => [
|
|
|
19
19
|
...(appSidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
|
|
20
20
|
...(p.extraFiles ?? []).map((f) => f.to),
|
|
21
21
|
];
|
|
22
|
+
export const SURFACES = ['both', 'popup', 'side-panel', 'none'];
|
|
23
|
+
export const surfacesOf = (v) => SURFACES.includes(v ?? '') ? v : 'both';
|
|
24
|
+
export const hasPopup = (s) => s === 'both' || s === 'popup';
|
|
25
|
+
export const hasSidePanel = (s) => s === 'both' || s === 'side-panel';
|
|
26
|
+
/** The scaffold's own files for those surfaces: the manifest and the background always, the pages as asked for. */
|
|
27
|
+
export const scaffoldFilesFor = (s, all) => all.filter((f) => (f === 'popup.html' ? hasPopup(s) : f === 'sidepanel.html' ? hasSidePanel(s) : f === 'popup.js' || f === 'popup.css' ? s !== 'none' : true));
|
|
22
28
|
/**
|
|
23
29
|
* The scaffold's manifest, once it is known which pages were really written. Chrome refuses to load an extension
|
|
24
30
|
* whose `side_panel.default_path` names a file that is not there — the whole extension, not just the panel — so the
|
|
25
|
-
* minimal extension ships a manifest that names only its popup, and whatever writes the
|
|
31
|
+
* minimal extension ships a manifest that names only its popup, and whatever writes the pages settles it here: the
|
|
32
|
+
* side panel added with its page, the popup taken out when there is none (the icon stays; the click then opens the
|
|
33
|
+
* side panel, or nothing, which is what a background-only tool wants).
|
|
26
34
|
*/
|
|
27
35
|
export function scaffoldManifest(manifest, wrote) {
|
|
36
|
+
const out = { ...manifest };
|
|
37
|
+
if (wrote.popup === false) {
|
|
38
|
+
const action = { ...out.action };
|
|
39
|
+
delete action.default_popup;
|
|
40
|
+
out.action = action;
|
|
41
|
+
}
|
|
28
42
|
if (!wrote.sidePanel)
|
|
29
|
-
return
|
|
30
|
-
const permissions = Array.isArray(
|
|
43
|
+
return out;
|
|
44
|
+
const permissions = Array.isArray(out.permissions) ? [...out.permissions] : [];
|
|
31
45
|
if (!permissions.includes('sidePanel'))
|
|
32
46
|
permissions.push('sidePanel');
|
|
33
|
-
return { ...
|
|
47
|
+
return { ...out, permissions, side_panel: { default_path: 'sidepanel.html' } };
|
|
34
48
|
}
|
|
35
49
|
/**
|
|
36
50
|
* The last two placeholders in a handed-out file — the extension's own popup and side panel, which only the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|