toolaby 1.6.3 → 1.6.5

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 (4) hide show
  1. package/README.md +1 -1
  2. package/bin.mjs +128 -23
  3. package/lib/safe.js +344 -32
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -26,7 +26,7 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
26
26
 
27
27
  **`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
28
28
 
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.
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 secret (a Stripe key, live or test, a webhook secret, an AWS access key, long-term or temporary, or a secret access key, an OpenAI or Anthropic key, a Google OAuth client secret, a GitHub, Slack or npm token, a PuTTY key, a Supabase `service_role` key, a password in an address), read to its end, and in UTF-16 or base64 too. A Google API key is made to be in a browser: its file is packed and named, for you to restrict the key in Google Cloud. A build copies what a link in `public/` points at, so `pack` also leaves out a file with the same bytes or text as one of the project's private files or the command's own session, in UTF-16 or base64 as well, a folder that is a git repository's own, git's history, index, refs, logs and config under any name, and a file named as an image that holds none, and it names the links in `public/`. A `.gz` or `.br` a build's compression plugin writes is judged by what it holds. It refuses a build when a file the manifest names would be left out, or when a public folder is itself a link into `.git`. `--allow-private` packs what it left out. Dotfiles and links are never packed. Nothing is sent anywhere.
30
30
 
31
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.
32
32
 
package/bin.mjs CHANGED
@@ -32,7 +32,7 @@
32
32
  // platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
33
33
  // so this and the dashboard's Wire button never disagree.
34
34
  import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, chmodSync, unlinkSync, renameSync } from 'node:fs';
35
- import { join, resolve, dirname, relative, basename, sep } from 'node:path';
35
+ import { join, resolve, dirname, relative, basename, sep, posix } from 'node:path';
36
36
  import { deflateRawSync } from 'node:zlib';
37
37
  import { homedir } from 'node:os';
38
38
  import { spawn } from 'node:child_process';
@@ -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, privateCopies, projectRoot, gitDirectory, notAnImage, publicLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
44
+ import { inside, signInPage, devicePolling, opener, privateKind, privateContent, privateCopies, googleKey, projectRoot, gitDirectory, gitObject, gitObjects, gitFile, notAnImage, unpacked, COMPRESSED, publicLinks, privateLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
45
45
 
46
- const VERSION = '1.6.3';
46
+ const VERSION = '1.6.5';
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.
@@ -90,14 +90,20 @@ const envElsewhere = () => (process.env.TOOLABY_TOKEN && !envRefused && !envToke
90
90
 
91
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
92
  const ANSWER_LIMIT = 4 * 1024 * 1024;
93
+ /** How long it waits for one, the whole of it: none of the Wall's takes more than a few seconds. */
94
+ const ANSWER_TIME = 30_000;
93
95
 
94
96
  async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
97
+ // The time ran out (fetchHere's deadline; AbortError where an older Node says so): one line, whichever part was late.
98
+ // A Wall that answered a byte at a time held `login` and `wire` for ever (round 8, 25 Sep 2026).
99
+ const late = (e) => (e?.name === 'TimeoutError' || e?.name === 'AbortError' ? new Error(`The Wall at ${wall} did not answer ${path.split('?')[0]} within ${ANSWER_TIME / 1000} seconds.`) : null);
95
100
  // Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
96
101
  const res = await fetchHere(`${wall}${path}`, {
102
+ ms: ANSWER_TIME,
97
103
  method,
98
104
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
99
105
  body: body ? JSON.stringify(body) : undefined,
100
- }).catch((e) => { throw new Error(`Could not reach ${wall}: ${e.message}`); });
106
+ }).catch((e) => { throw late(e) ?? new Error(`Could not reach ${wall}: ${e.message}`); });
101
107
  if (res.status === 401 && token && token === envToken()) {
102
108
  envRefused = true;
103
109
  const stored = storedToken();
@@ -105,8 +111,8 @@ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
105
111
  }
106
112
  // Read up to a few megabytes and no further: a Wall at another address (--wall, TOOLABY_WALL) that answered without
107
113
  // 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));
114
+ // An answer cut short reads as no JSON, as it always has; only one past the limit, or the time, stops the command.
115
+ const bytes = await readBounded(res, ANSWER_LIMIT).catch((e) => { const why = late(e); if (why) throw why; return Buffer.alloc(0); });
110
116
  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
117
  let json = {};
112
118
  try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
@@ -124,19 +130,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
124
130
  async function login() {
125
131
  const start = await api('/api/dev-auth/device/code', { method: 'POST', body: { client_id: CLIENT_ID }, token: null });
126
132
  if (!start.ok || !start.json.device_code) fail(`The Wall at ${wall} did not start a sign-in (HTTP ${start.status}). Is the address right? --wall names another.`);
127
- const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
133
+ const { device_code, user_code, verification_uri, verification_uri_complete } = start.json;
128
134
  const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
129
135
  // The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
130
136
  // Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
131
137
  const page = signInPage(link, wall);
132
138
  if (!page) fail(`The Wall at ${wall} gave ${JSON.stringify(String(link).slice(0, 200))} as its sign-in page, which is not its own address over https.\n It was not opened, and nothing was signed in.`);
133
- out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
139
+ // How often it asks and for how long: what the Wall said, a quarter of an hour at most (lib/safe.js).
140
+ const { every: first, lasts } = devicePolling(start.json);
141
+ out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(lasts / 60_000)} minutes)\n`);
134
142
  openBrowser(page);
135
- let every = Math.max(1, Number(interval) || 5) * 1000;
136
- const until = Date.now() + Number(expires_in) * 1000;
143
+ let every = first;
144
+ const until = Date.now() + lasts;
137
145
  process.stdout.write(' Waiting for your approval');
138
146
  while (Date.now() < until) {
139
- await sleep(every);
147
+ // Never past the code's end: the last ask comes as it ends.
148
+ await sleep(Math.min(every, until - Date.now()));
140
149
  process.stdout.write('.');
141
150
  const poll = await api('/api/dev-auth/device/token', { method: 'POST', body: { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLIENT_ID }, token: null });
142
151
  if (poll.ok && poll.json.access_token) {
@@ -496,10 +505,52 @@ async function webhooks() {
496
505
  // admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
497
506
  // or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
498
507
  // can ship with. What looks private — a key, an environment file, a source map — is left out and named, and so is a
499
- // link out of the folder: a store zip is public. Nothing is sent anywhere: it reads the folder and writes one file.
508
+ // link out of the folder: a store zip is public. A build it would leave a file of the manifest's out of, which Chrome
509
+ // would not load, is refused, as is one whose public folder is a link into .git (round 9, 25 Sep 2026). Nothing is
510
+ // sent anywhere: it reads the folder and writes one file.
500
511
  const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
501
512
  const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
502
513
  const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
514
+ /** Why a file is left out as no file of the folder's own, not as a private one: never packed, --allow-private or not. */
515
+ const NOT_OWN = ['a link', 'not a file', 'a name no zip should hold'];
516
+
517
+ /**
518
+ * The files a manifest names, each with what it is to the extension, as the zip spells them: no leading ./ or /, no
519
+ * query or fragment. A pattern (a web-accessible resource's `*`) names no one file, and an address none at all.
520
+ */
521
+ function namedFiles(manifest) {
522
+ const named = [];
523
+ const add = (path, what) => {
524
+ if (typeof path !== 'string' || path.includes('*') || /^[a-z][a-z0-9+.-]*:/i.test(path)) return;
525
+ const clean = posix.normalize(path.split(/[?#]/)[0].replace(/^(?:\.?\/)+/, ''));
526
+ if (clean && clean !== '.' && clean !== '..' && !clean.startsWith('../')) named.push([clean, what]);
527
+ };
528
+ const list = (value) => (Array.isArray(value) ? value : []);
529
+ const icons = (value) => { for (const path of value && typeof value === 'object' ? Object.values(value) : [value]) add(path, 'an icon'); };
530
+ add('manifest.json', 'the manifest');
531
+ add(manifest.background?.service_worker, 'the background service worker');
532
+ for (const path of list(manifest.background?.scripts)) add(path, 'a background script');
533
+ add(manifest.background?.page, 'the background page');
534
+ for (const script of list(manifest.content_scripts)) {
535
+ for (const path of list(script?.js)) add(path, 'a content script');
536
+ for (const path of list(script?.css)) add(path, "a content script's style sheet");
537
+ }
538
+ for (const surface of ['action', 'browser_action', 'page_action']) { add(manifest[surface]?.default_popup, 'the popup'); icons(manifest[surface]?.default_icon); }
539
+ add(manifest.side_panel?.default_path, 'the side panel');
540
+ add(manifest.sidebar_action?.default_panel, 'the side panel');
541
+ icons(manifest.sidebar_action?.default_icon);
542
+ add(manifest.options_page, 'the options page');
543
+ add(manifest.options_ui?.page, 'the options page');
544
+ add(manifest.devtools_page, 'the DevTools page');
545
+ if (manifest.chrome_url_overrides && typeof manifest.chrome_url_overrides === 'object') for (const path of Object.values(manifest.chrome_url_overrides)) add(path, 'a page it replaces');
546
+ for (const path of list(manifest.sandbox?.pages)) add(path, 'a sandboxed page');
547
+ icons(manifest.icons);
548
+ for (const entry of list(manifest.web_accessible_resources)) for (const path of typeof entry === 'string' ? [entry] : list(entry?.resources)) add(path, 'a web-accessible resource');
549
+ for (const rules of list(manifest.declarative_net_request?.rule_resources)) add(rules?.path, 'a rule set');
550
+ add(manifest.storage?.managed_schema, 'its managed storage schema');
551
+ if (typeof manifest.default_locale === 'string') add(`_locales/${manifest.default_locale}/messages.json`, 'its default locale');
552
+ return named;
553
+ }
503
554
 
504
555
  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; });
505
556
  const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
@@ -542,10 +593,18 @@ function zipOf(entries) {
542
593
  * A build has already followed the links of the project it came from: what they pointed at is an ordinary file here.
543
594
  * 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
595
  * repository's own, and a file named as an image when it holds none (round 7, 25 Sep 2026).
596
+ *
597
+ * And a compressed file is what it holds (round 8, 25 Sep 2026): a build's compression plugin writes data.json.gz and
598
+ * data.json.br beside data.json, and `pack` left data.json out as private while it packed both. One beside a file left
599
+ * out goes with it, however large; any other is opened, a few MB of it at most (lib/safe.js), and judged as the file.
600
+ *
601
+ * And git's other files are git's, whatever their names — its index, its refs, its logs, a config (round 9, 25 Sep 2026).
545
602
  */
546
- function filesIn(root, copyOf = () => null) {
603
+ function filesIn(root, copyOf = () => null, fromLink = () => null) {
547
604
  const found = []; const left = []; const privately = [];
548
605
  const allowed = flag('--allow-private');
606
+ const judge = (name, bytes) => privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? gitObject(bytes) ?? gitFile(bytes) ?? notAnImage(name, bytes);
607
+ const judged = new Map(); // what each file found private is, by its path: a compressed one beside it is the same
549
608
  const walk = (dir) => {
550
609
  for (const name of readdirSync(dir).sort()) {
551
610
  if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
@@ -554,15 +613,27 @@ function filesIn(root, copyOf = () => null) {
554
613
  const st = lstatSync(p);
555
614
  if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
556
615
  if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
616
+ // What a build copied from a link into .git, or to a private file, left out whole, as the link's (round 8).
617
+ const via = fromLink(rel);
618
+ if (via) { judged.set(rel, via); if (!allowed) { left.push([rel, via]); continue; } privately.push([rel, via]); }
557
619
  if (st.isDirectory()) {
558
620
  // 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']); }
621
+ const git = gitDirectory(p) ? 'a git directory' : gitObjects(p) ? 'git history' : null;
622
+ if (git && !via) { if (!allowed) { left.push([rel, git]); continue; } privately.push([rel, git]); }
560
623
  walk(p);
561
624
  continue;
562
625
  }
563
626
  if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
564
627
  const bytes = readFileSync(p);
565
- const why = privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? notAnImage(name, bytes);
628
+ let why = judge(name, bytes);
629
+ if (!why) {
630
+ // Sorted, data.json comes before data.json.br and data.json.gz: its verdict is known when theirs is asked.
631
+ const twin = COMPRESSED.test(name) ? judged.get(rel.replace(COMPRESSED, '')) : null;
632
+ const inner = twin ? null : unpacked(name, bytes);
633
+ const held = twin ?? (inner && judge(inner.name, inner.bytes));
634
+ if (held) why = `${held}, compressed`;
635
+ }
636
+ if (why) judged.set(rel, why);
566
637
  if (why && !allowed) left.push([rel, why]);
567
638
  else { found.push(p); if (why) privately.push([rel, why]); }
568
639
  }
@@ -588,8 +659,38 @@ function pack() {
588
659
  // The project the build came from — or, with none above it, the folder alone — and the command's own secrets: what a
589
660
  // build could have copied in under another name, by following a link (lib/safe.js).
590
661
  const project = projectRoot(folder);
662
+ const allowed = flag('--allow-private');
591
663
  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);
664
+ // What a link in the project's public folders put into the build, when it points into .git or at a private file.
665
+ const leaking = project ? privateLinks(project, folder) : [];
666
+ // A public folder that is itself such a link, or a build whose top is a git repository's own folder, is refused: the
667
+ // build copied what the link points at to the top of its output, among the extension's own files, and nothing tells
668
+ // them apart — `public -> .git` put the repository's index, its logs and its last commit message in the zip (round 9,
669
+ // 25 Sep 2026).
670
+ const whole = leaking.filter((l) => !l.within);
671
+ if (whole.length && !allowed) fail(`Not packed: ${whole.map((l) => `${oneLine(l.link)} is ${l.why}`).join(', ')}. A build copies what a public folder links to, whole, to the top of its output.\n Make ${whole.length > 1 ? 'each' : 'it'} a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
672
+ if (gitDirectory(folder) && !allowed) fail(`Not packed: ${oneLine(relative(process.cwd(), folder) || '.')} is a git repository's own folder (HEAD, config and its history), as a build leaves when its public folder is a link to .git.\n Make the public folder a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
673
+ const fromLink = (rel) => { const hit = leaking.find((l) => l.within && (rel === l.within || rel.startsWith(`${l.within}/`))); return hit ? `from ${hit.link}, ${hit.why}` : null; };
674
+ const { found: files, left, privately } = filesIn(folder, copyOf, fromLink);
675
+ // What was left out as no file of the folder's own, and what as private.
676
+ const links = left.filter(([, why]) => NOT_OWN.includes(why));
677
+ const kept = left.filter((entry) => !links.includes(entry));
678
+
679
+ // A file the manifest names that is left out makes a zip Chrome does not load, or loads without its worker, a page or
680
+ // an icon: refused, and nothing written (round 9, 25 Sep 2026). --allow-private packs a private one; a link, never.
681
+ const broken = [];
682
+ for (const [path, what] of namedFiles(manifest)) {
683
+ const gone = left.find(([rel]) => path.toLowerCase() === rel.toLowerCase() || path.toLowerCase().startsWith(`${rel.toLowerCase()}/`));
684
+ if (gone && !broken.some(([p]) => p === path)) broken.push([path, what, gone[1]]);
685
+ }
686
+ if (broken.length) {
687
+ const secret = broken.filter(([, , why]) => !NOT_OWN.includes(why)).length;
688
+ const advice = [
689
+ secret && `Take out what is private and build again, or pack ${secret > 1 ? 'them' : 'it'} anyway with --allow-private, for anyone who downloads the item to read.`,
690
+ secret < broken.length && 'The zip holds the folder\'s own files, and never a link: make each the file itself.',
691
+ ].filter(Boolean);
692
+ fail(`Not packed: the manifest names ${broken.length > 1 ? 'files' : 'a file'} this leaves out, and the zip would not load:\n\n${broken.map(([path, what, why]) => ` · ${oneLine(path)}, ${what} (${oneLine(why)})`).join('\n')}\n\n ${advice.join('\n ')}`);
693
+ }
593
694
  const problems = [];
594
695
 
595
696
  // Which side the build sells on: the tool keys inside it, wherever the bundler put them.
@@ -632,12 +733,14 @@ function pack() {
632
733
  renameSync(fresh, target);
633
734
  const kb = (statSync(target).size / 1024).toFixed(0);
634
735
  out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
635
- // What was left out, and why — or, asked for, what private file went in: a store zip is public.
636
- const links = left.filter(([, why]) => why === 'a link' || why === 'not a file' || why === 'a name no zip should hold');
637
- const kept = left.filter((entry) => !links.includes(entry));
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.`);
736
+ // What was left out, and why — or, asked for, what private file went in: a store zip is public. A reason can name a
737
+ // file of the project's (a copy of…, from a link…), and so is put on one line, as the file's own name is.
738
+ if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${oneLine(f)} (${oneLine(why)})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
639
739
  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.`);
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'}.`);
740
+ if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${oneLine(f)} (${oneLine(why)})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
741
+ // Packed and named: a Google API key is made to be in a browser, and kept to its use by its restrictions (lib/safe.js).
742
+ const google = entries.filter(([, bytes]) => googleKey(bytes)).map(([name]) => oneLine(name));
743
+ if (google.length) out(` ${google.length > 1 ? 'Google API keys are' : 'A Google API key is'} in ${google.join(', ')}: anyone who downloads the item can read ${google.length > 1 ? 'them' : 'it'}. Restrict ${google.length > 1 ? 'each' : 'it'} to this extension, and to the APIs it calls, in Google Cloud → Credentials.`);
641
744
  // Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
642
745
  const linked = project ? publicLinks(project, folder) : [];
643
746
  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.`);
@@ -670,8 +773,10 @@ function help() {
670
773
  toolaby tools your tools, with their ids
671
774
  toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
672
775
  refuses a Test key or localhost inside --out <file.zip>
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
776
+ leaves out keys, certificates, .env and credential files, source maps, secrets,
777
+ git's files, a build's copy of a private file (compressed, UTF-16 or base64),
778
+ an image that is none; refuses a build if one is a file the manifest names
779
+ --allow-private packs them
675
780
  toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
676
781
  --browser loads it in Chromium (needs Playwright in the project)
677
782
  toolaby mcp the Wall for coding agents, an MCP server on stdio:
package/lib/safe.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // What this command will not do, whoever asks: write outside the project it was pointed at, write a file of a new
2
2
  // extension it does not know, open a sign-in page that is not the Wall's, put a private file in a store zip, print a
3
- // line it did not write, ask whatever address a project names, or take a download without end.
3
+ // line it did not write, ask whatever address a project names, or take a download or wait for a sign-in without end.
4
4
  //
5
5
  // Each of these took someone else's word. A cloned repository's manifest named the file `wire` wrote — a service
6
6
  // worker at "../../.zshrc" overwrote the shell's startup file — and a link in the folder took a write wherever it
@@ -9,7 +9,8 @@
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
11
  import { createHash } from 'node:crypto';
12
- import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
12
+ import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
13
+ import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync, constants as zlibConstants } from 'node:zlib';
13
14
  import { homedir } from 'node:os';
14
15
  import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
15
16
 
@@ -118,16 +119,21 @@ export async function readBounded(res, limit) {
118
119
  * A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
119
120
  * refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
120
121
  * for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026).
122
+ *
123
+ * And one that gives up at its time: the answer, redirects and body alike — read or not yet — within `ms` of asking, or
124
+ * a TimeoutError, as fetchBounded's. Bounded in size alone, a Wall that answered a byte at a time held `login`, `wire`
125
+ * and the MCP server for ever (round 8, 25 Sep 2026).
121
126
  */
122
- export async function fetchHere(url, init = {}) {
127
+ export async function fetchHere(url, { ms = 30_000, ...init } = {}) {
128
+ const signal = AbortSignal.timeout(ms);
123
129
  const origin = new URL(url).origin;
124
- let res = await fetch(url, { ...init, redirect: 'manual' });
130
+ let res = await fetch(url, { ...init, redirect: 'manual', signal });
125
131
  for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
126
132
  const next = new URL(res.headers.get('location'), url);
127
133
  await res.body?.cancel().catch(() => {});
128
134
  if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
129
135
  url = next.href;
130
- res = await fetch(url, { ...init, redirect: 'manual' });
136
+ res = await fetch(url, { ...init, redirect: 'manual', signal });
131
137
  }
132
138
  return res;
133
139
  }
@@ -151,32 +157,164 @@ export function readText(path, limit = READ_LIMIT) {
151
157
  * Why a file's contents have no place in a store zip, whatever it is called, or null: a private key, a cloud service
152
158
  * account's key, a source map written into the code. Names alone let `key.txt`, a Google Cloud key under the name
153
159
  * 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.
160
+ * — a Stripe or AWS key, a GitHub, Slack or npm token, a password in an address — until round 7 of the same day, and
161
+ * an OpenAI or Anthropic key, a Slack app token, a Stripe test key and an AWS secret access key until round 8.
162
+ *
163
+ * All of the file, in each form a copy of a text takes (readThrough): the first 4 MB alone were read, so a key further
164
+ * into a large bundle was packed; and a copy in UTF-16, or in base64, held its secret between NULs or in other letters
165
+ * (round 9, 25 Sep 2026).
155
166
  */
156
167
  export function privateContent(bytes) {
157
- const text = bytes.subarray(0, 4 * 1024 * 1024).toString('latin1');
158
- if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
159
- if (pemKey(text)) return 'a private key inside';
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;
168
+ return readThrough(bytes, (text) => {
169
+ if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
170
+ if (pemKey(text)) return 'a private key inside';
171
+ if (/[#@]\s*sourceMappingURL\s*=\s*data:/.test(text)) return 'a source map inside';
172
+ for (const [shape, why, honest] of SECRETS) for (const found of text.matchAll(shape)) if (!standIn(found[1] ?? found[0]) && !honest?.(found)) return why;
173
+ if (passwordIn(text)) return 'a password in an address inside';
174
+ return null;
175
+ });
164
176
  }
165
177
 
166
178
  /**
167
179
  * 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.
180
+ * (`key.startsWith('sk_live_')`), nor a publishable key, which is made to be in a browser — and, for some, what says
181
+ * the one found is honest after all. Where a shape captures, the capture is the secret, and the rest what names it.
169
182
  */
170
183
  const SECRETS = [
171
184
  [/\b[rs]k_live_[0-9A-Za-z]{24,}/g, 'a Stripe live key inside'],
185
+ // A test key reads and writes the account's test data, customers' addresses among them: a secret too (round 8).
186
+ [/\b[rs]k_test_[0-9A-Za-z]{24,}/g, 'a Stripe test key inside'],
172
187
  [/\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'],
188
+ // A long-term key's id, or a temporary one's (ASIA…), which comes with its secret and a session token (round 9).
189
+ [/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, 'an AWS access key inside'],
190
+ // Its secret, which a file can hold without the AKIA id beside it: 40 characters of base64 where a name says what
191
+ // they are (aws_secret_access_key, AWS_SECRET_ACCESS_KEY, secretAccessKey…), both cases in them, as no hex digest's.
192
+ [/(?:(?<![A-Za-z0-9])aws[\w.-]{0,30}?|secret_?access_?key)["']?[ \t]{0,16}(?:[:=]|=>)[ \t]{0,16}["']?([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])/gi, 'an AWS secret access key inside', ([, key]) => !/[A-Z]/.test(key) || !/[a-z]/.test(key)],
174
193
  [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82,})/g, 'a GitHub token inside'],
175
194
  [/\bxox[abprs]-\d+-[A-Za-z0-9-]{20,}/g, 'a Slack token inside'],
195
+ [/\bxapp-\d-[A-Z0-9]{8,}-\d{8,}-[A-Za-z0-9]{32,}/g, 'a Slack app token inside'],
176
196
  [/\bnpm_[A-Za-z0-9]{36,}/g, 'an npm token inside'],
197
+ // Google's OAuth client secret, which signs in as the app: both cases after GOCSPX-, as no placeholder has (round 9).
198
+ [/\bGOCSPX-[A-Za-z0-9_-]{20,}/g, 'a Google OAuth client secret inside', ([secret]) => !/[A-Z]/.test(secret.slice(7)) || !/[a-z]/.test(secret.slice(7))],
199
+ // OpenAI's: a project's, a service account's or an admin's, or the first kind, 48 characters after sk-.
200
+ [/\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{80,}|[A-Za-z0-9]{48}(?![A-Za-z0-9_-]))/g, 'an OpenAI key inside'],
201
+ [/\bsk-ant-[a-z]{2,8}\d{2}-[A-Za-z0-9_-]{80,}/g, 'an Anthropic key inside'],
202
+ // A PuTTY key file by its first two lines, the key's kind and then its encryption: code that reads one holds neither (round 9).
203
+ [/PuTTY-User-Key-File-\d+: [\w.@-]+(?:\r?\n|(?:\\r)?\\n)Encryption: [\w-]+/g, 'a private key inside'],
204
+ // Supabase's service_role key: a JWT whose payload names the role, which passes every row-level rule. The anon key and
205
+ // a signed-in user's token have the same shape, and are made to be in a browser, as a publishable key is (round 9).
206
+ [/\bey[A-Za-z0-9_-]{10,500}\.ey[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{20,1000}/g, 'a Supabase service_role key inside', ([jwt]) => !adminRole(jwt)],
177
207
  ];
178
208
  /** What stands where a secret goes, in docs and examples: a run of one character, or AWS's own EXAMPLE key. */
179
209
  const standIn = (found) => /(.)\1{7}/.test(found) || found.includes('EXAMPLE');
210
+ /** Whether a JWT's payload names Supabase's service_role, or its supabase_admin. */
211
+ function adminRole(jwt) {
212
+ try { return ['service_role', 'supabase_admin'].includes(JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')).role); } catch { return false; }
213
+ }
214
+
215
+ /** A Google API key: `AIza` and 35 more. */
216
+ const GOOGLE_KEY = /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g;
217
+ /**
218
+ * Whether a file holds a Google API key, a stand-in aside. Such a key is made to be in a browser — a Firebase web
219
+ * config's, the Maps or YouTube key an extension calls with — and is kept to its use by its restrictions in Google
220
+ * Cloud, not by being unseen. `pack` packs the file and names it (round 9, 25 Sep 2026): it left it out until then,
221
+ * breaking the zip for a lone Firebase key or a YouTube key, and let any key through beside a Firebase word.
222
+ */
223
+ export function googleKey(bytes) {
224
+ return readThrough(bytes, (text) => ([...text.matchAll(GOOGLE_KEY)].some(([key]) => !standIn(key)) ? 'a Google API key' : null)) !== null;
225
+ }
226
+
227
+ /** How much of a file is read as text at once, and how far each piece reaches back into the one before: further than any shape read here, a private key's 32 KB among them. */
228
+ const PIECE = 4 * 1024 * 1024;
229
+ const OVERLAP = 128 * 1024;
230
+
231
+ /**
232
+ * The first answer `look` gives for a file's text, with the form the file held it in, or null. Each of the file's
233
+ * readings is read in pieces of PIECE bytes, each reaching OVERLAP back into the one before: what a boundary splits is
234
+ * whole in one of them, and no string is larger than a piece, however large the file.
235
+ */
236
+ function readThrough(bytes, look) {
237
+ for (const [view, how] of readings(bytes)) {
238
+ for (let from = 0; from < view.length; from += PIECE - OVERLAP) {
239
+ const found = look(view.subarray(from, from + PIECE).toString('latin1'));
240
+ if (found) return `${found}${how}`;
241
+ if (from + PIECE >= view.length) break;
242
+ }
243
+ }
244
+ return null;
245
+ }
246
+
247
+ /**
248
+ * A file's bytes in each form a copy of a text takes, as [bytes, how it held them]: as they are; decoded from UTF-16,
249
+ * as Windows PowerShell 5.1 writes `Get-Content .env > dist\data.txt`; decoded from base64, where the file is that and
250
+ * nothing else; and one inside the other, three deep, since PowerShell's `>` writes base64 in UTF-16 too. Only a file
251
+ * of `limit` bytes at most is decoded (round 9, 25 Sep 2026).
252
+ */
253
+ function readings(bytes, limit = Infinity, how = '', depth = 3) {
254
+ const all = [[bytes, how && `, in ${how}`]];
255
+ if (!depth || bytes.length > limit) return all;
256
+ const order = utf16(bytes);
257
+ if (order) all.push(...readings(fromUtf16(bytes, order), limit, how ? `${how} and UTF-16` : 'UTF-16', depth - 1));
258
+ const held = fromBase64(bytes);
259
+ if (held) all.push(...readings(held, limit, how ? `${how} and base64` : 'base64', depth - 1));
260
+ return all;
261
+ }
262
+
263
+ /**
264
+ * Which way round a text in UTF-16 is, 'le' or 'be', or null: by its byte-order mark (FF FE, FE FF), or, with none, by
265
+ * the NULs of its Latin letters, in the same half of three pairs in four over its first 512.
266
+ */
267
+ function utf16(bytes) {
268
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return 'le';
269
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return 'be';
270
+ const pairs = Math.min(bytes.length >> 1, 512);
271
+ if (pairs < 8) return null;
272
+ let low = 0; let high = 0;
273
+ for (let i = 0; i < pairs * 2; i += 2) {
274
+ if (bytes[i] && !bytes[i + 1]) low++;
275
+ else if (!bytes[i] && bytes[i + 1]) high++;
276
+ }
277
+ return low >= pairs * 0.75 ? 'le' : high >= pairs * 0.75 ? 'be' : null;
278
+ }
279
+
280
+ /** A text in UTF-16 as UTF-8, a piece at a time. */
281
+ function fromUtf16(bytes, order) {
282
+ const even = bytes.length - (bytes.length % 2);
283
+ const parts = [];
284
+ for (let from = 0; from < even; from += PIECE) {
285
+ const piece = bytes.subarray(from, Math.min(from + PIECE, even));
286
+ // Big-endian turned round in a copy: swap16 turns the bytes it is given round where they are.
287
+ parts.push(Buffer.from((order === 'be' ? Buffer.from(piece).swap16() : piece).toString('utf16le'), 'utf8'));
288
+ }
289
+ return Buffer.concat(parts);
290
+ }
291
+
292
+ const BASE64 = new Uint8Array(256);
293
+ for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/-_') BASE64[c.charCodeAt(0)] = 1;
294
+ /**
295
+ * What a file that is base64 and nothing else holds, or null: past blank space and a byte-order mark, 16 characters at
296
+ * least, of either alphabet, and `=` only at the end. Decoded a piece at a time: PIECE is a whole number of quartets.
297
+ */
298
+ function fromBase64(bytes) {
299
+ let count = 0; let end = 0;
300
+ for (let i = bom(bytes); i < bytes.length; i++) {
301
+ const b = bytes[i];
302
+ if (blank(b)) continue;
303
+ if (b === 0x3d) { if (++end > 2) return null; } else if (end || !BASE64[b]) return null;
304
+ count++;
305
+ }
306
+ if (count < 16 || (count - end) % 4 === 1) return null;
307
+ const parts = [];
308
+ const chars = Buffer.allocUnsafe(Math.min(count, PIECE));
309
+ let n = 0;
310
+ for (let i = bom(bytes); i < bytes.length; i++) {
311
+ if (blank(bytes[i])) continue;
312
+ chars[n++] = bytes[i];
313
+ if (n === chars.length) { parts.push(Buffer.from(chars.toString('latin1'), 'base64')); n = 0; }
314
+ }
315
+ if (n) parts.push(Buffer.from(chars.toString('latin1', 0, n), 'base64'));
316
+ return Buffer.concat(parts);
317
+ }
180
318
 
181
319
  /** An address with a password in it, `scheme://user:password@host`: the user and password as an address spells them. */
182
320
  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;
@@ -231,6 +369,64 @@ const LOOK = { depth: 4, entries: 20_000 };
231
369
  /** Folders the look for private files passes by: installed packages, caches, and git's own, of which only config is read. */
232
370
  const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.next', '.turbo', '.cache']);
233
371
 
372
+ /** Space, tab, and the line ends and feeds: what a text form changes, and what its start passes over. */
373
+ const blank = (b) => b === 0x20 || (b >= 0x09 && b <= 0x0d);
374
+ const bom = (bytes) => (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0);
375
+
376
+ /**
377
+ * A text as it reads, whatever an editor, a platform or a build did around its words: no byte-order mark, LF line
378
+ * ends, no space or tab at the end of a line, nothing blank at the end — or null for what is no text (a NUL in its
379
+ * first 8,000 bytes, as git judges one) or nothing but blank. One pass, and no pattern that could backtrack.
380
+ */
381
+ function textForm(bytes) {
382
+ if (bytes.subarray(0, 8000).includes(0)) return null;
383
+ const out = Buffer.allocUnsafe(bytes.length);
384
+ let n = 0;
385
+ for (let i = bom(bytes); i < bytes.length; i++) {
386
+ const b = bytes[i];
387
+ if (b !== 0x0a && b !== 0x0d) { out[n++] = b; continue; }
388
+ if (b === 0x0d && bytes[i + 1] === 0x0a) i++;
389
+ while (n && (out[n - 1] === 0x20 || out[n - 1] === 0x09)) n--;
390
+ out[n++] = 0x0a;
391
+ }
392
+ while (n && blank(out[n - 1])) n--;
393
+ return n ? out.subarray(0, n) : null;
394
+ }
395
+
396
+ /**
397
+ * The first 64 bytes of a text that are not blank, after a byte-order mark: every text form of a text starts with the
398
+ * same, so a file that starts otherwise is no copy — told without reading on. Null for a NUL among them, or none.
399
+ */
400
+ function textStart(bytes) {
401
+ let start = '';
402
+ for (let i = bom(bytes); i < bytes.length && start.length < 64; i++) {
403
+ if (bytes[i] === 0) return null;
404
+ if (!blank(bytes[i])) start += String.fromCharCode(bytes[i]);
405
+ }
406
+ return start || null;
407
+ }
408
+
409
+ /** The names a build gives a compressed copy: vite-plugin-compression's and compression-webpack-plugin's, beside the file. */
410
+ export const COMPRESSED = /\.(gz|br)$/i;
411
+
412
+ /**
413
+ * What a compressed file holds, and the name it had — `{ name, bytes }` — or null: a gzip whatever its name, and by the
414
+ * name a build gives it a .gz of zlib's (a plugin's "deflate") or a .br. Opened to `limit` bytes and no further, as
415
+ * zlib's maxOutputLength stops it: one that holds more, a bomb among them, is null, as are what is not compressed and
416
+ * what does not open. A build's compression plugin writes data.json.gz and .br beside data.json, and `pack` judged
417
+ * them by their compressed bytes, in which nothing private can be read (round 8, 25 Sep 2026).
418
+ *
419
+ * A .gz that is not zlib's is opened as raw deflate, which has no header to know it by: vite-plugin-compression's
420
+ * deflateRaw writes one under that name, and it was packed (round 9, 25 Sep 2026).
421
+ */
422
+ export function unpacked(name, bytes, limit = COPY_LIMIT) {
423
+ const ways = bytes[0] === 0x1f && bytes[1] === 0x8b ? [gunzipSync] : /\.br$/i.test(name) ? [brotliDecompressSync] : /\.gz$/i.test(name) ? [inflateSync, inflateRawSync] : [];
424
+ for (const open of ways) {
425
+ try { return { name: name.replace(COMPRESSED, ''), bytes: open(bytes, { maxOutputLength: limit }) }; } catch { /* the next way, if there is one */ }
426
+ }
427
+ return null;
428
+ }
429
+
234
430
  /**
235
431
  * 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
432
  * file" or null. Its bytes are compared (SHA-256) with every file under `root` that is private by its name (privateKind:
@@ -238,18 +434,36 @@ const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.nex
238
434
  * 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
435
  * points at as an ordinary file under the link's name, so a cloned template's public/icon-128.png -> ../.env reached
240
436
  * the zip as a PNG, the developer's .env inside (25 Sep 2026).
437
+ *
438
+ * And so is their text: a copy with CRLF line ends, a byte-order mark, or a newline more or less at its end had other
439
+ * bytes, and was packed (round 8, 25 Sep 2026). Each text form is compared too — only a file whose text starts as a
440
+ * private one's is read through, so a build's bundles cost a look at their first 64 characters.
441
+ *
442
+ * And so is what they hold in each form a copy of a text takes (readings): a copy in UTF-16, which Windows PowerShell
443
+ * 5.1 writes for `Get-Content .env > dist\data.txt`, or in base64, had other bytes and no text form, and was packed
444
+ * (round 9, 25 Sep 2026). A private file in UTF-16 is known by its text in UTF-8 as well.
241
445
  */
242
446
  export function privateCopies(root, more = []) {
243
447
  const known = new Map(); // size → hash → what the file is
448
+ const texts = new Map(); // how its text starts → hash of its text form → what the file is
244
449
  const note = (path, what) => {
245
450
  let bytes;
246
451
  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);
452
+ for (const [view] of readings(bytes)) {
453
+ // Nothing in it is nothing to keep, and a copy of nothing is every empty file.
454
+ if (!view.toString('latin1').trim()) continue;
455
+ const same = known.get(view.length) ?? new Map();
456
+ known.set(view.length, same);
457
+ const hash = sha256(view);
458
+ if (!same.has(hash)) same.set(hash, what);
459
+ const form = textForm(view);
460
+ const start = form && textStart(view);
461
+ if (!start) continue;
462
+ const alike = texts.get(start) ?? new Map();
463
+ texts.set(start, alike);
464
+ const formHash = sha256(form);
465
+ if (!alike.has(formHash)) alike.set(formHash, what);
466
+ }
253
467
  };
254
468
  let entries = 0;
255
469
  // Level by level, so the project's own top — where .env and .npmrc are kept — is always read, however large the rest.
@@ -278,8 +492,17 @@ export function privateCopies(root, more = []) {
278
492
  }
279
493
  for (const [path, what] of more) note(path, what);
280
494
  return (bytes) => {
281
- const what = known.get(bytes.length)?.get(sha256(bytes));
282
- return what ? `a copy of ${what}` : null;
495
+ // Decoded up to three times the most a private file is: its copy in UTF-16, or in base64 written in UTF-16, is no larger.
496
+ for (const [view, how] of readings(bytes, 3 * COPY_LIMIT)) {
497
+ let what = known.get(view.length)?.get(sha256(view));
498
+ if (!what && view.length <= COPY_LIMIT) {
499
+ const alike = texts.get(textStart(view));
500
+ const form = alike && textForm(view);
501
+ if (form) what = alike.get(sha256(form));
502
+ }
503
+ if (what) return `a copy of ${what}${how}`;
504
+ }
505
+ return null;
283
506
  };
284
507
  }
285
508
 
@@ -296,11 +519,61 @@ export function gitDirectory(dir) {
296
519
  } catch { return false; }
297
520
  }
298
521
 
299
- const RASTER = /\.(png|jpe?g|gif|webp|ico|bmp)$/i;
522
+ /**
523
+ * Git's own history, whatever its name: a pack or its index (`PACK`, `\xfftOc`), or a loose object — zlib that opens
524
+ * on `blob 57\0`, `tree`, `commit`, `tag`. A build that follows a link to .git/objects copies a folder of these, every
525
+ * commit in it, files removed from the tree among them; round seven looked for a repository's HEAD and config, which
526
+ * an object store has not (round 8, 25 Sep 2026).
527
+ */
528
+ export function gitObject(bytes) {
529
+ if (bytes.length >= 8 && (bytes.subarray(0, 4).toString('latin1') === 'PACK' || bytes.subarray(0, 4).equals(Buffer.from([0xff, 0x74, 0x4f, 0x63])))) return 'git history';
530
+ if (bytes.length >= 2 && bytes[0] === 0x78 && (bytes[0] * 256 + bytes[1]) % 31 === 0) {
531
+ try {
532
+ const head = inflateSync(bytes.subarray(0, 1024), { finishFlush: zlibConstants.Z_SYNC_FLUSH }).subarray(0, 32).toString('latin1');
533
+ if (/^(blob|tree|commit|tag) \d+\0/.test(head)) return 'git history';
534
+ } catch { /* not zlib, or not whole: nothing git reads */ }
535
+ }
536
+ return null;
537
+ }
538
+
539
+ /** A line of git's list of refs (packed-refs, which starts with its own header, or FETCH_HEAD), and one of its logs. */
540
+ const GIT_REFS = /^(?:# pack-refs with:|[0-9a-f]{40}(?:[0-9a-f]{24})?\t(?:not-for-merge)?\t)/;
541
+ const GIT_LOG = /^[0-9a-f]{40}(?:[0-9a-f]{24})? [0-9a-f]{40}(?:[0-9a-f]{24})? [^\n<]*<[^\n>]*> \d+ [+-]\d{4}(?:\t|\r?\n|$)/;
542
+ /**
543
+ * Git's other files, whatever their names, by what they hold: its index (`DIRC`), its list of refs, the log of where
544
+ * each branch has been — an author, a time and a commit's subject a line — and a repository's config, which names its
545
+ * remote. A build that copied them from .git under other names shipped them all, the objects aside (round 9, 25 Sep
546
+ * 2026). A commit message being written (COMMIT_EDITMSG) is text like any other, and is not told apart.
547
+ */
548
+ export function gitFile(bytes) {
549
+ if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'DIRC' && [2, 3, 4].includes(bytes.readUInt32BE(4))) return "git's index";
550
+ const head = bytes.subarray(0, 64 * 1024).toString('latin1');
551
+ if (GIT_REFS.test(head)) return "git's refs";
552
+ if (GIT_LOG.test(head)) return "git's log";
553
+ // A config from its first section on (comments before it aside), with the [core] and the format version git writes.
554
+ if (/^\s*(?:[#;][^\n]*\n\s*)*\[[^\]\n]+\]/.test(head) && /^[ \t]*\[core\][ \t]*\r?$/m.test(head) && /^[ \t]*repositoryformatversion[ \t]*=/m.test(head)) return "git's config";
555
+ return null;
556
+ }
557
+
558
+ /** Whether a folder is a copy of git's object store — its pack/ or a two-hex folder holding an object — to leave out whole, one line said. */
559
+ export function gitObjects(dir) {
560
+ try {
561
+ for (const name of readdirSync(dir).slice(0, 300)) {
562
+ const p = join(dir, name);
563
+ if (!lstatSync(p).isDirectory() || !(name === 'pack' || /^[0-9a-f]{2}$/.test(name))) continue;
564
+ const first = readdirSync(p).find((f) => lstatSync(join(p, f)).isFile());
565
+ if (first && gitObject(readFileSync(join(p, first)).subarray(0, 4096))) return true;
566
+ }
567
+ } catch { /* unread: its files are judged one by one */ }
568
+ return false;
569
+ }
570
+
571
+ // .avif, .tif and .tiff, and .apng and .jfif (a PNG's and a JPEG's bytes), were no picture's names until round 8.
572
+ const RASTER = /\.(a?png|jpe?g|jfif|gif|webp|avif|ico|bmp|tiff?)$/i;
300
573
  /**
301
574
  * 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.
575
+ * are zero, as no text's are), AVIF's box, and TIFF's byte order and 42 (43 for BigTIFF). Any of them passes under any
576
+ * picture's name: Chrome reads a picture by its bytes, and a JPEG saved as .png is no secret.
304
577
  */
305
578
  const PICTURE = [
306
579
  (b) => b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
@@ -310,6 +583,7 @@ const PICTURE = [
310
583
  (b) => b.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00])),
311
584
  (b) => b.length >= 10 && b.subarray(0, 2).toString('latin1') === 'BM' && b.readUInt32LE(6) === 0,
312
585
  (b) => b.subarray(4, 8).toString('latin1') === 'ftyp',
586
+ (b) => ['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(b.subarray(0, 4).toString('hex')),
313
587
  ];
314
588
  /**
315
589
  * 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
@@ -336,6 +610,31 @@ const BUILD_CONFIGS = ['wxt.config', 'vite.config'].flatMap((n) => ['ts', 'mts',
336
610
  * the link is gone, and says where they are (25 Sep 2026).
337
611
  */
338
612
  export function publicLinks(root, packed = null) {
613
+ return [...new Set(publicLinkEntries(root, packed).map((e) => e.link))].sort();
614
+ }
615
+
616
+ /**
617
+ * The links of the folders a build copies as they are whose target a store zip must never hold — inside a .git
618
+ * folder, or private by its name — and where the build put each: its path under the folder it is in, which the build
619
+ * copies to the top of its output. `pack` leaves those out of the build, and says why (round 8, 25 Sep 2026): a warning
620
+ * let `public/vendor -> ../.git/objects` ship every commit. A link to anything else is said, as before, not refused:
621
+ * a monorepo's shared icons are links too.
622
+ *
623
+ * `.git` in any case: macOS and Windows find `../.GIT/logs` all the same, and it was packed (round 9, 25 Sep 2026).
624
+ */
625
+ export function privateLinks(root, packed = null) {
626
+ const out = [];
627
+ for (const { link, within, path } of publicLinkEntries(root, packed)) {
628
+ let target;
629
+ try { target = realpathSync(path); } catch { continue; }
630
+ const kind = privateKind(target.split(sep).pop() ?? '');
631
+ const why = target.split(sep).some((part) => part.toLowerCase() === '.git') ? 'a link into .git' : kind ? `a link to ${kind}` : null;
632
+ if (why) out.push({ link, within, why });
633
+ }
634
+ return out;
635
+ }
636
+
637
+ function publicLinkEntries(root, packed = null) {
339
638
  const dirs = new Set(PUBLIC_DIRS);
340
639
  for (const config of BUILD_CONFIGS) {
341
640
  let text;
@@ -345,7 +644,7 @@ export function publicLinks(root, packed = null) {
345
644
  }
346
645
  const links = [];
347
646
  let entries = 0;
348
- const walk = (dir, depth) => {
647
+ const walk = (base, dir, depth) => {
349
648
  let names;
350
649
  try { names = readdirSync(dir).sort(); } catch { return; }
351
650
  for (const name of names) {
@@ -353,8 +652,8 @@ export function publicLinks(root, packed = null) {
353
652
  const path = join(dir, name);
354
653
  let st;
355
654
  try { st = lstatSync(path); } catch { continue; }
356
- if (st.isSymbolicLink()) links.push(path);
357
- else if (st.isDirectory() && depth < 8) walk(path, depth + 1);
655
+ if (st.isSymbolicLink()) links.push({ path, base });
656
+ else if (st.isDirectory() && depth < 8) walk(base, path, depth + 1);
358
657
  }
359
658
  };
360
659
  for (const dir of dirs) {
@@ -363,10 +662,12 @@ export function publicLinks(root, packed = null) {
363
662
  if (!at.startsWith(resolve(root) + sep) || (packed && (at === packed || at.startsWith(packed + sep)))) continue;
364
663
  let st;
365
664
  try { st = lstatSync(at); } catch { continue; }
366
- if (st.isSymbolicLink()) links.push(at);
367
- else if (st.isDirectory()) walk(at, 0);
665
+ // The folder itself a link: the build copies it whole, to the top of its output.
666
+ if (st.isSymbolicLink()) links.push({ path: at, base: at });
667
+ else if (st.isDirectory()) walk(at, at, 0);
368
668
  }
369
- return [...new Set(links.map((p) => relative(root, p).split(sep).join('/')))].sort();
669
+ const posixOf = (p) => p.split(sep).join('/');
670
+ return links.map(({ path, base }) => ({ path, link: posixOf(relative(root, path)), within: path === base ? '' : posixOf(relative(base, path)) }));
370
671
  }
371
672
 
372
673
  /**
@@ -384,6 +685,17 @@ export function signInPage(link, wall) {
384
685
  return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
385
686
  }
386
687
 
688
+ /**
689
+ * How long `login` waits between asking whether its code was approved, and how long it asks at all, in milliseconds:
690
+ * what the Wall said (where it said nothing, RFC 8628's 5 seconds, and ten minutes), within one second and half a
691
+ * minute between asks, and a quarter of an hour in all. It took the Wall's word, so a Wall at another address (--wall,
692
+ * TOOLABY_WALL) kept it waiting as long as it said (round 9, 25 Sep 2026).
693
+ */
694
+ export function devicePolling({ interval, expires_in: expiresIn } = {}) {
695
+ const seconds = (value, otherwise, least, most) => Math.min(Math.max(Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : otherwise, least), most);
696
+ return { every: seconds(interval, 5, 1, 30) * 1000, lasts: seconds(expiresIn, 600, 1, 15 * 60) * 1000 };
697
+ }
698
+
387
699
  /**
388
700
  * How a page is opened on each platform, the address one argument and no shell in between. On Windows,
389
701
  * `cmd /c start "" <url>` read an `&` in the address as a second command; rundll32 hands it to the default browser
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.6.3",
3
+ "version": "1.6.5",
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": {