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 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.4.4';
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
- const wanted = [...CODE_FILES, ...scaffold];
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
- const wroteSidePanel = wanted.includes('sidepanel.html');
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
- out(`\n ${plan.name} → ${dir}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. Click the icon: the popup shows what the Free plan holds; Buy opens ${plan.site}/tools/${slug}\n${wroteSidePanel ? " 3. Open side panel, in that popup: the same page at the panel's width (README.md says how to keep only one)\n" : ''}\n Edit background.js: your work goes behind toolaby.gate().\n`);
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 side panel adds it here.
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 manifest;
30
- const permissions = Array.isArray(manifest.permissions) ? [...manifest.permissions] : [];
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 { ...manifest, permissions, side_panel: { default_path: 'sidepanel.html' } };
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.4.4",
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": {