toolaby 1.6.2 → 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.
- package/README.md +4 -2
- package/bin.mjs +167 -29
- package/lib/check.js +10 -10
- package/lib/mcp.js +7 -7
- package/lib/safe.js +547 -15
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
@@ -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, oneLine, fetchBounded, fetchHere, 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.
|
|
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.
|
|
@@ -88,20 +88,34 @@ 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
|
-
|
|
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
|
+
/** 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;
|
|
95
|
+
|
|
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);
|
|
92
100
|
// Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
|
|
93
101
|
const res = await fetchHere(`${wall}${path}`, {
|
|
102
|
+
ms: ANSWER_TIME,
|
|
94
103
|
method,
|
|
95
104
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
96
105
|
body: body ? JSON.stringify(body) : undefined,
|
|
97
|
-
}).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}`); });
|
|
98
107
|
if (res.status === 401 && token && token === envToken()) {
|
|
99
108
|
envRefused = true;
|
|
100
109
|
const stored = storedToken();
|
|
101
|
-
if (stored && stored !== token) return api(path, { method, body, token: stored
|
|
110
|
+
if (stored && stored !== token) { await res.body?.cancel().catch(() => {}); return api(path, { method, body, token: stored }); }
|
|
102
111
|
}
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
// Read up to a few megabytes and no further: a Wall at another address (--wall, TOOLABY_WALL) that answered without
|
|
113
|
+
// end filled the command's memory, and the MCP server's, which hands what it reads to an agent (25 Sep 2026).
|
|
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); });
|
|
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.`);
|
|
117
|
+
let json = {};
|
|
118
|
+
try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
|
|
105
119
|
return { status: res.status, ok: res.ok, json };
|
|
106
120
|
}
|
|
107
121
|
|
|
@@ -116,19 +130,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
116
130
|
async function login() {
|
|
117
131
|
const start = await api('/api/dev-auth/device/code', { method: 'POST', body: { client_id: CLIENT_ID }, token: null });
|
|
118
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.`);
|
|
119
|
-
const { device_code, user_code, verification_uri, verification_uri_complete
|
|
133
|
+
const { device_code, user_code, verification_uri, verification_uri_complete } = start.json;
|
|
120
134
|
const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
|
|
121
135
|
// The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
|
|
122
136
|
// Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
|
|
123
137
|
const page = signInPage(link, wall);
|
|
124
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.`);
|
|
125
|
-
|
|
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`);
|
|
126
142
|
openBrowser(page);
|
|
127
|
-
let every =
|
|
128
|
-
const until = Date.now() +
|
|
143
|
+
let every = first;
|
|
144
|
+
const until = Date.now() + lasts;
|
|
129
145
|
process.stdout.write(' Waiting for your approval');
|
|
130
146
|
while (Date.now() < until) {
|
|
131
|
-
|
|
147
|
+
// Never past the code's end: the last ask comes as it ends.
|
|
148
|
+
await sleep(Math.min(every, until - Date.now()));
|
|
132
149
|
process.stdout.write('.');
|
|
133
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 });
|
|
134
151
|
if (poll.ok && poll.json.access_token) {
|
|
@@ -419,7 +436,7 @@ async function webhooks() {
|
|
|
419
436
|
return;
|
|
420
437
|
}
|
|
421
438
|
if (sub === 'secret') {
|
|
422
|
-
// For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx toolaby webhooks secret) node server.mjs`.
|
|
439
|
+
// For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx -y toolaby@latest webhooks secret) node server.mjs`.
|
|
423
440
|
process.stdout.write(devSecret() + '\n');
|
|
424
441
|
return;
|
|
425
442
|
}
|
|
@@ -448,7 +465,7 @@ async function webhooks() {
|
|
|
448
465
|
} catch (e) { fail(`Could not reach ${to}: ${e.message}`); }
|
|
449
466
|
const text = (await res.text()).slice(0, 200);
|
|
450
467
|
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`);
|
|
468
|
+
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
469
|
if (res.status >= 300) out(' A real delivery would be retried until your server answers 2xx.\n');
|
|
453
470
|
return;
|
|
454
471
|
}
|
|
@@ -488,10 +505,52 @@ async function webhooks() {
|
|
|
488
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_
|
|
489
506
|
// or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
|
|
490
507
|
// can ship with. What looks private — a key, an environment file, a source map — is left out and named, and so is a
|
|
491
|
-
// link out of the folder: a store zip is public.
|
|
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.
|
|
492
511
|
const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
|
|
493
512
|
const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
494
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
|
+
}
|
|
495
554
|
|
|
496
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; });
|
|
497
556
|
const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
|
|
@@ -530,9 +589,22 @@ function zipOf(entries) {
|
|
|
530
589
|
* 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
590
|
* .env or .git, under an innocent name (25 Sep 2026). Nor a name no zip should hold — a backslash, which unzips as a
|
|
532
591
|
* folder, or a control character — nor anything but a file. A zip is public once the store serves it.
|
|
592
|
+
*
|
|
593
|
+
* A build has already followed the links of the project it came from: what they pointed at is an ordinary file here.
|
|
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
|
|
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).
|
|
533
602
|
*/
|
|
534
|
-
function filesIn(root) {
|
|
603
|
+
function filesIn(root, copyOf = () => null, fromLink = () => null) {
|
|
535
604
|
const found = []; const left = []; const privately = [];
|
|
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
|
|
536
608
|
const walk = (dir) => {
|
|
537
609
|
for (const name of readdirSync(dir).sort()) {
|
|
538
610
|
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
@@ -541,10 +613,28 @@ function filesIn(root) {
|
|
|
541
613
|
const st = lstatSync(p);
|
|
542
614
|
if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
|
|
543
615
|
if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
|
|
544
|
-
|
|
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]); }
|
|
619
|
+
if (st.isDirectory()) {
|
|
620
|
+
// Left out whole, unwalked: its config and its history are the repository's, whatever the folder is called.
|
|
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]); }
|
|
623
|
+
walk(p);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
545
626
|
if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
|
|
546
|
-
const
|
|
547
|
-
|
|
627
|
+
const bytes = readFileSync(p);
|
|
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);
|
|
637
|
+
if (why && !allowed) left.push([rel, why]);
|
|
548
638
|
else { found.push(p); if (why) privately.push([rel, why]); }
|
|
549
639
|
}
|
|
550
640
|
};
|
|
@@ -552,6 +642,9 @@ function filesIn(root) {
|
|
|
552
642
|
return { found, left, privately };
|
|
553
643
|
}
|
|
554
644
|
|
|
645
|
+
/** A path as the developer knows it: under their home folder, from ~. */
|
|
646
|
+
const fromHome = (path) => (path.startsWith(homedir() + sep) ? `~${path.slice(homedir().length)}` : path);
|
|
647
|
+
|
|
555
648
|
function pack() {
|
|
556
649
|
const given = positional[0];
|
|
557
650
|
const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
@@ -563,7 +656,41 @@ function pack() {
|
|
|
563
656
|
// 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
657
|
// version with "../" in it put the zip outside the folder.
|
|
565
658
|
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
|
-
|
|
659
|
+
// The project the build came from — or, with none above it, the folder alone — and the command's own secrets: what a
|
|
660
|
+
// build could have copied in under another name, by following a link (lib/safe.js).
|
|
661
|
+
const project = projectRoot(folder);
|
|
662
|
+
const allowed = flag('--allow-private');
|
|
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`]]);
|
|
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
|
+
}
|
|
567
694
|
const problems = [];
|
|
568
695
|
|
|
569
696
|
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
@@ -575,7 +702,7 @@ function pack() {
|
|
|
575
702
|
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
703
|
}
|
|
577
704
|
}
|
|
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.)`);
|
|
705
|
+
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
706
|
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
707
|
|
|
581
708
|
// Addresses of this machine have no place in a store build.
|
|
@@ -585,7 +712,7 @@ function pack() {
|
|
|
585
712
|
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
586
713
|
for (const site of sites) {
|
|
587
714
|
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.`);
|
|
715
|
+
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
716
|
}
|
|
590
717
|
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
591
718
|
|
|
@@ -606,12 +733,17 @@ function pack() {
|
|
|
606
733
|
renameSync(fresh, target);
|
|
607
734
|
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
608
735
|
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
609
|
-
// What was left out, and why — or, asked for, what private file went in: a store zip is public.
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
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.`);
|
|
613
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.`);
|
|
614
|
-
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.`);
|
|
744
|
+
// Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
|
|
745
|
+
const linked = project ? publicLinks(project, folder) : [];
|
|
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.`);
|
|
615
747
|
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
748
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
617
749
|
}
|
|
@@ -641,7 +773,10 @@ function help() {
|
|
|
641
773
|
toolaby tools your tools, with their ids
|
|
642
774
|
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
643
775
|
refuses a Test key or localhost inside --out <file.zip>
|
|
644
|
-
leaves out keys, certificates, .env and credential files, source maps
|
|
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
|
|
645
780
|
toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
646
781
|
--browser loads it in Chromium (needs Playwright in the project)
|
|
647
782
|
toolaby mcp the Wall for coding agents, an MCP server on stdio:
|
|
@@ -654,6 +789,9 @@ function help() {
|
|
|
654
789
|
Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
|
|
655
790
|
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI —
|
|
656
791
|
sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://test.toolaby.app for Test).
|
|
792
|
+
|
|
793
|
+
Not installed: npx -y toolaby@latest <command>. In a repository you did not write, install it (npm i -g toolaby)
|
|
794
|
+
and type toolaby: npx can run a toolaby the project carries, or fetch one from a registry its .npmrc names.
|
|
657
795
|
`);
|
|
658
796
|
}
|
|
659
797
|
|
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
|
@@ -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
|
|
@@ -8,8 +8,11 @@
|
|
|
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 {
|
|
12
|
-
import {
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
13
|
+
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync, constants as zlibConstants } from 'node:zlib';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
18
|
* The minimal extension's own files, as the Wall names them (SCAFFOLD_FILES in wall/src/packages/wiring/files.ts;
|
|
@@ -88,8 +91,17 @@ export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {
|
|
|
88
91
|
url = next.href;
|
|
89
92
|
res = await fetch(url, { redirect: 'manual', signal });
|
|
90
93
|
}
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
return { status: res.status, ok: res.ok, body: await readBounded(res, limit) };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A response's body, up to `limit` bytes and no further: null past it, and the rest let go of unread. What a server
|
|
99
|
+
* sends is read as it comes, so one that answered without end filled the command's memory — `fetchBounded`'s
|
|
100
|
+
* downloads, and every answer of a Wall at another address (--wall, TOOLABY_WALL), which the MCP server also handed
|
|
101
|
+
* on to an agent (25 Sep 2026).
|
|
102
|
+
*/
|
|
103
|
+
export async function readBounded(res, limit) {
|
|
104
|
+
if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel().catch(() => {}); return null; }
|
|
93
105
|
const chunks = [];
|
|
94
106
|
let size = 0;
|
|
95
107
|
const reader = res.body?.getReader();
|
|
@@ -97,26 +109,31 @@ export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {
|
|
|
97
109
|
const { done, value } = reader ? await reader.read() : { done: true };
|
|
98
110
|
if (done) break;
|
|
99
111
|
size += value.byteLength;
|
|
100
|
-
if (size > limit) { await reader.cancel().catch(() => {}); return
|
|
112
|
+
if (size > limit) { await reader.cancel().catch(() => {}); return null; }
|
|
101
113
|
chunks.push(value);
|
|
102
114
|
}
|
|
103
|
-
return
|
|
115
|
+
return Buffer.concat(chunks);
|
|
104
116
|
}
|
|
105
117
|
|
|
106
118
|
/**
|
|
107
119
|
* A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
|
|
108
120
|
* refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
|
|
109
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).
|
|
110
126
|
*/
|
|
111
|
-
export async function fetchHere(url, init = {}) {
|
|
127
|
+
export async function fetchHere(url, { ms = 30_000, ...init } = {}) {
|
|
128
|
+
const signal = AbortSignal.timeout(ms);
|
|
112
129
|
const origin = new URL(url).origin;
|
|
113
|
-
let res = await fetch(url, { ...init, redirect: 'manual' });
|
|
130
|
+
let res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
114
131
|
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
115
132
|
const next = new URL(res.headers.get('location'), url);
|
|
116
133
|
await res.body?.cancel().catch(() => {});
|
|
117
134
|
if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
|
|
118
135
|
url = next.href;
|
|
119
|
-
res = await fetch(url, { ...init, redirect: 'manual' });
|
|
136
|
+
res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
120
137
|
}
|
|
121
138
|
return res;
|
|
122
139
|
}
|
|
@@ -139,16 +156,520 @@ export function readText(path, limit = READ_LIMIT) {
|
|
|
139
156
|
/**
|
|
140
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
|
|
141
158
|
* 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).
|
|
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
|
|
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).
|
|
143
166
|
*/
|
|
144
167
|
export function privateContent(bytes) {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A secret by its whole shape, and what to call it — never by its prefix alone, which code that checks for one holds
|
|
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.
|
|
182
|
+
*/
|
|
183
|
+
const SECRETS = [
|
|
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'],
|
|
187
|
+
[/\bwhsec_[A-Za-z0-9+/]{24,}/g, 'a webhook signing secret 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)],
|
|
193
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82,})/g, 'a GitHub token inside'],
|
|
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'],
|
|
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)],
|
|
207
|
+
];
|
|
208
|
+
/** What stands where a secret goes, in docs and examples: a run of one character, or AWS's own EXAMPLE key. */
|
|
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
|
+
}
|
|
318
|
+
|
|
319
|
+
/** An address with a password in it, `scheme://user:password@host`: the user and password as an address spells them. */
|
|
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;
|
|
321
|
+
/** A password that is the example's word for one, as docs and error messages write it. */
|
|
322
|
+
const EXAMPLE_PASSWORD = /^(?:pass(?:word|wd)?|pwd|pw|secret|changeme|x+|\.+|(?:your|my)[-_]?pass(?:word)?)$/i;
|
|
323
|
+
/**
|
|
324
|
+
* Whether the text holds a live password in an address: not an example's (`user:password@`, a user's own name as its
|
|
325
|
+
* password, as local databases are set up), and not one for this machine, which reaches nothing elsewhere.
|
|
326
|
+
*/
|
|
327
|
+
function passwordIn(text) {
|
|
328
|
+
for (const [, user, password, host] of text.matchAll(ADDRESS)) if (password !== user && !EXAMPLE_PASSWORD.test(password) && !LOOPBACK.test(host)) return true;
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?)-----/g;
|
|
333
|
+
/**
|
|
334
|
+
* Whether the text holds a PEM private key: its BEGIN line, lines of base64 after any header lines (Proc-Type,
|
|
335
|
+
* 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.
|
|
336
|
+
* The BEGIN line alone is code's: jose's importPKCS8 checks a string starts with it, and a bundle holding jose was
|
|
337
|
+
* refused as a key until round 7 (25 Sep 2026).
|
|
338
|
+
*/
|
|
339
|
+
function pemKey(text) {
|
|
340
|
+
for (const begin of text.matchAll(PEM_BEGIN)) {
|
|
341
|
+
const from = begin.index + begin[0].length;
|
|
342
|
+
const to = text.indexOf(`-----END ${begin[1]}-----`, from);
|
|
343
|
+
if (to < 0 || to - from > 32 * 1024) continue;
|
|
344
|
+
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));
|
|
345
|
+
if (lines.length && lines.every((l) => /^[A-Za-z0-9+/=]+$/.test(l)) && lines.join('').length >= 40) return true;
|
|
346
|
+
}
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whether anything is at the path, a link to nowhere included. */
|
|
351
|
+
const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
|
|
352
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The project a built folder belongs to: the nearest folder at or above it with a package.json or a .git — or null,
|
|
356
|
+
* and never the home folder or the top of the disk, which are no project to look through.
|
|
357
|
+
*/
|
|
358
|
+
export function projectRoot(folder) {
|
|
359
|
+
const home = resolve(homedir());
|
|
360
|
+
for (let at = resolve(folder); at !== home && dirname(at) !== at; at = dirname(at)) {
|
|
361
|
+
if (there(join(at, 'package.json')) || there(join(at, '.git'))) return at;
|
|
362
|
+
}
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** The most of one file `pack` compares by its bytes, and how far into a project it looks for the private ones. */
|
|
367
|
+
const COPY_LIMIT = 4 * 1024 * 1024;
|
|
368
|
+
const LOOK = { depth: 4, entries: 20_000 };
|
|
369
|
+
/** Folders the look for private files passes by: installed packages, caches, and git's own, of which only config is read. */
|
|
370
|
+
const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.next', '.turbo', '.cache']);
|
|
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
|
+
|
|
430
|
+
/**
|
|
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
|
|
432
|
+
* file" or null. Its bytes are compared (SHA-256) with every file under `root` that is private by its name (privateKind:
|
|
433
|
+
* .env files, keys, .npmrc, credentials…), with `root`'s .git/config, and with `more` — [path, what it is] — each a
|
|
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/
|
|
435
|
+
* points at as an ordinary file under the link's name, so a cloned template's public/icon-128.png -> ../.env reached
|
|
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.
|
|
445
|
+
*/
|
|
446
|
+
export function privateCopies(root, more = []) {
|
|
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
|
|
449
|
+
const note = (path, what) => {
|
|
450
|
+
let bytes;
|
|
451
|
+
try { const st = statSync(path); if (!st.isFile() || st.size > COPY_LIMIT) return; bytes = readFileSync(path); } catch { return; }
|
|
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
|
+
}
|
|
467
|
+
};
|
|
468
|
+
let entries = 0;
|
|
469
|
+
// Level by level, so the project's own top — where .env and .npmrc are kept — is always read, however large the rest.
|
|
470
|
+
for (let level = root ? [root] : [], depth = 0; level.length && depth <= LOOK.depth; depth++) {
|
|
471
|
+
const next = [];
|
|
472
|
+
for (const dir of level) {
|
|
473
|
+
let names;
|
|
474
|
+
try { names = readdirSync(dir).sort(); } catch { continue; }
|
|
475
|
+
for (const name of names) {
|
|
476
|
+
if (++entries > LOOK.entries) break;
|
|
477
|
+
const path = join(dir, name);
|
|
478
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
479
|
+
let st;
|
|
480
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
481
|
+
if (st.isDirectory()) {
|
|
482
|
+
if (name === '.git') note(join(path, 'config'), `${rel}/config`);
|
|
483
|
+
else if (!PASSED_BY.has(name)) next.push(path);
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
// A private file that is itself a link is read where it points: a build following a link reads it there too.
|
|
487
|
+
const kind = privateKind(name);
|
|
488
|
+
if (kind && kind !== 'a source map') note(path, `${rel}, ${kind}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
level = next;
|
|
492
|
+
}
|
|
493
|
+
for (const [path, what] of more) note(path, what);
|
|
494
|
+
return (bytes) => {
|
|
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;
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Whether a folder is a git repository's own — HEAD, config and an objects/ folder — as a build leaves one it copied
|
|
511
|
+
* whole from a link to .git: the remote, with any token in its address, and every commit (25 Sep 2026). A repository
|
|
512
|
+
* with no commit yet has an empty objects/, which a build that copies files leaves behind; its HEAD says what it is.
|
|
513
|
+
*/
|
|
514
|
+
export function gitDirectory(dir) {
|
|
515
|
+
try {
|
|
516
|
+
if (!lstatSync(join(dir, 'HEAD')).isFile() || !lstatSync(join(dir, 'config')).isFile()) return false;
|
|
517
|
+
if (there(join(dir, 'objects')) && lstatSync(join(dir, 'objects')).isDirectory()) return true;
|
|
518
|
+
return /^(ref: refs\/|[0-9a-f]{40}\s|[0-9a-f]{64}\s)/.test(readText(join(dir, 'HEAD'), 4096));
|
|
519
|
+
} catch { return false; }
|
|
520
|
+
}
|
|
521
|
+
|
|
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";
|
|
149
555
|
return null;
|
|
150
556
|
}
|
|
151
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;
|
|
573
|
+
/**
|
|
574
|
+
* The first bytes of each kind of picture an extension shows: PNG, JPEG, GIF, WebP, ICO, BMP (whose reserved bytes
|
|
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.
|
|
577
|
+
*/
|
|
578
|
+
const PICTURE = [
|
|
579
|
+
(b) => b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
|
|
580
|
+
(b) => b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
|
|
581
|
+
(b) => /^GIF8[79]a/.test(b.subarray(0, 6).toString('latin1')),
|
|
582
|
+
(b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
|
|
583
|
+
(b) => b.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00])),
|
|
584
|
+
(b) => b.length >= 10 && b.subarray(0, 2).toString('latin1') === 'BM' && b.readUInt32LE(6) === 0,
|
|
585
|
+
(b) => b.subarray(4, 8).toString('latin1') === 'ftyp',
|
|
586
|
+
(b) => ['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(b.subarray(0, 4).toString('hex')),
|
|
587
|
+
];
|
|
588
|
+
/**
|
|
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
|
|
590
|
+
* text. A build that followed a link left a .env or a git config under an icon's name, and nothing else looked
|
|
591
|
+
* inside it (25 Sep 2026). An empty file holds nothing, and passes.
|
|
592
|
+
*/
|
|
593
|
+
export function notAnImage(name, bytes) {
|
|
594
|
+
if (!bytes.length) return null;
|
|
595
|
+
if (/\.svg$/i.test(name)) {
|
|
596
|
+
const text = bytes.subarray(0, 1024 * 1024).toString('utf8').replace(/^\ufeff/, '');
|
|
597
|
+
return /^\s*</.test(text) && /<svg[\s>/:]/i.test(text) ? null : 'not the image its name says';
|
|
598
|
+
}
|
|
599
|
+
return RASTER.test(name) && !PICTURE.some((is) => is(bytes)) ? 'not the image its name says' : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The folders a build copies as they are: public/ (Vite, CRXJS and WXT), WXT's under src/, and one a config names. */
|
|
603
|
+
const PUBLIC_DIRS = ['public', 'src/public'];
|
|
604
|
+
const BUILD_CONFIGS = ['wxt.config', 'vite.config'].flatMap((n) => ['ts', 'mts', 'js', 'mjs'].map((e) => `${n}.${e}`));
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* The symbolic links in the folders of `root` that a build copies as they are, relative to it — but for those inside
|
|
608
|
+
* `packed`, whose links `pack` leaves out and names. WXT's copyPublicDirectory and Vite's copyDir copy what a link
|
|
609
|
+
* points at, so a template's public/icon-128.png -> ../.env is a PNG in the build; `pack` sees only the build, where
|
|
610
|
+
* the link is gone, and says where they are (25 Sep 2026).
|
|
611
|
+
*/
|
|
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) {
|
|
638
|
+
const dirs = new Set(PUBLIC_DIRS);
|
|
639
|
+
for (const config of BUILD_CONFIGS) {
|
|
640
|
+
let text;
|
|
641
|
+
try { text = readText(join(root, config)); } catch { continue; }
|
|
642
|
+
const named = /\bpublicDir\s*:\s*(['"`])([^'"`\n]+)\1/.exec(text)?.[2];
|
|
643
|
+
if (named) dirs.add(posix.normalize(named).replace(/\/+$/, ''));
|
|
644
|
+
}
|
|
645
|
+
const links = [];
|
|
646
|
+
let entries = 0;
|
|
647
|
+
const walk = (base, dir, depth) => {
|
|
648
|
+
let names;
|
|
649
|
+
try { names = readdirSync(dir).sort(); } catch { return; }
|
|
650
|
+
for (const name of names) {
|
|
651
|
+
if (++entries > 5_000) return;
|
|
652
|
+
const path = join(dir, name);
|
|
653
|
+
let st;
|
|
654
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
655
|
+
if (st.isSymbolicLink()) links.push({ path, base });
|
|
656
|
+
else if (st.isDirectory() && depth < 8) walk(base, path, depth + 1);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
for (const dir of dirs) {
|
|
660
|
+
const at = resolve(root, dir);
|
|
661
|
+
// Not one a config names outside the project, which is not this command's to look through, nor one in the folder packed.
|
|
662
|
+
if (!at.startsWith(resolve(root) + sep) || (packed && (at === packed || at.startsWith(packed + sep)))) continue;
|
|
663
|
+
let st;
|
|
664
|
+
try { st = lstatSync(at); } catch { continue; }
|
|
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);
|
|
668
|
+
}
|
|
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)) }));
|
|
671
|
+
}
|
|
672
|
+
|
|
152
673
|
/**
|
|
153
674
|
* 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
675
|
* machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
|
|
@@ -164,6 +685,17 @@ export function signInPage(link, wall) {
|
|
|
164
685
|
return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
|
|
165
686
|
}
|
|
166
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
|
+
|
|
167
699
|
/**
|
|
168
700
|
* How a page is opened on each platform, the address one argument and no shell in between. On Windows,
|
|
169
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
|
+
"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": {
|