toolaby 1.4.5 → 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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/bin.mjs +111 -3
  3. 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
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.5';
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.
@@ -364,6 +366,110 @@ async function webhooks() {
364
366
  fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
365
367
  }
366
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
+
367
473
  function help() {
368
474
  out(`
369
475
  toolaby — the Toolaby command line (${VERSION})
@@ -375,6 +481,8 @@ function help() {
375
481
  toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
376
482
  --surfaces both|popup|side-panel|none what it opens (default: both)
377
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>
378
486
  toolaby webhooks events the six events, with an example of each
379
487
  toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
380
488
  toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
@@ -385,7 +493,7 @@ function help() {
385
493
  `);
386
494
  }
387
495
 
388
- 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) };
389
497
  const run = commands[command];
390
498
  if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
391
499
  Promise.resolve().then(run).catch((e) => fail(e.message));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.4.5",
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": {