toolaby 1.6.3 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin.mjs +178 -57
- package/lib/check.js +42 -6
- package/lib/manifest.js +111 -10
- package/lib/project.js +2 -2
- package/lib/safe.js +344 -32
- package/lib/summary.js +73 -0
- package/package.json +1 -1
package/lib/safe.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// What this command will not do, whoever asks: write outside the project it was pointed at, write a file of a new
|
|
2
2
|
// extension it does not know, open a sign-in page that is not the Wall's, put a private file in a store zip, print a
|
|
3
|
-
// line it did not write, ask whatever address a project names, or take a download without end.
|
|
3
|
+
// line it did not write, ask whatever address a project names, or take a download or wait for a sign-in without end.
|
|
4
4
|
//
|
|
5
5
|
// Each of these took someone else's word. A cloned repository's manifest named the file `wire` wrote — a service
|
|
6
6
|
// worker at "../../.zshrc" overwrote the shell's startup file — and a link in the folder took a write wherever it
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// through cmd.exe, where an `&` in it ran a command. Hand-written, unlike manifest.js and project.js: this is the
|
|
10
10
|
// command's own boundary, not a wiring rule the browser shares.
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
|
-
import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { lstatSync, readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
|
13
|
+
import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync, constants as zlibConstants } from 'node:zlib';
|
|
13
14
|
import { homedir } from 'node:os';
|
|
14
15
|
import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
15
16
|
|
|
@@ -118,16 +119,21 @@ export async function readBounded(res, limit) {
|
|
|
118
119
|
* A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
|
|
119
120
|
* refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
|
|
120
121
|
* for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026).
|
|
122
|
+
*
|
|
123
|
+
* And one that gives up at its time: the answer, redirects and body alike — read or not yet — within `ms` of asking, or
|
|
124
|
+
* a TimeoutError, as fetchBounded's. Bounded in size alone, a Wall that answered a byte at a time held `login`, `wire`
|
|
125
|
+
* and the MCP server for ever (round 8, 25 Sep 2026).
|
|
121
126
|
*/
|
|
122
|
-
export async function fetchHere(url, init = {}) {
|
|
127
|
+
export async function fetchHere(url, { ms = 30_000, ...init } = {}) {
|
|
128
|
+
const signal = AbortSignal.timeout(ms);
|
|
123
129
|
const origin = new URL(url).origin;
|
|
124
|
-
let res = await fetch(url, { ...init, redirect: 'manual' });
|
|
130
|
+
let res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
125
131
|
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
126
132
|
const next = new URL(res.headers.get('location'), url);
|
|
127
133
|
await res.body?.cancel().catch(() => {});
|
|
128
134
|
if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
|
|
129
135
|
url = next.href;
|
|
130
|
-
res = await fetch(url, { ...init, redirect: 'manual' });
|
|
136
|
+
res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
131
137
|
}
|
|
132
138
|
return res;
|
|
133
139
|
}
|
|
@@ -151,32 +157,164 @@ export function readText(path, limit = READ_LIMIT) {
|
|
|
151
157
|
* Why a file's contents have no place in a store zip, whatever it is called, or null: a private key, a cloud service
|
|
152
158
|
* account's key, a source map written into the code. Names alone let `key.txt`, a Google Cloud key under the name
|
|
153
159
|
* its console gives it, and a bundle's inline map through (25 Sep 2026). So did a live secret in a file of any name
|
|
154
|
-
* — a Stripe or AWS key, a GitHub, Slack or npm token, a password in an address — until round 7 of the same day
|
|
160
|
+
* — a Stripe or AWS key, a GitHub, Slack or npm token, a password in an address — until round 7 of the same day, and
|
|
161
|
+
* an OpenAI or Anthropic key, a Slack app token, a Stripe test key and an AWS secret access key until round 8.
|
|
162
|
+
*
|
|
163
|
+
* All of the file, in each form a copy of a text takes (readThrough): the first 4 MB alone were read, so a key further
|
|
164
|
+
* into a large bundle was packed; and a copy in UTF-16, or in base64, held its secret between NULs or in other letters
|
|
165
|
+
* (round 9, 25 Sep 2026).
|
|
155
166
|
*/
|
|
156
167
|
export function privateContent(bytes) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
168
|
+
return readThrough(bytes, (text) => {
|
|
169
|
+
if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
|
|
170
|
+
if (pemKey(text)) return 'a private key inside';
|
|
171
|
+
if (/[#@]\s*sourceMappingURL\s*=\s*data:/.test(text)) return 'a source map inside';
|
|
172
|
+
for (const [shape, why, honest] of SECRETS) for (const found of text.matchAll(shape)) if (!standIn(found[1] ?? found[0]) && !honest?.(found)) return why;
|
|
173
|
+
if (passwordIn(text)) return 'a password in an address inside';
|
|
174
|
+
return null;
|
|
175
|
+
});
|
|
164
176
|
}
|
|
165
177
|
|
|
166
178
|
/**
|
|
167
179
|
* A secret by its whole shape, and what to call it — never by its prefix alone, which code that checks for one holds
|
|
168
|
-
* (`key.startsWith('sk_live_')`), nor a publishable key, which is made to be in a browser
|
|
180
|
+
* (`key.startsWith('sk_live_')`), nor a publishable key, which is made to be in a browser — and, for some, what says
|
|
181
|
+
* the one found is honest after all. Where a shape captures, the capture is the secret, and the rest what names it.
|
|
169
182
|
*/
|
|
170
183
|
const SECRETS = [
|
|
171
184
|
[/\b[rs]k_live_[0-9A-Za-z]{24,}/g, 'a Stripe live key inside'],
|
|
185
|
+
// A test key reads and writes the account's test data, customers' addresses among them: a secret too (round 8).
|
|
186
|
+
[/\b[rs]k_test_[0-9A-Za-z]{24,}/g, 'a Stripe test key inside'],
|
|
172
187
|
[/\bwhsec_[A-Za-z0-9+/]{24,}/g, 'a webhook signing secret inside'],
|
|
173
|
-
|
|
188
|
+
// A long-term key's id, or a temporary one's (ASIA…), which comes with its secret and a session token (round 9).
|
|
189
|
+
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, 'an AWS access key inside'],
|
|
190
|
+
// Its secret, which a file can hold without the AKIA id beside it: 40 characters of base64 where a name says what
|
|
191
|
+
// they are (aws_secret_access_key, AWS_SECRET_ACCESS_KEY, secretAccessKey…), both cases in them, as no hex digest's.
|
|
192
|
+
[/(?:(?<![A-Za-z0-9])aws[\w.-]{0,30}?|secret_?access_?key)["']?[ \t]{0,16}(?:[:=]|=>)[ \t]{0,16}["']?([A-Za-z0-9/+]{40})(?![A-Za-z0-9/+=])/gi, 'an AWS secret access key inside', ([, key]) => !/[A-Z]/.test(key) || !/[a-z]/.test(key)],
|
|
174
193
|
[/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82,})/g, 'a GitHub token inside'],
|
|
175
194
|
[/\bxox[abprs]-\d+-[A-Za-z0-9-]{20,}/g, 'a Slack token inside'],
|
|
195
|
+
[/\bxapp-\d-[A-Z0-9]{8,}-\d{8,}-[A-Za-z0-9]{32,}/g, 'a Slack app token inside'],
|
|
176
196
|
[/\bnpm_[A-Za-z0-9]{36,}/g, 'an npm token inside'],
|
|
197
|
+
// Google's OAuth client secret, which signs in as the app: both cases after GOCSPX-, as no placeholder has (round 9).
|
|
198
|
+
[/\bGOCSPX-[A-Za-z0-9_-]{20,}/g, 'a Google OAuth client secret inside', ([secret]) => !/[A-Z]/.test(secret.slice(7)) || !/[a-z]/.test(secret.slice(7))],
|
|
199
|
+
// OpenAI's: a project's, a service account's or an admin's, or the first kind, 48 characters after sk-.
|
|
200
|
+
[/\bsk-(?:(?:proj|svcacct|admin)-[A-Za-z0-9_-]{80,}|[A-Za-z0-9]{48}(?![A-Za-z0-9_-]))/g, 'an OpenAI key inside'],
|
|
201
|
+
[/\bsk-ant-[a-z]{2,8}\d{2}-[A-Za-z0-9_-]{80,}/g, 'an Anthropic key inside'],
|
|
202
|
+
// A PuTTY key file by its first two lines, the key's kind and then its encryption: code that reads one holds neither (round 9).
|
|
203
|
+
[/PuTTY-User-Key-File-\d+: [\w.@-]+(?:\r?\n|(?:\\r)?\\n)Encryption: [\w-]+/g, 'a private key inside'],
|
|
204
|
+
// Supabase's service_role key: a JWT whose payload names the role, which passes every row-level rule. The anon key and
|
|
205
|
+
// a signed-in user's token have the same shape, and are made to be in a browser, as a publishable key is (round 9).
|
|
206
|
+
[/\bey[A-Za-z0-9_-]{10,500}\.ey[A-Za-z0-9_-]{10,4000}\.[A-Za-z0-9_-]{20,1000}/g, 'a Supabase service_role key inside', ([jwt]) => !adminRole(jwt)],
|
|
177
207
|
];
|
|
178
208
|
/** What stands where a secret goes, in docs and examples: a run of one character, or AWS's own EXAMPLE key. */
|
|
179
209
|
const standIn = (found) => /(.)\1{7}/.test(found) || found.includes('EXAMPLE');
|
|
210
|
+
/** Whether a JWT's payload names Supabase's service_role, or its supabase_admin. */
|
|
211
|
+
function adminRole(jwt) {
|
|
212
|
+
try { return ['service_role', 'supabase_admin'].includes(JSON.parse(Buffer.from(jwt.split('.')[1], 'base64url').toString('utf8')).role); } catch { return false; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A Google API key: `AIza` and 35 more. */
|
|
216
|
+
const GOOGLE_KEY = /\bAIza[0-9A-Za-z_-]{35}(?![0-9A-Za-z_-])/g;
|
|
217
|
+
/**
|
|
218
|
+
* Whether a file holds a Google API key, a stand-in aside. Such a key is made to be in a browser — a Firebase web
|
|
219
|
+
* config's, the Maps or YouTube key an extension calls with — and is kept to its use by its restrictions in Google
|
|
220
|
+
* Cloud, not by being unseen. `pack` packs the file and names it (round 9, 25 Sep 2026): it left it out until then,
|
|
221
|
+
* breaking the zip for a lone Firebase key or a YouTube key, and let any key through beside a Firebase word.
|
|
222
|
+
*/
|
|
223
|
+
export function googleKey(bytes) {
|
|
224
|
+
return readThrough(bytes, (text) => ([...text.matchAll(GOOGLE_KEY)].some(([key]) => !standIn(key)) ? 'a Google API key' : null)) !== null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** How much of a file is read as text at once, and how far each piece reaches back into the one before: further than any shape read here, a private key's 32 KB among them. */
|
|
228
|
+
const PIECE = 4 * 1024 * 1024;
|
|
229
|
+
const OVERLAP = 128 * 1024;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The first answer `look` gives for a file's text, with the form the file held it in, or null. Each of the file's
|
|
233
|
+
* readings is read in pieces of PIECE bytes, each reaching OVERLAP back into the one before: what a boundary splits is
|
|
234
|
+
* whole in one of them, and no string is larger than a piece, however large the file.
|
|
235
|
+
*/
|
|
236
|
+
function readThrough(bytes, look) {
|
|
237
|
+
for (const [view, how] of readings(bytes)) {
|
|
238
|
+
for (let from = 0; from < view.length; from += PIECE - OVERLAP) {
|
|
239
|
+
const found = look(view.subarray(from, from + PIECE).toString('latin1'));
|
|
240
|
+
if (found) return `${found}${how}`;
|
|
241
|
+
if (from + PIECE >= view.length) break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A file's bytes in each form a copy of a text takes, as [bytes, how it held them]: as they are; decoded from UTF-16,
|
|
249
|
+
* as Windows PowerShell 5.1 writes `Get-Content .env > dist\data.txt`; decoded from base64, where the file is that and
|
|
250
|
+
* nothing else; and one inside the other, three deep, since PowerShell's `>` writes base64 in UTF-16 too. Only a file
|
|
251
|
+
* of `limit` bytes at most is decoded (round 9, 25 Sep 2026).
|
|
252
|
+
*/
|
|
253
|
+
function readings(bytes, limit = Infinity, how = '', depth = 3) {
|
|
254
|
+
const all = [[bytes, how && `, in ${how}`]];
|
|
255
|
+
if (!depth || bytes.length > limit) return all;
|
|
256
|
+
const order = utf16(bytes);
|
|
257
|
+
if (order) all.push(...readings(fromUtf16(bytes, order), limit, how ? `${how} and UTF-16` : 'UTF-16', depth - 1));
|
|
258
|
+
const held = fromBase64(bytes);
|
|
259
|
+
if (held) all.push(...readings(held, limit, how ? `${how} and base64` : 'base64', depth - 1));
|
|
260
|
+
return all;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Which way round a text in UTF-16 is, 'le' or 'be', or null: by its byte-order mark (FF FE, FE FF), or, with none, by
|
|
265
|
+
* the NULs of its Latin letters, in the same half of three pairs in four over its first 512.
|
|
266
|
+
*/
|
|
267
|
+
function utf16(bytes) {
|
|
268
|
+
if (bytes[0] === 0xff && bytes[1] === 0xfe) return 'le';
|
|
269
|
+
if (bytes[0] === 0xfe && bytes[1] === 0xff) return 'be';
|
|
270
|
+
const pairs = Math.min(bytes.length >> 1, 512);
|
|
271
|
+
if (pairs < 8) return null;
|
|
272
|
+
let low = 0; let high = 0;
|
|
273
|
+
for (let i = 0; i < pairs * 2; i += 2) {
|
|
274
|
+
if (bytes[i] && !bytes[i + 1]) low++;
|
|
275
|
+
else if (!bytes[i] && bytes[i + 1]) high++;
|
|
276
|
+
}
|
|
277
|
+
return low >= pairs * 0.75 ? 'le' : high >= pairs * 0.75 ? 'be' : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A text in UTF-16 as UTF-8, a piece at a time. */
|
|
281
|
+
function fromUtf16(bytes, order) {
|
|
282
|
+
const even = bytes.length - (bytes.length % 2);
|
|
283
|
+
const parts = [];
|
|
284
|
+
for (let from = 0; from < even; from += PIECE) {
|
|
285
|
+
const piece = bytes.subarray(from, Math.min(from + PIECE, even));
|
|
286
|
+
// Big-endian turned round in a copy: swap16 turns the bytes it is given round where they are.
|
|
287
|
+
parts.push(Buffer.from((order === 'be' ? Buffer.from(piece).swap16() : piece).toString('utf16le'), 'utf8'));
|
|
288
|
+
}
|
|
289
|
+
return Buffer.concat(parts);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const BASE64 = new Uint8Array(256);
|
|
293
|
+
for (const c of 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/-_') BASE64[c.charCodeAt(0)] = 1;
|
|
294
|
+
/**
|
|
295
|
+
* What a file that is base64 and nothing else holds, or null: past blank space and a byte-order mark, 16 characters at
|
|
296
|
+
* least, of either alphabet, and `=` only at the end. Decoded a piece at a time: PIECE is a whole number of quartets.
|
|
297
|
+
*/
|
|
298
|
+
function fromBase64(bytes) {
|
|
299
|
+
let count = 0; let end = 0;
|
|
300
|
+
for (let i = bom(bytes); i < bytes.length; i++) {
|
|
301
|
+
const b = bytes[i];
|
|
302
|
+
if (blank(b)) continue;
|
|
303
|
+
if (b === 0x3d) { if (++end > 2) return null; } else if (end || !BASE64[b]) return null;
|
|
304
|
+
count++;
|
|
305
|
+
}
|
|
306
|
+
if (count < 16 || (count - end) % 4 === 1) return null;
|
|
307
|
+
const parts = [];
|
|
308
|
+
const chars = Buffer.allocUnsafe(Math.min(count, PIECE));
|
|
309
|
+
let n = 0;
|
|
310
|
+
for (let i = bom(bytes); i < bytes.length; i++) {
|
|
311
|
+
if (blank(bytes[i])) continue;
|
|
312
|
+
chars[n++] = bytes[i];
|
|
313
|
+
if (n === chars.length) { parts.push(Buffer.from(chars.toString('latin1'), 'base64')); n = 0; }
|
|
314
|
+
}
|
|
315
|
+
if (n) parts.push(Buffer.from(chars.toString('latin1', 0, n), 'base64'));
|
|
316
|
+
return Buffer.concat(parts);
|
|
317
|
+
}
|
|
180
318
|
|
|
181
319
|
/** An address with a password in it, `scheme://user:password@host`: the user and password as an address spells them. */
|
|
182
320
|
const ADDRESS = /\b[a-z][a-z0-9+.-]{1,15}:\/\/([a-z0-9._~%!+,;=-]{1,64}):([a-z0-9._~%!+,;=-]{1,128})@([a-z0-9.-]+|\[[0-9a-f:.]+\])/gi;
|
|
@@ -231,6 +369,64 @@ const LOOK = { depth: 4, entries: 20_000 };
|
|
|
231
369
|
/** Folders the look for private files passes by: installed packages, caches, and git's own, of which only config is read. */
|
|
232
370
|
const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.next', '.turbo', '.cache']);
|
|
233
371
|
|
|
372
|
+
/** Space, tab, and the line ends and feeds: what a text form changes, and what its start passes over. */
|
|
373
|
+
const blank = (b) => b === 0x20 || (b >= 0x09 && b <= 0x0d);
|
|
374
|
+
const bom = (bytes) => (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0);
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* A text as it reads, whatever an editor, a platform or a build did around its words: no byte-order mark, LF line
|
|
378
|
+
* ends, no space or tab at the end of a line, nothing blank at the end — or null for what is no text (a NUL in its
|
|
379
|
+
* first 8,000 bytes, as git judges one) or nothing but blank. One pass, and no pattern that could backtrack.
|
|
380
|
+
*/
|
|
381
|
+
function textForm(bytes) {
|
|
382
|
+
if (bytes.subarray(0, 8000).includes(0)) return null;
|
|
383
|
+
const out = Buffer.allocUnsafe(bytes.length);
|
|
384
|
+
let n = 0;
|
|
385
|
+
for (let i = bom(bytes); i < bytes.length; i++) {
|
|
386
|
+
const b = bytes[i];
|
|
387
|
+
if (b !== 0x0a && b !== 0x0d) { out[n++] = b; continue; }
|
|
388
|
+
if (b === 0x0d && bytes[i + 1] === 0x0a) i++;
|
|
389
|
+
while (n && (out[n - 1] === 0x20 || out[n - 1] === 0x09)) n--;
|
|
390
|
+
out[n++] = 0x0a;
|
|
391
|
+
}
|
|
392
|
+
while (n && blank(out[n - 1])) n--;
|
|
393
|
+
return n ? out.subarray(0, n) : null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The first 64 bytes of a text that are not blank, after a byte-order mark: every text form of a text starts with the
|
|
398
|
+
* same, so a file that starts otherwise is no copy — told without reading on. Null for a NUL among them, or none.
|
|
399
|
+
*/
|
|
400
|
+
function textStart(bytes) {
|
|
401
|
+
let start = '';
|
|
402
|
+
for (let i = bom(bytes); i < bytes.length && start.length < 64; i++) {
|
|
403
|
+
if (bytes[i] === 0) return null;
|
|
404
|
+
if (!blank(bytes[i])) start += String.fromCharCode(bytes[i]);
|
|
405
|
+
}
|
|
406
|
+
return start || null;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The names a build gives a compressed copy: vite-plugin-compression's and compression-webpack-plugin's, beside the file. */
|
|
410
|
+
export const COMPRESSED = /\.(gz|br)$/i;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* What a compressed file holds, and the name it had — `{ name, bytes }` — or null: a gzip whatever its name, and by the
|
|
414
|
+
* name a build gives it a .gz of zlib's (a plugin's "deflate") or a .br. Opened to `limit` bytes and no further, as
|
|
415
|
+
* zlib's maxOutputLength stops it: one that holds more, a bomb among them, is null, as are what is not compressed and
|
|
416
|
+
* what does not open. A build's compression plugin writes data.json.gz and .br beside data.json, and `pack` judged
|
|
417
|
+
* them by their compressed bytes, in which nothing private can be read (round 8, 25 Sep 2026).
|
|
418
|
+
*
|
|
419
|
+
* A .gz that is not zlib's is opened as raw deflate, which has no header to know it by: vite-plugin-compression's
|
|
420
|
+
* deflateRaw writes one under that name, and it was packed (round 9, 25 Sep 2026).
|
|
421
|
+
*/
|
|
422
|
+
export function unpacked(name, bytes, limit = COPY_LIMIT) {
|
|
423
|
+
const ways = bytes[0] === 0x1f && bytes[1] === 0x8b ? [gunzipSync] : /\.br$/i.test(name) ? [brotliDecompressSync] : /\.gz$/i.test(name) ? [inflateSync, inflateRawSync] : [];
|
|
424
|
+
for (const open of ways) {
|
|
425
|
+
try { return { name: name.replace(COMPRESSED, ''), bytes: open(bytes, { maxOutputLength: limit }) }; } catch { /* the next way, if there is one */ }
|
|
426
|
+
}
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
|
|
234
430
|
/**
|
|
235
431
|
* What says a file of a build is a copy of a private one: `why(bytes)`, which answers "a copy of .env, an environment
|
|
236
432
|
* file" or null. Its bytes are compared (SHA-256) with every file under `root` that is private by its name (privateKind:
|
|
@@ -238,18 +434,36 @@ const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.nex
|
|
|
238
434
|
* few MB at most and not empty. A build resolves a link before `pack` sees it: WXT and Vite copy what a link in public/
|
|
239
435
|
* points at as an ordinary file under the link's name, so a cloned template's public/icon-128.png -> ../.env reached
|
|
240
436
|
* the zip as a PNG, the developer's .env inside (25 Sep 2026).
|
|
437
|
+
*
|
|
438
|
+
* And so is their text: a copy with CRLF line ends, a byte-order mark, or a newline more or less at its end had other
|
|
439
|
+
* bytes, and was packed (round 8, 25 Sep 2026). Each text form is compared too — only a file whose text starts as a
|
|
440
|
+
* private one's is read through, so a build's bundles cost a look at their first 64 characters.
|
|
441
|
+
*
|
|
442
|
+
* And so is what they hold in each form a copy of a text takes (readings): a copy in UTF-16, which Windows PowerShell
|
|
443
|
+
* 5.1 writes for `Get-Content .env > dist\data.txt`, or in base64, had other bytes and no text form, and was packed
|
|
444
|
+
* (round 9, 25 Sep 2026). A private file in UTF-16 is known by its text in UTF-8 as well.
|
|
241
445
|
*/
|
|
242
446
|
export function privateCopies(root, more = []) {
|
|
243
447
|
const known = new Map(); // size → hash → what the file is
|
|
448
|
+
const texts = new Map(); // how its text starts → hash of its text form → what the file is
|
|
244
449
|
const note = (path, what) => {
|
|
245
450
|
let bytes;
|
|
246
451
|
try { const st = statSync(path); if (!st.isFile() || st.size > COPY_LIMIT) return; bytes = readFileSync(path); } catch { return; }
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
452
|
+
for (const [view] of readings(bytes)) {
|
|
453
|
+
// Nothing in it is nothing to keep, and a copy of nothing is every empty file.
|
|
454
|
+
if (!view.toString('latin1').trim()) continue;
|
|
455
|
+
const same = known.get(view.length) ?? new Map();
|
|
456
|
+
known.set(view.length, same);
|
|
457
|
+
const hash = sha256(view);
|
|
458
|
+
if (!same.has(hash)) same.set(hash, what);
|
|
459
|
+
const form = textForm(view);
|
|
460
|
+
const start = form && textStart(view);
|
|
461
|
+
if (!start) continue;
|
|
462
|
+
const alike = texts.get(start) ?? new Map();
|
|
463
|
+
texts.set(start, alike);
|
|
464
|
+
const formHash = sha256(form);
|
|
465
|
+
if (!alike.has(formHash)) alike.set(formHash, what);
|
|
466
|
+
}
|
|
253
467
|
};
|
|
254
468
|
let entries = 0;
|
|
255
469
|
// Level by level, so the project's own top — where .env and .npmrc are kept — is always read, however large the rest.
|
|
@@ -278,8 +492,17 @@ export function privateCopies(root, more = []) {
|
|
|
278
492
|
}
|
|
279
493
|
for (const [path, what] of more) note(path, what);
|
|
280
494
|
return (bytes) => {
|
|
281
|
-
|
|
282
|
-
|
|
495
|
+
// Decoded up to three times the most a private file is: its copy in UTF-16, or in base64 written in UTF-16, is no larger.
|
|
496
|
+
for (const [view, how] of readings(bytes, 3 * COPY_LIMIT)) {
|
|
497
|
+
let what = known.get(view.length)?.get(sha256(view));
|
|
498
|
+
if (!what && view.length <= COPY_LIMIT) {
|
|
499
|
+
const alike = texts.get(textStart(view));
|
|
500
|
+
const form = alike && textForm(view);
|
|
501
|
+
if (form) what = alike.get(sha256(form));
|
|
502
|
+
}
|
|
503
|
+
if (what) return `a copy of ${what}${how}`;
|
|
504
|
+
}
|
|
505
|
+
return null;
|
|
283
506
|
};
|
|
284
507
|
}
|
|
285
508
|
|
|
@@ -296,11 +519,61 @@ export function gitDirectory(dir) {
|
|
|
296
519
|
} catch { return false; }
|
|
297
520
|
}
|
|
298
521
|
|
|
299
|
-
|
|
522
|
+
/**
|
|
523
|
+
* Git's own history, whatever its name: a pack or its index (`PACK`, `\xfftOc`), or a loose object — zlib that opens
|
|
524
|
+
* on `blob 57\0`, `tree`, `commit`, `tag`. A build that follows a link to .git/objects copies a folder of these, every
|
|
525
|
+
* commit in it, files removed from the tree among them; round seven looked for a repository's HEAD and config, which
|
|
526
|
+
* an object store has not (round 8, 25 Sep 2026).
|
|
527
|
+
*/
|
|
528
|
+
export function gitObject(bytes) {
|
|
529
|
+
if (bytes.length >= 8 && (bytes.subarray(0, 4).toString('latin1') === 'PACK' || bytes.subarray(0, 4).equals(Buffer.from([0xff, 0x74, 0x4f, 0x63])))) return 'git history';
|
|
530
|
+
if (bytes.length >= 2 && bytes[0] === 0x78 && (bytes[0] * 256 + bytes[1]) % 31 === 0) {
|
|
531
|
+
try {
|
|
532
|
+
const head = inflateSync(bytes.subarray(0, 1024), { finishFlush: zlibConstants.Z_SYNC_FLUSH }).subarray(0, 32).toString('latin1');
|
|
533
|
+
if (/^(blob|tree|commit|tag) \d+\0/.test(head)) return 'git history';
|
|
534
|
+
} catch { /* not zlib, or not whole: nothing git reads */ }
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** A line of git's list of refs (packed-refs, which starts with its own header, or FETCH_HEAD), and one of its logs. */
|
|
540
|
+
const GIT_REFS = /^(?:# pack-refs with:|[0-9a-f]{40}(?:[0-9a-f]{24})?\t(?:not-for-merge)?\t)/;
|
|
541
|
+
const GIT_LOG = /^[0-9a-f]{40}(?:[0-9a-f]{24})? [0-9a-f]{40}(?:[0-9a-f]{24})? [^\n<]*<[^\n>]*> \d+ [+-]\d{4}(?:\t|\r?\n|$)/;
|
|
542
|
+
/**
|
|
543
|
+
* Git's other files, whatever their names, by what they hold: its index (`DIRC`), its list of refs, the log of where
|
|
544
|
+
* each branch has been — an author, a time and a commit's subject a line — and a repository's config, which names its
|
|
545
|
+
* remote. A build that copied them from .git under other names shipped them all, the objects aside (round 9, 25 Sep
|
|
546
|
+
* 2026). A commit message being written (COMMIT_EDITMSG) is text like any other, and is not told apart.
|
|
547
|
+
*/
|
|
548
|
+
export function gitFile(bytes) {
|
|
549
|
+
if (bytes.length >= 12 && bytes.toString('latin1', 0, 4) === 'DIRC' && [2, 3, 4].includes(bytes.readUInt32BE(4))) return "git's index";
|
|
550
|
+
const head = bytes.subarray(0, 64 * 1024).toString('latin1');
|
|
551
|
+
if (GIT_REFS.test(head)) return "git's refs";
|
|
552
|
+
if (GIT_LOG.test(head)) return "git's log";
|
|
553
|
+
// A config from its first section on (comments before it aside), with the [core] and the format version git writes.
|
|
554
|
+
if (/^\s*(?:[#;][^\n]*\n\s*)*\[[^\]\n]+\]/.test(head) && /^[ \t]*\[core\][ \t]*\r?$/m.test(head) && /^[ \t]*repositoryformatversion[ \t]*=/m.test(head)) return "git's config";
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** Whether a folder is a copy of git's object store — its pack/ or a two-hex folder holding an object — to leave out whole, one line said. */
|
|
559
|
+
export function gitObjects(dir) {
|
|
560
|
+
try {
|
|
561
|
+
for (const name of readdirSync(dir).slice(0, 300)) {
|
|
562
|
+
const p = join(dir, name);
|
|
563
|
+
if (!lstatSync(p).isDirectory() || !(name === 'pack' || /^[0-9a-f]{2}$/.test(name))) continue;
|
|
564
|
+
const first = readdirSync(p).find((f) => lstatSync(join(p, f)).isFile());
|
|
565
|
+
if (first && gitObject(readFileSync(join(p, first)).subarray(0, 4096))) return true;
|
|
566
|
+
}
|
|
567
|
+
} catch { /* unread: its files are judged one by one */ }
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// .avif, .tif and .tiff, and .apng and .jfif (a PNG's and a JPEG's bytes), were no picture's names until round 8.
|
|
572
|
+
const RASTER = /\.(a?png|jpe?g|jfif|gif|webp|avif|ico|bmp|tiff?)$/i;
|
|
300
573
|
/**
|
|
301
574
|
* The first bytes of each kind of picture an extension shows: PNG, JPEG, GIF, WebP, ICO, BMP (whose reserved bytes
|
|
302
|
-
* are zero, as no text's are), and
|
|
303
|
-
* its bytes, and a JPEG saved as .png is no secret.
|
|
575
|
+
* are zero, as no text's are), AVIF's box, and TIFF's byte order and 42 (43 for BigTIFF). Any of them passes under any
|
|
576
|
+
* picture's name: Chrome reads a picture by its bytes, and a JPEG saved as .png is no secret.
|
|
304
577
|
*/
|
|
305
578
|
const PICTURE = [
|
|
306
579
|
(b) => b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
|
|
@@ -310,6 +583,7 @@ const PICTURE = [
|
|
|
310
583
|
(b) => b.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00])),
|
|
311
584
|
(b) => b.length >= 10 && b.subarray(0, 2).toString('latin1') === 'BM' && b.readUInt32LE(6) === 0,
|
|
312
585
|
(b) => b.subarray(4, 8).toString('latin1') === 'ftyp',
|
|
586
|
+
(b) => ['49492a00', '4d4d002a', '49492b00', '4d4d002b'].includes(b.subarray(0, 4).toString('hex')),
|
|
313
587
|
];
|
|
314
588
|
/**
|
|
315
589
|
* Why a file named as an image is none, or null: its first bytes are no picture's, or — an .svg — it is not SVG's
|
|
@@ -336,6 +610,31 @@ const BUILD_CONFIGS = ['wxt.config', 'vite.config'].flatMap((n) => ['ts', 'mts',
|
|
|
336
610
|
* the link is gone, and says where they are (25 Sep 2026).
|
|
337
611
|
*/
|
|
338
612
|
export function publicLinks(root, packed = null) {
|
|
613
|
+
return [...new Set(publicLinkEntries(root, packed).map((e) => e.link))].sort();
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The links of the folders a build copies as they are whose target a store zip must never hold — inside a .git
|
|
618
|
+
* folder, or private by its name — and where the build put each: its path under the folder it is in, which the build
|
|
619
|
+
* copies to the top of its output. `pack` leaves those out of the build, and says why (round 8, 25 Sep 2026): a warning
|
|
620
|
+
* let `public/vendor -> ../.git/objects` ship every commit. A link to anything else is said, as before, not refused:
|
|
621
|
+
* a monorepo's shared icons are links too.
|
|
622
|
+
*
|
|
623
|
+
* `.git` in any case: macOS and Windows find `../.GIT/logs` all the same, and it was packed (round 9, 25 Sep 2026).
|
|
624
|
+
*/
|
|
625
|
+
export function privateLinks(root, packed = null) {
|
|
626
|
+
const out = [];
|
|
627
|
+
for (const { link, within, path } of publicLinkEntries(root, packed)) {
|
|
628
|
+
let target;
|
|
629
|
+
try { target = realpathSync(path); } catch { continue; }
|
|
630
|
+
const kind = privateKind(target.split(sep).pop() ?? '');
|
|
631
|
+
const why = target.split(sep).some((part) => part.toLowerCase() === '.git') ? 'a link into .git' : kind ? `a link to ${kind}` : null;
|
|
632
|
+
if (why) out.push({ link, within, why });
|
|
633
|
+
}
|
|
634
|
+
return out;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function publicLinkEntries(root, packed = null) {
|
|
339
638
|
const dirs = new Set(PUBLIC_DIRS);
|
|
340
639
|
for (const config of BUILD_CONFIGS) {
|
|
341
640
|
let text;
|
|
@@ -345,7 +644,7 @@ export function publicLinks(root, packed = null) {
|
|
|
345
644
|
}
|
|
346
645
|
const links = [];
|
|
347
646
|
let entries = 0;
|
|
348
|
-
const walk = (dir, depth) => {
|
|
647
|
+
const walk = (base, dir, depth) => {
|
|
349
648
|
let names;
|
|
350
649
|
try { names = readdirSync(dir).sort(); } catch { return; }
|
|
351
650
|
for (const name of names) {
|
|
@@ -353,8 +652,8 @@ export function publicLinks(root, packed = null) {
|
|
|
353
652
|
const path = join(dir, name);
|
|
354
653
|
let st;
|
|
355
654
|
try { st = lstatSync(path); } catch { continue; }
|
|
356
|
-
if (st.isSymbolicLink()) links.push(path);
|
|
357
|
-
else if (st.isDirectory() && depth < 8) walk(path, depth + 1);
|
|
655
|
+
if (st.isSymbolicLink()) links.push({ path, base });
|
|
656
|
+
else if (st.isDirectory() && depth < 8) walk(base, path, depth + 1);
|
|
358
657
|
}
|
|
359
658
|
};
|
|
360
659
|
for (const dir of dirs) {
|
|
@@ -363,10 +662,12 @@ export function publicLinks(root, packed = null) {
|
|
|
363
662
|
if (!at.startsWith(resolve(root) + sep) || (packed && (at === packed || at.startsWith(packed + sep)))) continue;
|
|
364
663
|
let st;
|
|
365
664
|
try { st = lstatSync(at); } catch { continue; }
|
|
366
|
-
|
|
367
|
-
|
|
665
|
+
// The folder itself a link: the build copies it whole, to the top of its output.
|
|
666
|
+
if (st.isSymbolicLink()) links.push({ path: at, base: at });
|
|
667
|
+
else if (st.isDirectory()) walk(at, at, 0);
|
|
368
668
|
}
|
|
369
|
-
|
|
669
|
+
const posixOf = (p) => p.split(sep).join('/');
|
|
670
|
+
return links.map(({ path, base }) => ({ path, link: posixOf(relative(root, path)), within: path === base ? '' : posixOf(relative(base, path)) }));
|
|
370
671
|
}
|
|
371
672
|
|
|
372
673
|
/**
|
|
@@ -384,6 +685,17 @@ export function signInPage(link, wall) {
|
|
|
384
685
|
return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
|
|
385
686
|
}
|
|
386
687
|
|
|
688
|
+
/**
|
|
689
|
+
* How long `login` waits between asking whether its code was approved, and how long it asks at all, in milliseconds:
|
|
690
|
+
* what the Wall said (where it said nothing, RFC 8628's 5 seconds, and ten minutes), within one second and half a
|
|
691
|
+
* minute between asks, and a quarter of an hour in all. It took the Wall's word, so a Wall at another address (--wall,
|
|
692
|
+
* TOOLABY_WALL) kept it waiting as long as it said (round 9, 25 Sep 2026).
|
|
693
|
+
*/
|
|
694
|
+
export function devicePolling({ interval, expires_in: expiresIn } = {}) {
|
|
695
|
+
const seconds = (value, otherwise, least, most) => Math.min(Math.max(Number(value) > 0 && Number.isFinite(Number(value)) ? Number(value) : otherwise, least), most);
|
|
696
|
+
return { every: seconds(interval, 5, 1, 30) * 1000, lasts: seconds(expiresIn, 600, 1, 15 * 60) * 1000 };
|
|
697
|
+
}
|
|
698
|
+
|
|
387
699
|
/**
|
|
388
700
|
* How a page is opened on each platform, the address one argument and no shell in between. On Windows,
|
|
389
701
|
* `cmd /c start "" <url>` read an `&` in the address as a second command; rundll32 hands it to the default browser
|
package/lib/summary.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Generated from wall/src/packages/wiring/summary.ts by wall/scripts/pack-create.mjs — edit there.
|
|
2
|
+
import { withoutComments } from './manifest.js';
|
|
3
|
+
import { importPath } from './project.js';
|
|
4
|
+
export function popupSwapIn(files) {
|
|
5
|
+
for (const f of files) {
|
|
6
|
+
const m = withoutComments(f.text).match(/\b(chrome|browser)\.(action|browserAction)\.(setPopup|getPopup)\s*\(/);
|
|
7
|
+
if (m)
|
|
8
|
+
return { file: f.path, call: `${m[1]}.${m[2]}.${m[3]}()` };
|
|
9
|
+
}
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
/** Whether a path is the extension's own code, for popupSwapIn: a script, not the Wall's, not a dependency's or a build's. */
|
|
13
|
+
export const ownCode = (path) => /\.(m?js|jsx|ts|tsx)$/.test(path) && !/(^|\/)toolaby[^/]*$/.test(path) && !/(^|\/)(node_modules|dist|build|\.output|\.wxt|\.plasmo)\//.test(path);
|
|
14
|
+
export function wiringSummary(o) {
|
|
15
|
+
const { project: p, wiring: w } = o;
|
|
16
|
+
const rows = [];
|
|
17
|
+
// The popup: the Wall's in front of the extension's, or theirs in front and the Wall's beside it, or none at all.
|
|
18
|
+
const ownPopup = w?.ownPopup ?? null;
|
|
19
|
+
if (o.appPopup) {
|
|
20
|
+
rows.push(o.popupSwap
|
|
21
|
+
? { tone: 'warn', label: 'Popup', text: `Your code handles the popup itself (${o.popupSwap.call} in ${o.popupSwap.file}), so keep yours in front`, fix: 'own-popup' }
|
|
22
|
+
: { tone: 'ok', label: 'Popup', text: `The Wall checks first, then opens ${o.appPopup}` });
|
|
23
|
+
}
|
|
24
|
+
else if (ownPopup)
|
|
25
|
+
rows.push({ tone: 'ok', label: 'Popup', text: `Yours stays in front; toolaby.showPaywall() opens the Wall's` });
|
|
26
|
+
else if (p.note)
|
|
27
|
+
rows.push({ tone: 'warn', label: 'Popup', text: p.note });
|
|
28
|
+
else if (!o.appSidePanel && !w?.ownSidePanel)
|
|
29
|
+
rows.push({ tone: 'ok', label: 'Popup', text: 'None: the Wall steps in where your code calls toolaby.gate()' });
|
|
30
|
+
if (o.appSidePanel)
|
|
31
|
+
rows.push({ tone: 'ok', label: 'Side panel', text: `The Wall checks first, then opens ${o.appSidePanel}` });
|
|
32
|
+
else if (w?.ownSidePanel)
|
|
33
|
+
rows.push({ tone: 'ok', label: 'Side panel', text: `Yours stays in front; toolaby.showPaywall() opens the Wall's` });
|
|
34
|
+
// The background: what starts the Wall.
|
|
35
|
+
if (w) {
|
|
36
|
+
const b = w.background;
|
|
37
|
+
if (b.kind === 'new')
|
|
38
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts the Wall` });
|
|
39
|
+
else if (b.kind === 'wrap')
|
|
40
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.wraps} runs as before, and the Wall starts with it` });
|
|
41
|
+
else if (b.kind === 'started')
|
|
42
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts the Wall` });
|
|
43
|
+
else if (b.kind === 'lines')
|
|
44
|
+
rows.push({ tone: 'todo', label: 'Background', text: `Add these 2 lines to the top of ${b.file}:`, code: `import { toolaby } from '${importPath(b.file)}';\n\ntoolaby.startBackground();` });
|
|
45
|
+
else if (b.kind === 'gone')
|
|
46
|
+
rows.push({ tone: 'todo', label: 'Background', text: `${b.wraps} is no longer in the folder. Put your background's file name in manifest.json:`, code: '"background": { "service_worker": "your-background.js" }', again: true });
|
|
47
|
+
else
|
|
48
|
+
rows.push({ tone: 'todo', label: 'Background', text: `The Wall can't load a file named "${b.file}". Rename it to ${b.to}, and in manifest.json:`, code: `"background": { "service_worker": "${b.to}" }`, again: true });
|
|
49
|
+
}
|
|
50
|
+
else if (p.extraFiles?.length)
|
|
51
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.label} starts the Wall (${p.extraFiles.map((f) => f.to).join(', ')})` });
|
|
52
|
+
else if (p.createWorker)
|
|
53
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.createWorker.file} starts the Wall` });
|
|
54
|
+
else if (p.worker && !o.workerStarts)
|
|
55
|
+
rows.push({ tone: 'todo', label: 'Background', text: `Add these 2 lines to the top of ${p.worker}:`, code: p.workerLines.trim() });
|
|
56
|
+
else if (p.worker)
|
|
57
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.worker} starts the Wall` });
|
|
58
|
+
// The manifest: what it gained, or, generated by a framework, what its config needs.
|
|
59
|
+
if (w)
|
|
60
|
+
rows.push({ tone: 'ok', label: 'Manifest', text: w.added.length ? w.added.join(' · ') : 'Has what the Wall needs' });
|
|
61
|
+
for (const sn of p.snippets)
|
|
62
|
+
rows.push({ tone: 'todo', label: 'Manifest', text: `Add to ${sn.file}:`, code: sn.code.trim() });
|
|
63
|
+
if (p.built)
|
|
64
|
+
rows.push({ tone: 'warn', label: 'Build', text: 'Your build must copy every toolaby* file into its output' });
|
|
65
|
+
const steps = rows.filter((r) => r.tone === 'todo').length;
|
|
66
|
+
const todo = steps > 0;
|
|
67
|
+
const doThem = steps > 1 ? 'Do the steps above' : 'Do the step above';
|
|
68
|
+
const reload = p.kind === 'plain' && !p.built ? 'reload it in chrome://extensions' : 'build it (or start the dev server), then reload it in chrome://extensions';
|
|
69
|
+
const next = rows.some((r) => r.again)
|
|
70
|
+
? `${doThem}, then wire again.`
|
|
71
|
+
: `${todo ? `${doThem}, then ${reload}` : reload.charAt(0).toUpperCase() + reload.slice(1)}. The Set up page turns green when it checks in.`;
|
|
72
|
+
return { rows, todo, next };
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one; check it, and give coding agents the Wall as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|