toolaby 1.6.2 → 1.6.3

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
@@ -3,11 +3,13 @@
3
3
  The Toolaby command line. It puts the Wall — sign-in, a Free plan, paid plans, licences and subscriptions — into a Chrome extension you already have, or makes a new one for you.
4
4
 
5
5
  ```sh
6
- npm i -g toolaby # once (or run every command through `npx -y toolaby …`)
6
+ npm i -g toolaby # once (or run every command through `npx -y toolaby@latest …`)
7
7
  toolaby wire <tool-id> # in your extension's folder, on Test; signs you in first if it must
8
8
  toolaby wire <tool-id> --live # the same, on Live: the store build
9
9
  ```
10
10
 
11
+ In a repository you did not write, use the installed command: `npx` can run a `toolaby` the project carries, or fetch one from a registry the project's `.npmrc` names.
12
+
11
13
  Every command acts on the test Wall (`test.toolaby.app`) unless told `--live` (`toolaby.app`), as Stripe's command line does. `toolaby tools` lists your ids on either side.
12
14
 
13
15
  ## What it does
@@ -24,7 +26,7 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
24
26
 
25
27
  **`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
26
28
 
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`. A store zip is public, so files that look private (`*.pem`, `*.key`, `*.p12` and other keys, `id_rsa` and the other SSH keys, certificates, `*.env`, `credentials.json`, a service account's key such as `firebase-adminsdk-….json`, `_netrc`, source maps) are left out and named; `--allow-private` packs them. Dotfiles and links that lead out of the folder are never packed. Nothing is sent anywhere.
29
+ **`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`. A store zip is public, so files that look private (`*.pem`, `*.key`, `*.p12` and other keys, `id_rsa` and the other SSH keys, certificates, `*.env`, `credentials.json`, a service account's key such as `firebase-adminsdk-….json`, `_netrc`, source maps) are left out and named, and so is a file of any name that holds a private key or a live secret (a Stripe live key, a webhook secret, an AWS access key, a GitHub, Slack or npm token, a password in an address). A build copies what a link in `public/` points at, so `pack` also leaves out a file with the same bytes as one of the project's private files or the command's own session, a folder that is a git repository's own, and a file named as an image that holds none, and it names the links in `public/`. `--allow-private` packs what it left out. Dotfiles and links are never packed. Nothing is sent anywhere.
28
30
 
29
31
  **`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.
30
32
 
package/bin.mjs CHANGED
@@ -41,9 +41,9 @@ import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
41
41
  import { detectProject, CODE_FILES, POPUP_FILES, SIDE_PANEL_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE, AGENT_FILES, AGENT_ENTRY_FILES } from './lib/project.js';
42
42
  import { runMcp } from './lib/mcp.js';
43
43
  import { runCheck } from './lib/check.js';
44
- import { inside, signInPage, opener, privateKind, privateContent, oneLine, fetchBounded, fetchHere, readText, SCAFFOLD_FILES } from './lib/safe.js';
44
+ import { inside, signInPage, opener, privateKind, privateContent, privateCopies, projectRoot, gitDirectory, notAnImage, publicLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
45
45
 
46
- const VERSION = '1.6.2';
46
+ const VERSION = '1.6.3';
47
47
  const CLIENT_ID = 'toolaby-cli';
48
48
  const argv = process.argv.slice(2);
49
49
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -88,7 +88,10 @@ const storedToken = () => envToken() || readCredentials()[wall]?.token || null;
88
88
  /** Said where a session is missing and TOOLABY_TOKEN is set: it is kept for another Wall, and a CI job should know. */
89
89
  const envElsewhere = () => (process.env.TOOLABY_TOKEN && !envRefused && !envToken() ? `\n TOOLABY_TOKEN is sent to ${tokenWall} alone; TOOLABY_TOKEN_WALL=${wall} sends it here instead.` : '');
90
90
 
91
- async function api(path, { method = 'GET', body, token = storedToken(), raw = false } = {}) {
91
+ /** The most of one answer the command reads from a Wall: none of the Wall's comes near it (the largest, a wiring, is under 1 MB). */
92
+ const ANSWER_LIMIT = 4 * 1024 * 1024;
93
+
94
+ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
92
95
  // Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
93
96
  const res = await fetchHere(`${wall}${path}`, {
94
97
  method,
@@ -98,10 +101,15 @@ async function api(path, { method = 'GET', body, token = storedToken(), raw = fa
98
101
  if (res.status === 401 && token && token === envToken()) {
99
102
  envRefused = true;
100
103
  const stored = storedToken();
101
- if (stored && stored !== token) return api(path, { method, body, token: stored, raw });
104
+ if (stored && stored !== token) { await res.body?.cancel().catch(() => {}); return api(path, { method, body, token: stored }); }
102
105
  }
103
- if (raw) return res;
104
- const json = await res.json().catch(() => ({}));
106
+ // Read up to a few megabytes and no further: a Wall at another address (--wall, TOOLABY_WALL) that answered without
107
+ // end filled the command's memory, and the MCP server's, which hands what it reads to an agent (25 Sep 2026).
108
+ // An answer cut short reads as no JSON, as it always has; only one past the limit stops the command.
109
+ const bytes = await readBounded(res, ANSWER_LIMIT).catch(() => Buffer.alloc(0));
110
+ if (!bytes) throw new Error(`The Wall at ${wall} answered ${path.split('?')[0]} with more than ${ANSWER_LIMIT / 1024 / 1024} MB, which no answer of a Wall's is. None of it was used.`);
111
+ let json = {};
112
+ try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
105
113
  return { status: res.status, ok: res.ok, json };
106
114
  }
107
115
 
@@ -419,7 +427,7 @@ async function webhooks() {
419
427
  return;
420
428
  }
421
429
  if (sub === 'secret') {
422
- // For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx toolaby webhooks secret) node server.mjs`.
430
+ // For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx -y toolaby@latest webhooks secret) node server.mjs`.
423
431
  process.stdout.write(devSecret() + '\n');
424
432
  return;
425
433
  }
@@ -448,7 +456,7 @@ async function webhooks() {
448
456
  } catch (e) { fail(`Could not reach ${to}: ${e.message}`); }
449
457
  const text = (await res.text()).slice(0, 200);
450
458
  out(`\n POST ${to} → ${res.status}${text ? ` ${oneLine(text)}` : ''}`);
451
- out(` ${type}, signed with ${opt('--secret') ? 'your secret' : "this machine's development secret — `npx toolaby webhooks secret` prints it for WALL_WEBHOOK_SECRET"}.\n`);
459
+ out(` ${type}, signed with ${opt('--secret') ? 'your secret' : "this machine's development secret — `npx -y toolaby@latest webhooks secret` prints it for WALL_WEBHOOK_SECRET"}.\n`);
452
460
  if (res.status >= 300) out(' A real delivery would be retried until your server answers 2xx.\n');
453
461
  return;
454
462
  }
@@ -530,9 +538,14 @@ function zipOf(entries) {
530
538
  * link: one out of the folder can put ~/.ssh/id_rsa in a store zip as logo.png, and one inside it the folder's own
531
539
  * .env or .git, under an innocent name (25 Sep 2026). Nor a name no zip should hold — a backslash, which unzips as a
532
540
  * folder, or a control character — nor anything but a file. A zip is public once the store serves it.
541
+ *
542
+ * A build has already followed the links of the project it came from: what they pointed at is an ordinary file here.
543
+ * So a file is also private when it is a copy of one of the project's (`copyOf`), a folder when it is a git
544
+ * repository's own, and a file named as an image when it holds none (round 7, 25 Sep 2026).
533
545
  */
534
- function filesIn(root) {
546
+ function filesIn(root, copyOf = () => null) {
535
547
  const found = []; const left = []; const privately = [];
548
+ const allowed = flag('--allow-private');
536
549
  const walk = (dir) => {
537
550
  for (const name of readdirSync(dir).sort()) {
538
551
  if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
@@ -541,10 +554,16 @@ function filesIn(root) {
541
554
  const st = lstatSync(p);
542
555
  if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
543
556
  if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
544
- if (st.isDirectory()) { walk(p); continue; }
557
+ if (st.isDirectory()) {
558
+ // Left out whole, unwalked: its config and its history are the repository's, whatever the folder is called.
559
+ if (gitDirectory(p)) { if (!allowed) { left.push([rel, 'a git directory']); continue; } privately.push([rel, 'a git directory']); }
560
+ walk(p);
561
+ continue;
562
+ }
545
563
  if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
546
- const why = privateKind(name) ?? privateContent(readFileSync(p));
547
- if (why && !flag('--allow-private')) left.push([rel, why]);
564
+ const bytes = readFileSync(p);
565
+ const why = privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? notAnImage(name, bytes);
566
+ if (why && !allowed) left.push([rel, why]);
548
567
  else { found.push(p); if (why) privately.push([rel, why]); }
549
568
  }
550
569
  };
@@ -552,6 +571,9 @@ function filesIn(root) {
552
571
  return { found, left, privately };
553
572
  }
554
573
 
574
+ /** A path as the developer knows it: under their home folder, from ~. */
575
+ const fromHome = (path) => (path.startsWith(homedir() + sep) ? `~${path.slice(homedir().length)}` : path);
576
+
555
577
  function pack() {
556
578
  const given = positional[0];
557
579
  const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
@@ -563,7 +585,11 @@ function pack() {
563
585
  // One to four numbers, as Chrome and the store take it — and a file name here: the zip is named with it, and a
564
586
  // version with "../" in it put the zip outside the folder.
565
587
  if (typeof manifest.version !== 'string' || !/^\d{1,5}(\.\d{1,5}){0,3}$/.test(manifest.version)) fail(`manifest.json's "version" is ${JSON.stringify(manifest.version)}: Chrome takes one to four numbers separated by dots, like 1.2.0.`);
566
- const { found: files, left, privately } = filesIn(folder);
588
+ // The project the build came from — or, with none above it, the folder alone — and the command's own secrets: what a
589
+ // build could have copied in under another name, by following a link (lib/safe.js).
590
+ const project = projectRoot(folder);
591
+ const copyOf = privateCopies(project ?? folder, [[CREDENTIALS, `${fromHome(CREDENTIALS)}, this command's session`], [DEV_SECRET_FILE, `${fromHome(DEV_SECRET_FILE)}, this machine's webhook secret`]]);
592
+ const { found: files, left, privately } = filesIn(folder, copyOf);
567
593
  const problems = [];
568
594
 
569
595
  // Which side the build sells on: the tool keys inside it, wherever the bundler put them.
@@ -575,7 +601,7 @@ function pack() {
575
601
  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 */ }
576
602
  }
577
603
  }
578
- 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.)`);
604
+ 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 -y toolaby@latest wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
579
605
  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.');
580
606
 
581
607
  // Addresses of this machine have no place in a store build.
@@ -585,7 +611,7 @@ function pack() {
585
611
  // The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
586
612
  for (const site of sites) {
587
613
  const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
588
- if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${oneLine(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.`);
614
+ if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${oneLine(site)}/* in both host_permissions and externally_connectable: sign-in and the purchase would not reach the extension. \`npx -y toolaby@latest wire <tool-id> --live --force\` adds them.`);
589
615
  }
590
616
  if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
591
617
 
@@ -612,6 +638,9 @@ function pack() {
612
638
  if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
613
639
  if (links.length) out(` Left out: ${links.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. The zip holds the folder's own files: copy in what belongs in it.`);
614
640
  if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
641
+ // Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
642
+ const linked = project ? publicLinks(project, folder) : [];
643
+ if (linked.length) out(` Links in the folders a build copies as they are: ${linked.slice(0, 8).map(oneLine).join(', ')}${linked.length > 8 ? ` and ${linked.length - 8} more` : ''}. The build copies what each points at, a private file too: make each the file itself.`);
615
644
  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.');
616
645
  out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
617
646
  }
@@ -641,7 +670,8 @@ function help() {
641
670
  toolaby tools your tools, with their ids
642
671
  toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
643
672
  refuses a Test key or localhost inside --out <file.zip>
644
- leaves out keys, certificates, .env and credential files, source maps --allow-private packs them
673
+ leaves out keys, certificates, .env and credential files, source maps, live secrets,
674
+ a build's copy of a private file, a git folder, an image that is none --allow-private packs them
645
675
  toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
646
676
  --browser loads it in Chromium (needs Playwright in the project)
647
677
  toolaby mcp the Wall for coding agents, an MCP server on stdio:
@@ -654,6 +684,9 @@ function help() {
654
684
  Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
655
685
  --wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI —
656
686
  sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://test.toolaby.app for Test).
687
+
688
+ Not installed: npx -y toolaby@latest <command>. In a repository you did not write, install it (npm i -g toolaby)
689
+ and type toolaby: npx can run a toolaby the project carries, or fetch one from a registry its .npmrc names.
657
690
  `);
658
691
  }
659
692
 
package/lib/check.js CHANGED
@@ -76,47 +76,47 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
76
76
  // 2. The Wall's files.
77
77
  const at = (f) => join(dir, `${project.codeDir}${f}`);
78
78
  const missing = ['toolaby.js', 'toolaby.config.js'].filter((f) => !existsSync(at(f)));
79
- if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx toolaby wire <tool-id>\` here.`); return false; }
79
+ if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx -y toolaby@latest wire <tool-id>\` here.`); return false; }
80
80
  ok(`The Wall's files are in ${project.codeDir || 'the top folder'}.`);
81
81
 
82
82
  // 3. The tool key, and the tool it names — from a file: a link to a device or a pipe is read as nothing.
83
83
  const configText = readOr(at('toolaby.config.js'), '');
84
84
  const keyText = configText.match(KEY)?.[0];
85
85
  const key = keyText ? decodeKey(keyText) : null;
86
- if (!key) { bad('toolaby.config.js holds no tool key. Run `npx toolaby upgrade <tool-id>` here.'); return false; }
86
+ if (!key) { bad('toolaby.config.js holds no tool key. Run `npx -y toolaby@latest upgrade <tool-id>` here.'); return false; }
87
87
  const mode = keyText.split('_')[1];
88
88
  // The key is the project's: the Wall it names is asked twice below, with no session, and its tool id goes into a path
89
89
  // the developer's session asks of their own Wall. So only a Wall's address (lib/safe.js), and only a tool id. A cloned
90
90
  // project's key sent the two requests wherever it named, and a tool id of ".." made the signed-in one a request for
91
91
  // another route (25 Sep 2026).
92
92
  if (!keySite(key.site) || !TOOL_ID.test(key.slug)) {
93
- bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a Wall: its address is https (http only on this machine) with nothing after the host, and its id lowercase letters, digits and dashes. Nothing was asked of it. \`npx toolaby upgrade <tool-id>\` writes the tool's own key.`);
93
+ bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a Wall: its address is https (http only on this machine) with nothing after the host, and its id lowercase letters, digits and dashes. Nothing was asked of it. \`npx -y toolaby@latest upgrade <tool-id>\` writes the tool's own key.`);
94
94
  return false;
95
95
  }
96
96
  ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development Wall'} (${key.site}).`);
97
97
  const pub = await fetchBounded(`${key.site}/api/tools/${encodeURIComponent(key.slug)}`).then((r) => ({ status: r.status, json: jsonOf(r.body) })).catch(() => ({ status: 0, json: null }));
98
98
  const tool = pub.status === 200 ? pub.json : null;
99
- if (!tool) bad(pub.status === 0 ? `The Wall at ${key.site} could not be reached.` : pub.status === 404 ? `The Wall does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx toolaby tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
99
+ if (!tool) bad(pub.status === 0 ? `The Wall at ${key.site} could not be reached.` : pub.status === 404 ? `The Wall does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx -y toolaby@latest tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
100
100
  else {
101
101
  ok(`The Wall knows it: ${oneLine(tool.name)}.`);
102
102
  const jwk = tool.publicKeyJwk ?? {};
103
- if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx toolaby upgrade <tool-id>` writes it in.');
103
+ if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx -y toolaby@latest upgrade <tool-id>` writes it in.');
104
104
  }
105
105
 
106
106
  // 4. The manifest, against the platform's own wiring rules: what wiring it again would add.
107
107
  if (project.manifestFile && manifest) {
108
108
  const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
109
109
  const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
110
- if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.map(oneLine).join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
110
+ if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.map(oneLine).join('; ')}. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
111
111
  else ok('The manifest has the permissions and host permissions the Wall needs.');
112
112
  // The identity: without the `key`, each copy's id follows its folder, and once the extension is named the tool key refuses every copy not chosen.
113
- if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx toolaby upgrade ${key.slug}\` adds it.`);
113
+ if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
114
114
  } else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}/* in host_permissions and externally_connectable.`);
115
115
 
116
116
  // 5. The client, as fresh as the Wall's.
117
117
  const client = await fetchBounded(`${key.site}/client/toolaby.js`).then((r) => (r.ok && r.body ? r.body.toString('utf8') : null)).catch(() => null);
118
118
  if (client === null) warn('The Wall\'s current client could not be read to compare.');
119
- else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not the Wall's current client. \`npx toolaby upgrade ${key.slug}\` replaces it (your own files stay).`);
119
+ else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not the Wall's current client. \`npx -y toolaby@latest upgrade ${key.slug}\` replaces it (your own files stay).`);
120
120
  else ok('toolaby.js is the Wall\'s current client.');
121
121
 
122
122
  // 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
@@ -124,7 +124,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
124
124
  if (project.manifestFile && manifest && sources.some((s) => /\bshowPaywall\s*\(/.test(withoutComments(s)))) {
125
125
  const pages = [manifest.action?.default_popup ? 'toolaby-popup.html' : null, (manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel) ? 'toolaby-sidepanel.html' : null].filter(Boolean);
126
126
  const absent = pages.filter((p) => !existsSync(join(dir, `${project.popupDir}${p}`)));
127
- if (absent.length) warn(`The code calls toolaby.showPaywall(), which opens ${absent.join(' and ')}, and the extension has no such page: the paywall opens the buyer page in a new tab instead. \`npx toolaby upgrade ${key.slug}\` writes ${absent.length > 1 ? 'them' : 'it'}; your pages stay in front.`);
127
+ if (absent.length) warn(`The code calls toolaby.showPaywall(), which opens ${absent.join(' and ')}, and the extension has no such page: the paywall opens the buyer page in a new tab instead. \`npx -y toolaby@latest upgrade ${key.slug}\` writes ${absent.length > 1 ? 'them' : 'it'}; your pages stay in front.`);
128
128
  }
129
129
  if (tool) {
130
130
  const used = new Set();
@@ -146,7 +146,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
146
146
  else if (facts?.ok) warn('No copy has checked in yet: load it at chrome://extensions and open it once.');
147
147
  // The key carries what the Free plan holds and the kind of plan sold: a change since it was written leaves it behind.
148
148
  const current = facts?.ok ? facts.json?.toolKey : null;
149
- if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than the Wall's: what the Free plan holds, the plans or the signing key changed since it was written. \`npx toolaby upgrade ${key.slug}\` writes the current one.`);
149
+ if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than the Wall's: what the Free plan holds, the plans or the signing key changed since it was written. \`npx -y toolaby@latest upgrade ${key.slug}\` writes the current one.`);
150
150
  }
151
151
 
152
152
  // 8. In a browser: the background answers.
package/lib/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // `toolaby mcp`: the Wall for coding agents, as a Model Context Protocol server on stdio.
2
2
  //
3
- // An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby mcp` —
3
+ // An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby@latest mcp` —
4
4
  // and gets tools to read and change the developer's tools on the Wall: what the Free plan holds, features, plans,
5
5
  // grants, and the docs. It acts with the session `toolaby login` stored, on Test unless started with --live, through
6
6
  // the same routes and the same checks as the dashboard.
@@ -59,7 +59,7 @@ function toolsFor(side) {
59
59
  {
60
60
  name: 'toolaby_create_tool',
61
61
  title: 'Create a tool',
62
- description: `A new tool on the ${side} Wall. This changes what the developer's own workspace sells: one more tool. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx toolaby create <id>\` for a new one, \`npx toolaby wire <id>\` for an existing one.`,
62
+ description: `A new tool on the ${side} Wall. This changes what the developer's own workspace sells: one more tool. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx -y toolaby@latest create <id>\` for a new one, \`npx -y toolaby@latest wire <id>\` for an existing one.`,
63
63
  inputSchema: {
64
64
  type: 'object',
65
65
  properties: {
@@ -77,7 +77,7 @@ function toolsFor(side) {
77
77
  {
78
78
  name: 'toolaby_set_access',
79
79
  title: 'Set what the Free plan holds',
80
- description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). This changes access in the developer\'s own workspace, for every buyer of the tool. Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx toolaby upgrade <id>` then writes the new one.',
80
+ description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). This changes access in the developer\'s own workspace, for every buyer of the tool. Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx -y toolaby@latest upgrade <id>` then writes the new one.',
81
81
  inputSchema: {
82
82
  type: 'object',
83
83
  properties: {
@@ -129,7 +129,7 @@ function toolsFor(side) {
129
129
  {
130
130
  name: 'toolaby_add_plan',
131
131
  title: 'Add a plan',
132
- description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). This changes billing in the developer's own workspace. "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx toolaby upgrade <id>\` then writes the new one.`,
132
+ description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). This changes billing in the developer's own workspace. "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx -y toolaby@latest upgrade <id>\` then writes the new one.`,
133
133
  inputSchema: {
134
134
  type: 'object',
135
135
  properties: {
@@ -208,7 +208,7 @@ export function runMcp({ version, side, wall, api, token }) {
208
208
  return { result: { content: [{ type: 'text', text: 'A tool id is lowercase letters, digits and dashes, as toolaby_list_tools gives it.' }], isError: true } };
209
209
  }
210
210
  if (name !== 'toolaby_docs' && !token()) {
211
- return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx toolaby login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
211
+ return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx -y toolaby@latest login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
212
212
  }
213
213
  try {
214
214
  return { result: resultOf(await t.run(args ?? {}, api, docs)) };
@@ -229,7 +229,7 @@ export function runMcp({ version, side, wall, api, token }) {
229
229
  protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
230
230
  capabilities: { tools: { listChanged: false } },
231
231
  serverInfo: { name: 'toolaby', title: 'Toolaby Wall', version },
232
- instructions: `The Toolaby Wall (${side}, ${wall}): accounts, licences, subscriptions and the paywall for Chrome extensions. Read a tool with toolaby_get_tool before changing it; the extension gates paid work with toolaby.gate({ feature }) and each feature key must exist on the tool (toolaby_set_features). Wire files with the shell: npx toolaby create|wire|upgrade <id>, check with npx toolaby check. toolaby_docs reads the docs.`,
232
+ instructions: `The Toolaby Wall (${side}, ${wall}): accounts, licences, subscriptions and the paywall for Chrome extensions. Read a tool with toolaby_get_tool before changing it; the extension gates paid work with toolaby.gate({ feature }) and each feature key must exist on the tool (toolaby_set_features). Wire files with the shell: npx -y toolaby@latest create|wire|upgrade <id>, check with npx -y toolaby@latest check. toolaby_docs reads the docs.`,
233
233
  } });
234
234
  }
235
235
  case 'notifications/initialized':
@@ -248,7 +248,7 @@ export function runMcp({ version, side, wall, api, token }) {
248
248
  }
249
249
  };
250
250
 
251
- log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx toolaby login`'}`);
251
+ log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx -y toolaby@latest login`'}`);
252
252
  let buffer = '';
253
253
  process.stdin.setEncoding('utf8');
254
254
  process.stdin.on('data', (chunk) => {
package/lib/safe.js CHANGED
@@ -8,8 +8,10 @@
8
8
  // address (--wall, TOOLABY_WALL) could write any file with any content. And on Windows the sign-in address went
9
9
  // through cmd.exe, where an `&` in it ran a command. Hand-written, unlike manifest.js and project.js: this is the
10
10
  // command's own boundary, not a wiring rule the browser shares.
11
- import { lstatSync, readFileSync, statSync } from 'node:fs';
12
- import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
11
+ import { createHash } from 'node:crypto';
12
+ import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
13
15
 
14
16
  /**
15
17
  * The minimal extension's own files, as the Wall names them (SCAFFOLD_FILES in wall/src/packages/wiring/files.ts;
@@ -88,8 +90,17 @@ export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {
88
90
  url = next.href;
89
91
  res = await fetch(url, { redirect: 'manual', signal });
90
92
  }
91
- const answered = { status: res.status, ok: res.ok };
92
- if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel().catch(() => {}); return { ...answered, body: null }; }
93
+ return { status: res.status, ok: res.ok, body: await readBounded(res, limit) };
94
+ }
95
+
96
+ /**
97
+ * A response's body, up to `limit` bytes and no further: null past it, and the rest let go of unread. What a server
98
+ * sends is read as it comes, so one that answered without end filled the command's memory — `fetchBounded`'s
99
+ * downloads, and every answer of a Wall at another address (--wall, TOOLABY_WALL), which the MCP server also handed
100
+ * on to an agent (25 Sep 2026).
101
+ */
102
+ export async function readBounded(res, limit) {
103
+ if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel().catch(() => {}); return null; }
93
104
  const chunks = [];
94
105
  let size = 0;
95
106
  const reader = res.body?.getReader();
@@ -97,10 +108,10 @@ export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {
97
108
  const { done, value } = reader ? await reader.read() : { done: true };
98
109
  if (done) break;
99
110
  size += value.byteLength;
100
- if (size > limit) { await reader.cancel().catch(() => {}); return { ...answered, body: null }; }
111
+ if (size > limit) { await reader.cancel().catch(() => {}); return null; }
101
112
  chunks.push(value);
102
113
  }
103
- return { ...answered, body: Buffer.concat(chunks) };
114
+ return Buffer.concat(chunks);
104
115
  }
105
116
 
106
117
  /**
@@ -139,16 +150,225 @@ export function readText(path, limit = READ_LIMIT) {
139
150
  /**
140
151
  * Why a file's contents have no place in a store zip, whatever it is called, or null: a private key, a cloud service
141
152
  * account's key, a source map written into the code. Names alone let `key.txt`, a Google Cloud key under the name
142
- * its console gives it, and a bundle's inline map through (25 Sep 2026).
153
+ * its console gives it, and a bundle's inline map through (25 Sep 2026). So did a live secret in a file of any name
154
+ * — a Stripe or AWS key, a GitHub, Slack or npm token, a password in an address — until round 7 of the same day.
143
155
  */
144
156
  export function privateContent(bytes) {
145
157
  const text = bytes.subarray(0, 4 * 1024 * 1024).toString('latin1');
146
158
  if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
147
- if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/.test(text)) return 'a private key inside';
159
+ if (pemKey(text)) return 'a private key inside';
148
160
  if (/[#@]\s*sourceMappingURL\s*=\s*data:/.test(text)) return 'a source map inside';
161
+ for (const [shape, why] of SECRETS) for (const [found] of text.matchAll(shape)) if (!standIn(found)) return why;
162
+ if (passwordIn(text)) return 'a password in an address inside';
163
+ return null;
164
+ }
165
+
166
+ /**
167
+ * A secret by its whole shape, and what to call it — never by its prefix alone, which code that checks for one holds
168
+ * (`key.startsWith('sk_live_')`), nor a publishable key, which is made to be in a browser.
169
+ */
170
+ const SECRETS = [
171
+ [/\b[rs]k_live_[0-9A-Za-z]{24,}/g, 'a Stripe live key inside'],
172
+ [/\bwhsec_[A-Za-z0-9+/]{24,}/g, 'a webhook signing secret inside'],
173
+ [/\bAKIA[0-9A-Z]{16}\b/g, 'an AWS access key inside'],
174
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82,})/g, 'a GitHub token inside'],
175
+ [/\bxox[abprs]-\d+-[A-Za-z0-9-]{20,}/g, 'a Slack token inside'],
176
+ [/\bnpm_[A-Za-z0-9]{36,}/g, 'an npm token inside'],
177
+ ];
178
+ /** What stands where a secret goes, in docs and examples: a run of one character, or AWS's own EXAMPLE key. */
179
+ const standIn = (found) => /(.)\1{7}/.test(found) || found.includes('EXAMPLE');
180
+
181
+ /** An address with a password in it, `scheme://user:password@host`: the user and password as an address spells them. */
182
+ const ADDRESS = /\b[a-z][a-z0-9+.-]{1,15}:\/\/([a-z0-9._~%!+,;=-]{1,64}):([a-z0-9._~%!+,;=-]{1,128})@([a-z0-9.-]+|\[[0-9a-f:.]+\])/gi;
183
+ /** A password that is the example's word for one, as docs and error messages write it. */
184
+ const EXAMPLE_PASSWORD = /^(?:pass(?:word|wd)?|pwd|pw|secret|changeme|x+|\.+|(?:your|my)[-_]?pass(?:word)?)$/i;
185
+ /**
186
+ * Whether the text holds a live password in an address: not an example's (`user:password@`, a user's own name as its
187
+ * password, as local databases are set up), and not one for this machine, which reaches nothing elsewhere.
188
+ */
189
+ function passwordIn(text) {
190
+ for (const [, user, password, host] of text.matchAll(ADDRESS)) if (password !== user && !EXAMPLE_PASSWORD.test(password) && !LOOPBACK.test(host)) return true;
191
+ return false;
192
+ }
193
+
194
+ const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?)-----/g;
195
+ /**
196
+ * Whether the text holds a PEM private key: its BEGIN line, lines of base64 after any header lines (Proc-Type,
197
+ * DEK-Info, a PGP block's Version), and its own END line — the line breaks real, or written as \n in JSON or a string.
198
+ * The BEGIN line alone is code's: jose's importPKCS8 checks a string starts with it, and a bundle holding jose was
199
+ * refused as a key until round 7 (25 Sep 2026).
200
+ */
201
+ function pemKey(text) {
202
+ for (const begin of text.matchAll(PEM_BEGIN)) {
203
+ const from = begin.index + begin[0].length;
204
+ const to = text.indexOf(`-----END ${begin[1]}-----`, from);
205
+ if (to < 0 || to - from > 32 * 1024) continue;
206
+ const lines = text.slice(from, to).replace(/\\+[nr]|\r/g, '\n').split('\n').map((l) => l.trim().replace(/\\$/, '')).filter((l) => l && !/^[A-Za-z][A-Za-z-]*: /.test(l));
207
+ if (lines.length && lines.every((l) => /^[A-Za-z0-9+/=]+$/.test(l)) && lines.join('').length >= 40) return true;
208
+ }
209
+ return false;
210
+ }
211
+
212
+ /** Whether anything is at the path, a link to nowhere included. */
213
+ const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
214
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
215
+
216
+ /**
217
+ * The project a built folder belongs to: the nearest folder at or above it with a package.json or a .git — or null,
218
+ * and never the home folder or the top of the disk, which are no project to look through.
219
+ */
220
+ export function projectRoot(folder) {
221
+ const home = resolve(homedir());
222
+ for (let at = resolve(folder); at !== home && dirname(at) !== at; at = dirname(at)) {
223
+ if (there(join(at, 'package.json')) || there(join(at, '.git'))) return at;
224
+ }
149
225
  return null;
150
226
  }
151
227
 
228
+ /** The most of one file `pack` compares by its bytes, and how far into a project it looks for the private ones. */
229
+ const COPY_LIMIT = 4 * 1024 * 1024;
230
+ const LOOK = { depth: 4, entries: 20_000 };
231
+ /** Folders the look for private files passes by: installed packages, caches, and git's own, of which only config is read. */
232
+ const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.next', '.turbo', '.cache']);
233
+
234
+ /**
235
+ * What says a file of a build is a copy of a private one: `why(bytes)`, which answers "a copy of .env, an environment
236
+ * file" or null. Its bytes are compared (SHA-256) with every file under `root` that is private by its name (privateKind:
237
+ * .env files, keys, .npmrc, credentials…), with `root`'s .git/config, and with `more` — [path, what it is] — each a
238
+ * few MB at most and not empty. A build resolves a link before `pack` sees it: WXT and Vite copy what a link in public/
239
+ * points at as an ordinary file under the link's name, so a cloned template's public/icon-128.png -> ../.env reached
240
+ * the zip as a PNG, the developer's .env inside (25 Sep 2026).
241
+ */
242
+ export function privateCopies(root, more = []) {
243
+ const known = new Map(); // size → hash → what the file is
244
+ const note = (path, what) => {
245
+ let bytes;
246
+ try { const st = statSync(path); if (!st.isFile() || st.size > COPY_LIMIT) return; bytes = readFileSync(path); } catch { return; }
247
+ // Nothing in it is nothing to keep, and a copy of nothing is every empty file.
248
+ if (!bytes.toString('latin1').trim()) return;
249
+ const same = known.get(bytes.length) ?? new Map();
250
+ known.set(bytes.length, same);
251
+ const hash = sha256(bytes);
252
+ if (!same.has(hash)) same.set(hash, what);
253
+ };
254
+ let entries = 0;
255
+ // Level by level, so the project's own top — where .env and .npmrc are kept — is always read, however large the rest.
256
+ for (let level = root ? [root] : [], depth = 0; level.length && depth <= LOOK.depth; depth++) {
257
+ const next = [];
258
+ for (const dir of level) {
259
+ let names;
260
+ try { names = readdirSync(dir).sort(); } catch { continue; }
261
+ for (const name of names) {
262
+ if (++entries > LOOK.entries) break;
263
+ const path = join(dir, name);
264
+ const rel = relative(root, path).split(sep).join('/');
265
+ let st;
266
+ try { st = lstatSync(path); } catch { continue; }
267
+ if (st.isDirectory()) {
268
+ if (name === '.git') note(join(path, 'config'), `${rel}/config`);
269
+ else if (!PASSED_BY.has(name)) next.push(path);
270
+ continue;
271
+ }
272
+ // A private file that is itself a link is read where it points: a build following a link reads it there too.
273
+ const kind = privateKind(name);
274
+ if (kind && kind !== 'a source map') note(path, `${rel}, ${kind}`);
275
+ }
276
+ }
277
+ level = next;
278
+ }
279
+ for (const [path, what] of more) note(path, what);
280
+ return (bytes) => {
281
+ const what = known.get(bytes.length)?.get(sha256(bytes));
282
+ return what ? `a copy of ${what}` : null;
283
+ };
284
+ }
285
+
286
+ /**
287
+ * Whether a folder is a git repository's own — HEAD, config and an objects/ folder — as a build leaves one it copied
288
+ * whole from a link to .git: the remote, with any token in its address, and every commit (25 Sep 2026). A repository
289
+ * with no commit yet has an empty objects/, which a build that copies files leaves behind; its HEAD says what it is.
290
+ */
291
+ export function gitDirectory(dir) {
292
+ try {
293
+ if (!lstatSync(join(dir, 'HEAD')).isFile() || !lstatSync(join(dir, 'config')).isFile()) return false;
294
+ if (there(join(dir, 'objects')) && lstatSync(join(dir, 'objects')).isDirectory()) return true;
295
+ return /^(ref: refs\/|[0-9a-f]{40}\s|[0-9a-f]{64}\s)/.test(readText(join(dir, 'HEAD'), 4096));
296
+ } catch { return false; }
297
+ }
298
+
299
+ const RASTER = /\.(png|jpe?g|gif|webp|ico|bmp)$/i;
300
+ /**
301
+ * The first bytes of each kind of picture an extension shows: PNG, JPEG, GIF, WebP, ICO, BMP (whose reserved bytes
302
+ * are zero, as no text's are), and AVIF's box. Any of them passes under any picture's name: Chrome reads a picture by
303
+ * its bytes, and a JPEG saved as .png is no secret.
304
+ */
305
+ const PICTURE = [
306
+ (b) => b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
307
+ (b) => b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
308
+ (b) => /^GIF8[79]a/.test(b.subarray(0, 6).toString('latin1')),
309
+ (b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
310
+ (b) => b.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00])),
311
+ (b) => b.length >= 10 && b.subarray(0, 2).toString('latin1') === 'BM' && b.readUInt32LE(6) === 0,
312
+ (b) => b.subarray(4, 8).toString('latin1') === 'ftyp',
313
+ ];
314
+ /**
315
+ * Why a file named as an image is none, or null: its first bytes are no picture's, or — an .svg — it is not SVG's
316
+ * text. A build that followed a link left a .env or a git config under an icon's name, and nothing else looked
317
+ * inside it (25 Sep 2026). An empty file holds nothing, and passes.
318
+ */
319
+ export function notAnImage(name, bytes) {
320
+ if (!bytes.length) return null;
321
+ if (/\.svg$/i.test(name)) {
322
+ const text = bytes.subarray(0, 1024 * 1024).toString('utf8').replace(/^\ufeff/, '');
323
+ return /^\s*</.test(text) && /<svg[\s>/:]/i.test(text) ? null : 'not the image its name says';
324
+ }
325
+ return RASTER.test(name) && !PICTURE.some((is) => is(bytes)) ? 'not the image its name says' : null;
326
+ }
327
+
328
+ /** The folders a build copies as they are: public/ (Vite, CRXJS and WXT), WXT's under src/, and one a config names. */
329
+ const PUBLIC_DIRS = ['public', 'src/public'];
330
+ const BUILD_CONFIGS = ['wxt.config', 'vite.config'].flatMap((n) => ['ts', 'mts', 'js', 'mjs'].map((e) => `${n}.${e}`));
331
+
332
+ /**
333
+ * The symbolic links in the folders of `root` that a build copies as they are, relative to it — but for those inside
334
+ * `packed`, whose links `pack` leaves out and names. WXT's copyPublicDirectory and Vite's copyDir copy what a link
335
+ * points at, so a template's public/icon-128.png -> ../.env is a PNG in the build; `pack` sees only the build, where
336
+ * the link is gone, and says where they are (25 Sep 2026).
337
+ */
338
+ export function publicLinks(root, packed = null) {
339
+ const dirs = new Set(PUBLIC_DIRS);
340
+ for (const config of BUILD_CONFIGS) {
341
+ let text;
342
+ try { text = readText(join(root, config)); } catch { continue; }
343
+ const named = /\bpublicDir\s*:\s*(['"`])([^'"`\n]+)\1/.exec(text)?.[2];
344
+ if (named) dirs.add(posix.normalize(named).replace(/\/+$/, ''));
345
+ }
346
+ const links = [];
347
+ let entries = 0;
348
+ const walk = (dir, depth) => {
349
+ let names;
350
+ try { names = readdirSync(dir).sort(); } catch { return; }
351
+ for (const name of names) {
352
+ if (++entries > 5_000) return;
353
+ const path = join(dir, name);
354
+ let st;
355
+ try { st = lstatSync(path); } catch { continue; }
356
+ if (st.isSymbolicLink()) links.push(path);
357
+ else if (st.isDirectory() && depth < 8) walk(path, depth + 1);
358
+ }
359
+ };
360
+ for (const dir of dirs) {
361
+ const at = resolve(root, dir);
362
+ // Not one a config names outside the project, which is not this command's to look through, nor one in the folder packed.
363
+ if (!at.startsWith(resolve(root) + sep) || (packed && (at === packed || at.startsWith(packed + sep)))) continue;
364
+ let st;
365
+ try { st = lstatSync(at); } catch { continue; }
366
+ if (st.isSymbolicLink()) links.push(at);
367
+ else if (st.isDirectory()) walk(at, 0);
368
+ }
369
+ return [...new Set(links.map((p) => relative(root, p).split(sep).join('/')))].sort();
370
+ }
371
+
152
372
  /**
153
373
  * The page `login` opens: the one the Wall gave, when it is the Wall's own over https — or, for a Wall on this
154
374
  * machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
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; check it, and give coding agents the Wall as an MCP server.",
5
5
  "type": "module",
6
6
  "bin": {