toolaby 1.6.1 → 1.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/bin.mjs +187 -90
- package/lib/check.js +36 -23
- package/lib/mcp.js +44 -12
- package/lib/project.js +5 -3
- package/lib/safe.js +340 -8
- package/package.json +1 -1
package/lib/safe.js
CHANGED
|
@@ -1,5 +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
|
-
// extension it does not know, open a sign-in page that is not the Wall's,
|
|
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
4
|
//
|
|
4
5
|
// Each of these took someone else's word. A cloned repository's manifest named the file `wire` wrote — a service
|
|
5
6
|
// worker at "../../.zshrc" overwrote the shell's startup file — and a link in the folder took a write wherever it
|
|
@@ -7,8 +8,10 @@
|
|
|
7
8
|
// address (--wall, TOOLABY_WALL) could write any file with any content. And on Windows the sign-in address went
|
|
8
9
|
// through cmd.exe, where an `&` in it ran a command. Hand-written, unlike manifest.js and project.js: this is the
|
|
9
10
|
// command's own boundary, not a wiring rule the browser shares.
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { lstatSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* The minimal extension's own files, as the Wall names them (SCAFFOLD_FILES in wall/src/packages/wiring/files.ts;
|
|
@@ -44,6 +47,328 @@ export function inside(root, rel) {
|
|
|
44
47
|
|
|
45
48
|
const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$|\.localhost$/i;
|
|
46
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A value from a Wall's answer or a project's file, as one line. Every line leaves without terminal controls (bin.mjs),
|
|
52
|
+
* but keeps its line breaks, which the command's own messages need — so a tool name with "\n✓ Ready to publish" in it
|
|
53
|
+
* printed a whole line of its own, under the command's real ones (25 Sep 2026). Each such value goes through this
|
|
54
|
+
* where it is put into a line: line breaks and tabs, and any run of space, are one space.
|
|
55
|
+
*/
|
|
56
|
+
export const oneLine = (value) => String(value).replace(/\s+/g, ' ').slice(0, 500);
|
|
57
|
+
|
|
58
|
+
/** An address a Wall answers on: https, or http on this machine, for a Wall on the developer's laptop. */
|
|
59
|
+
const wallScheme = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.test(url.hostname));
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The Wall a tool key names, when `check` may ask it anything: an origin, https or on this machine, and nothing else —
|
|
63
|
+
* no path, no credentials, no other scheme — or null. The key is read from the project's own toolaby.config.js, and a
|
|
64
|
+
* cloned project's key sent `check`'s requests wherever it named: a server inside the developer's network, any path on
|
|
65
|
+
* any host (25 Sep 2026).
|
|
66
|
+
*/
|
|
67
|
+
export function keySite(site) {
|
|
68
|
+
let url;
|
|
69
|
+
try { url = new URL(site); } catch { return null; }
|
|
70
|
+
return url.origin === site && wallScheme(url) ? site : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A GET that cannot run away with the command: the body read up to `limit` bytes and no further, the whole within `ms`,
|
|
75
|
+
* and a redirect followed — five at most — only to an address a Wall answers on. Answers `{ status, ok, body }`, the
|
|
76
|
+
* body null when it passed the limit; throws as fetch does, and with a TimeoutError when the time runs out. A Wall at
|
|
77
|
+
* another address (--wall, TOOLABY_WALL) could send `create` an icon without end, which the new folder took until the
|
|
78
|
+
* disk was full; and an https address that redirected took `check` anywhere (25 Sep 2026).
|
|
79
|
+
*/
|
|
80
|
+
export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {}) {
|
|
81
|
+
const signal = AbortSignal.timeout(ms);
|
|
82
|
+
const origin = new URL(url).origin;
|
|
83
|
+
let res = await fetch(url, { redirect: 'manual', signal });
|
|
84
|
+
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
85
|
+
const next = new URL(res.headers.get('location'), url);
|
|
86
|
+
// Within the address asked, and no further: an https Wall that redirected sent `check` to a service on this
|
|
87
|
+
// machine, whose answer it printed as the tool's (25 Sep 2026).
|
|
88
|
+
if (next.origin !== origin || !wallScheme(next)) break;
|
|
89
|
+
await res.body?.cancel().catch(() => {});
|
|
90
|
+
url = next.href;
|
|
91
|
+
res = await fetch(url, { redirect: 'manual', signal });
|
|
92
|
+
}
|
|
93
|
+
return { status: res.status, ok: res.ok, body: await readBounded(res, limit) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A response's body, up to `limit` bytes and no further: null past it, and the rest let go of unread. What a server
|
|
98
|
+
* sends is read as it comes, so one that answered without end filled the command's memory — `fetchBounded`'s
|
|
99
|
+
* downloads, and every answer of a Wall at another address (--wall, TOOLABY_WALL), which the MCP server also handed
|
|
100
|
+
* on to an agent (25 Sep 2026).
|
|
101
|
+
*/
|
|
102
|
+
export async function readBounded(res, limit) {
|
|
103
|
+
if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel().catch(() => {}); return null; }
|
|
104
|
+
const chunks = [];
|
|
105
|
+
let size = 0;
|
|
106
|
+
const reader = res.body?.getReader();
|
|
107
|
+
for (;;) {
|
|
108
|
+
const { done, value } = reader ? await reader.read() : { done: true };
|
|
109
|
+
if (done) break;
|
|
110
|
+
size += value.byteLength;
|
|
111
|
+
if (size > limit) { await reader.cancel().catch(() => {}); return null; }
|
|
112
|
+
chunks.push(value);
|
|
113
|
+
}
|
|
114
|
+
return Buffer.concat(chunks);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
|
|
119
|
+
* refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
|
|
120
|
+
* for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026).
|
|
121
|
+
*/
|
|
122
|
+
export async function fetchHere(url, init = {}) {
|
|
123
|
+
const origin = new URL(url).origin;
|
|
124
|
+
let res = await fetch(url, { ...init, redirect: 'manual' });
|
|
125
|
+
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
126
|
+
const next = new URL(res.headers.get('location'), url);
|
|
127
|
+
await res.body?.cancel().catch(() => {});
|
|
128
|
+
if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
|
|
129
|
+
url = next.href;
|
|
130
|
+
res = await fetch(url, { ...init, redirect: 'manual' });
|
|
131
|
+
}
|
|
132
|
+
return res;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The most of one project file the command reads as text: a manifest, a config, a source file — none comes near it. */
|
|
136
|
+
export const READ_LIMIT = 5 * 1024 * 1024;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A project file's text, where it is a file and no larger than READ_LIMIT; otherwise it throws, as a missing one does.
|
|
140
|
+
* A cloned repository can hold a link to /dev/zero, or a pipe, under any name the command reads: it waited, or filled
|
|
141
|
+
* its memory, for ever (25 Sep 2026).
|
|
142
|
+
*/
|
|
143
|
+
export function readText(path, limit = READ_LIMIT) {
|
|
144
|
+
const st = statSync(path);
|
|
145
|
+
if (!st.isFile()) throw new Error(`${path} is not a file`);
|
|
146
|
+
if (st.size > limit) throw new Error(`${path} is larger than any file this reads`);
|
|
147
|
+
return readFileSync(path, 'utf8');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Why a file's contents have no place in a store zip, whatever it is called, or null: a private key, a cloud service
|
|
152
|
+
* account's key, a source map written into the code. Names alone let `key.txt`, a Google Cloud key under the name
|
|
153
|
+
* its console gives it, and a bundle's inline map through (25 Sep 2026). So did a live secret in a file of any name
|
|
154
|
+
* — a Stripe or AWS key, a GitHub, Slack or npm token, a password in an address — until round 7 of the same day.
|
|
155
|
+
*/
|
|
156
|
+
export function privateContent(bytes) {
|
|
157
|
+
const text = bytes.subarray(0, 4 * 1024 * 1024).toString('latin1');
|
|
158
|
+
if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
|
|
159
|
+
if (pemKey(text)) return 'a private key inside';
|
|
160
|
+
if (/[#@]\s*sourceMappingURL\s*=\s*data:/.test(text)) return 'a source map inside';
|
|
161
|
+
for (const [shape, why] of SECRETS) for (const [found] of text.matchAll(shape)) if (!standIn(found)) return why;
|
|
162
|
+
if (passwordIn(text)) return 'a password in an address inside';
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A secret by its whole shape, and what to call it — never by its prefix alone, which code that checks for one holds
|
|
168
|
+
* (`key.startsWith('sk_live_')`), nor a publishable key, which is made to be in a browser.
|
|
169
|
+
*/
|
|
170
|
+
const SECRETS = [
|
|
171
|
+
[/\b[rs]k_live_[0-9A-Za-z]{24,}/g, 'a Stripe live key inside'],
|
|
172
|
+
[/\bwhsec_[A-Za-z0-9+/]{24,}/g, 'a webhook signing secret inside'],
|
|
173
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, 'an AWS access key inside'],
|
|
174
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82,})/g, 'a GitHub token inside'],
|
|
175
|
+
[/\bxox[abprs]-\d+-[A-Za-z0-9-]{20,}/g, 'a Slack token inside'],
|
|
176
|
+
[/\bnpm_[A-Za-z0-9]{36,}/g, 'an npm token inside'],
|
|
177
|
+
];
|
|
178
|
+
/** What stands where a secret goes, in docs and examples: a run of one character, or AWS's own EXAMPLE key. */
|
|
179
|
+
const standIn = (found) => /(.)\1{7}/.test(found) || found.includes('EXAMPLE');
|
|
180
|
+
|
|
181
|
+
/** An address with a password in it, `scheme://user:password@host`: the user and password as an address spells them. */
|
|
182
|
+
const ADDRESS = /\b[a-z][a-z0-9+.-]{1,15}:\/\/([a-z0-9._~%!+,;=-]{1,64}):([a-z0-9._~%!+,;=-]{1,128})@([a-z0-9.-]+|\[[0-9a-f:.]+\])/gi;
|
|
183
|
+
/** A password that is the example's word for one, as docs and error messages write it. */
|
|
184
|
+
const EXAMPLE_PASSWORD = /^(?:pass(?:word|wd)?|pwd|pw|secret|changeme|x+|\.+|(?:your|my)[-_]?pass(?:word)?)$/i;
|
|
185
|
+
/**
|
|
186
|
+
* Whether the text holds a live password in an address: not an example's (`user:password@`, a user's own name as its
|
|
187
|
+
* password, as local databases are set up), and not one for this machine, which reaches nothing elsewhere.
|
|
188
|
+
*/
|
|
189
|
+
function passwordIn(text) {
|
|
190
|
+
for (const [, user, password, host] of text.matchAll(ADDRESS)) if (password !== user && !EXAMPLE_PASSWORD.test(password) && !LOOPBACK.test(host)) return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const PEM_BEGIN = /-----BEGIN ([A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?)-----/g;
|
|
195
|
+
/**
|
|
196
|
+
* Whether the text holds a PEM private key: its BEGIN line, lines of base64 after any header lines (Proc-Type,
|
|
197
|
+
* DEK-Info, a PGP block's Version), and its own END line — the line breaks real, or written as \n in JSON or a string.
|
|
198
|
+
* The BEGIN line alone is code's: jose's importPKCS8 checks a string starts with it, and a bundle holding jose was
|
|
199
|
+
* refused as a key until round 7 (25 Sep 2026).
|
|
200
|
+
*/
|
|
201
|
+
function pemKey(text) {
|
|
202
|
+
for (const begin of text.matchAll(PEM_BEGIN)) {
|
|
203
|
+
const from = begin.index + begin[0].length;
|
|
204
|
+
const to = text.indexOf(`-----END ${begin[1]}-----`, from);
|
|
205
|
+
if (to < 0 || to - from > 32 * 1024) continue;
|
|
206
|
+
const lines = text.slice(from, to).replace(/\\+[nr]|\r/g, '\n').split('\n').map((l) => l.trim().replace(/\\$/, '')).filter((l) => l && !/^[A-Za-z][A-Za-z-]*: /.test(l));
|
|
207
|
+
if (lines.length && lines.every((l) => /^[A-Za-z0-9+/=]+$/.test(l)) && lines.join('').length >= 40) return true;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether anything is at the path, a link to nowhere included. */
|
|
213
|
+
const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
|
|
214
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The project a built folder belongs to: the nearest folder at or above it with a package.json or a .git — or null,
|
|
218
|
+
* and never the home folder or the top of the disk, which are no project to look through.
|
|
219
|
+
*/
|
|
220
|
+
export function projectRoot(folder) {
|
|
221
|
+
const home = resolve(homedir());
|
|
222
|
+
for (let at = resolve(folder); at !== home && dirname(at) !== at; at = dirname(at)) {
|
|
223
|
+
if (there(join(at, 'package.json')) || there(join(at, '.git'))) return at;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The most of one file `pack` compares by its bytes, and how far into a project it looks for the private ones. */
|
|
229
|
+
const COPY_LIMIT = 4 * 1024 * 1024;
|
|
230
|
+
const LOOK = { depth: 4, entries: 20_000 };
|
|
231
|
+
/** Folders the look for private files passes by: installed packages, caches, and git's own, of which only config is read. */
|
|
232
|
+
const PASSED_BY = new Set(['node_modules', '.git', '.pnpm-store', '.yarn', '.next', '.turbo', '.cache']);
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* What says a file of a build is a copy of a private one: `why(bytes)`, which answers "a copy of .env, an environment
|
|
236
|
+
* file" or null. Its bytes are compared (SHA-256) with every file under `root` that is private by its name (privateKind:
|
|
237
|
+
* .env files, keys, .npmrc, credentials…), with `root`'s .git/config, and with `more` — [path, what it is] — each a
|
|
238
|
+
* few MB at most and not empty. A build resolves a link before `pack` sees it: WXT and Vite copy what a link in public/
|
|
239
|
+
* points at as an ordinary file under the link's name, so a cloned template's public/icon-128.png -> ../.env reached
|
|
240
|
+
* the zip as a PNG, the developer's .env inside (25 Sep 2026).
|
|
241
|
+
*/
|
|
242
|
+
export function privateCopies(root, more = []) {
|
|
243
|
+
const known = new Map(); // size → hash → what the file is
|
|
244
|
+
const note = (path, what) => {
|
|
245
|
+
let bytes;
|
|
246
|
+
try { const st = statSync(path); if (!st.isFile() || st.size > COPY_LIMIT) return; bytes = readFileSync(path); } catch { return; }
|
|
247
|
+
// Nothing in it is nothing to keep, and a copy of nothing is every empty file.
|
|
248
|
+
if (!bytes.toString('latin1').trim()) return;
|
|
249
|
+
const same = known.get(bytes.length) ?? new Map();
|
|
250
|
+
known.set(bytes.length, same);
|
|
251
|
+
const hash = sha256(bytes);
|
|
252
|
+
if (!same.has(hash)) same.set(hash, what);
|
|
253
|
+
};
|
|
254
|
+
let entries = 0;
|
|
255
|
+
// Level by level, so the project's own top — where .env and .npmrc are kept — is always read, however large the rest.
|
|
256
|
+
for (let level = root ? [root] : [], depth = 0; level.length && depth <= LOOK.depth; depth++) {
|
|
257
|
+
const next = [];
|
|
258
|
+
for (const dir of level) {
|
|
259
|
+
let names;
|
|
260
|
+
try { names = readdirSync(dir).sort(); } catch { continue; }
|
|
261
|
+
for (const name of names) {
|
|
262
|
+
if (++entries > LOOK.entries) break;
|
|
263
|
+
const path = join(dir, name);
|
|
264
|
+
const rel = relative(root, path).split(sep).join('/');
|
|
265
|
+
let st;
|
|
266
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
267
|
+
if (st.isDirectory()) {
|
|
268
|
+
if (name === '.git') note(join(path, 'config'), `${rel}/config`);
|
|
269
|
+
else if (!PASSED_BY.has(name)) next.push(path);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
// A private file that is itself a link is read where it points: a build following a link reads it there too.
|
|
273
|
+
const kind = privateKind(name);
|
|
274
|
+
if (kind && kind !== 'a source map') note(path, `${rel}, ${kind}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
level = next;
|
|
278
|
+
}
|
|
279
|
+
for (const [path, what] of more) note(path, what);
|
|
280
|
+
return (bytes) => {
|
|
281
|
+
const what = known.get(bytes.length)?.get(sha256(bytes));
|
|
282
|
+
return what ? `a copy of ${what}` : null;
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Whether a folder is a git repository's own — HEAD, config and an objects/ folder — as a build leaves one it copied
|
|
288
|
+
* whole from a link to .git: the remote, with any token in its address, and every commit (25 Sep 2026). A repository
|
|
289
|
+
* with no commit yet has an empty objects/, which a build that copies files leaves behind; its HEAD says what it is.
|
|
290
|
+
*/
|
|
291
|
+
export function gitDirectory(dir) {
|
|
292
|
+
try {
|
|
293
|
+
if (!lstatSync(join(dir, 'HEAD')).isFile() || !lstatSync(join(dir, 'config')).isFile()) return false;
|
|
294
|
+
if (there(join(dir, 'objects')) && lstatSync(join(dir, 'objects')).isDirectory()) return true;
|
|
295
|
+
return /^(ref: refs\/|[0-9a-f]{40}\s|[0-9a-f]{64}\s)/.test(readText(join(dir, 'HEAD'), 4096));
|
|
296
|
+
} catch { return false; }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const RASTER = /\.(png|jpe?g|gif|webp|ico|bmp)$/i;
|
|
300
|
+
/**
|
|
301
|
+
* The first bytes of each kind of picture an extension shows: PNG, JPEG, GIF, WebP, ICO, BMP (whose reserved bytes
|
|
302
|
+
* are zero, as no text's are), and AVIF's box. Any of them passes under any picture's name: Chrome reads a picture by
|
|
303
|
+
* its bytes, and a JPEG saved as .png is no secret.
|
|
304
|
+
*/
|
|
305
|
+
const PICTURE = [
|
|
306
|
+
(b) => b.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])),
|
|
307
|
+
(b) => b.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])),
|
|
308
|
+
(b) => /^GIF8[79]a/.test(b.subarray(0, 6).toString('latin1')),
|
|
309
|
+
(b) => b.subarray(0, 4).toString('latin1') === 'RIFF' && b.subarray(8, 12).toString('latin1') === 'WEBP',
|
|
310
|
+
(b) => b.subarray(0, 4).equals(Buffer.from([0x00, 0x00, 0x01, 0x00])),
|
|
311
|
+
(b) => b.length >= 10 && b.subarray(0, 2).toString('latin1') === 'BM' && b.readUInt32LE(6) === 0,
|
|
312
|
+
(b) => b.subarray(4, 8).toString('latin1') === 'ftyp',
|
|
313
|
+
];
|
|
314
|
+
/**
|
|
315
|
+
* Why a file named as an image is none, or null: its first bytes are no picture's, or — an .svg — it is not SVG's
|
|
316
|
+
* text. A build that followed a link left a .env or a git config under an icon's name, and nothing else looked
|
|
317
|
+
* inside it (25 Sep 2026). An empty file holds nothing, and passes.
|
|
318
|
+
*/
|
|
319
|
+
export function notAnImage(name, bytes) {
|
|
320
|
+
if (!bytes.length) return null;
|
|
321
|
+
if (/\.svg$/i.test(name)) {
|
|
322
|
+
const text = bytes.subarray(0, 1024 * 1024).toString('utf8').replace(/^\ufeff/, '');
|
|
323
|
+
return /^\s*</.test(text) && /<svg[\s>/:]/i.test(text) ? null : 'not the image its name says';
|
|
324
|
+
}
|
|
325
|
+
return RASTER.test(name) && !PICTURE.some((is) => is(bytes)) ? 'not the image its name says' : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The folders a build copies as they are: public/ (Vite, CRXJS and WXT), WXT's under src/, and one a config names. */
|
|
329
|
+
const PUBLIC_DIRS = ['public', 'src/public'];
|
|
330
|
+
const BUILD_CONFIGS = ['wxt.config', 'vite.config'].flatMap((n) => ['ts', 'mts', 'js', 'mjs'].map((e) => `${n}.${e}`));
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The symbolic links in the folders of `root` that a build copies as they are, relative to it — but for those inside
|
|
334
|
+
* `packed`, whose links `pack` leaves out and names. WXT's copyPublicDirectory and Vite's copyDir copy what a link
|
|
335
|
+
* points at, so a template's public/icon-128.png -> ../.env is a PNG in the build; `pack` sees only the build, where
|
|
336
|
+
* the link is gone, and says where they are (25 Sep 2026).
|
|
337
|
+
*/
|
|
338
|
+
export function publicLinks(root, packed = null) {
|
|
339
|
+
const dirs = new Set(PUBLIC_DIRS);
|
|
340
|
+
for (const config of BUILD_CONFIGS) {
|
|
341
|
+
let text;
|
|
342
|
+
try { text = readText(join(root, config)); } catch { continue; }
|
|
343
|
+
const named = /\bpublicDir\s*:\s*(['"`])([^'"`\n]+)\1/.exec(text)?.[2];
|
|
344
|
+
if (named) dirs.add(posix.normalize(named).replace(/\/+$/, ''));
|
|
345
|
+
}
|
|
346
|
+
const links = [];
|
|
347
|
+
let entries = 0;
|
|
348
|
+
const walk = (dir, depth) => {
|
|
349
|
+
let names;
|
|
350
|
+
try { names = readdirSync(dir).sort(); } catch { return; }
|
|
351
|
+
for (const name of names) {
|
|
352
|
+
if (++entries > 5_000) return;
|
|
353
|
+
const path = join(dir, name);
|
|
354
|
+
let st;
|
|
355
|
+
try { st = lstatSync(path); } catch { continue; }
|
|
356
|
+
if (st.isSymbolicLink()) links.push(path);
|
|
357
|
+
else if (st.isDirectory() && depth < 8) walk(path, depth + 1);
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
for (const dir of dirs) {
|
|
361
|
+
const at = resolve(root, dir);
|
|
362
|
+
// Not one a config names outside the project, which is not this command's to look through, nor one in the folder packed.
|
|
363
|
+
if (!at.startsWith(resolve(root) + sep) || (packed && (at === packed || at.startsWith(packed + sep)))) continue;
|
|
364
|
+
let st;
|
|
365
|
+
try { st = lstatSync(at); } catch { continue; }
|
|
366
|
+
if (st.isSymbolicLink()) links.push(at);
|
|
367
|
+
else if (st.isDirectory()) walk(at, 0);
|
|
368
|
+
}
|
|
369
|
+
return [...new Set(links.map((p) => relative(root, p).split(sep).join('/')))].sort();
|
|
370
|
+
}
|
|
371
|
+
|
|
47
372
|
/**
|
|
48
373
|
* The page `login` opens: the one the Wall gave, when it is the Wall's own over https — or, for a Wall on this
|
|
49
374
|
* machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
|
|
@@ -72,15 +397,22 @@ export function opener(platform, url) {
|
|
|
72
397
|
|
|
73
398
|
/**
|
|
74
399
|
* Why a file has no place in a store zip, or null: a private key or a keystore (Chrome's own "Pack extension" leaves
|
|
75
|
-
* the extension's .pem beside the folder, and it ends up inside), an environment file, a file of
|
|
76
|
-
*
|
|
77
|
-
*
|
|
400
|
+
* the extension's .pem beside the folder, and it ends up inside), a certificate, an environment file, a file of
|
|
401
|
+
* credentials by its name — a cloud service account's key, a package registry's or a machine's login — or a source
|
|
402
|
+
* map, which carries the source. A store zip is public: anyone can download an item and unzip it. `pack` leaves these
|
|
403
|
+
* out and names them; --allow-private packs them. Dotfiles, .env and .npmrc among them, are never packed, and are
|
|
404
|
+
* named here all the same. A certificate, a service account's key under the name its console downloads it with
|
|
405
|
+
* (firebase-adminsdk-…json, …-service-account-…json), an SSH key with a suffix and Windows' _netrc were packed until
|
|
406
|
+
* 25 Sep 2026.
|
|
78
407
|
*/
|
|
79
408
|
export function privateKind(name) {
|
|
80
409
|
const n = name.toLowerCase();
|
|
81
|
-
if (/\.(pem|key|p12|pfx|p8|jks|keystore|ppk)$/.test(n) || /^id_(rsa|dsa|ecdsa|ed25519)
|
|
410
|
+
if (/\.(pem|key|p12|pfx|p8|jks|keystore|bks|ppk)$/.test(n) || /^id_(rsa|dsa|ecdsa|ed25519)(?!.*\.pub$)/.test(n)) return 'a private key';
|
|
411
|
+
if (/\.(crt|cer|der)$/.test(n)) return 'a certificate';
|
|
82
412
|
if (/\.env(\.[^.]+)*$/.test(n)) return 'an environment file';
|
|
83
|
-
if (/^(credentials|secrets
|
|
413
|
+
if (/^(credentials|secrets?)(\.(json|ya?ml|toml))?$/.test(n) || /(service[-_]?account|firebase-adminsdk|^client_secret)[^/]*\.json$/.test(n)) return 'credentials';
|
|
414
|
+
if (/\.tfstate(\.backup)?$/.test(n)) return "a deployment's state";
|
|
415
|
+
if (/^[._]?(npmrc|yarnrc\.yml|netrc|pypirc|git-credentials|htpasswd|pgpass)$/.test(n)) return 'credentials';
|
|
84
416
|
if (n.endsWith('.map')) return 'a source map';
|
|
85
417
|
return null;
|
|
86
418
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one; check it, and give coding agents the Wall as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|