toolaby 1.6.0 → 1.6.1
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 +91 -29
- package/lib/check.js +2 -2
- package/lib/safe.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Every command acts on the test Wall (`test.toolaby.app`) unless told `--live` (`
|
|
|
12
12
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
15
|
-
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools.
|
|
15
|
+
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools. It opens only the Wall's own page, over https (or on `localhost`, for a Wall on your machine); any other address is refused and nothing is signed in.
|
|
16
16
|
|
|
17
17
|
**`toolaby wire <tool-id>`**, run in your extension's folder, wires that extension to your tool:
|
|
18
18
|
|
|
@@ -24,7 +24,7 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
24
24
|
|
|
25
25
|
**`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
|
|
26
26
|
|
|
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`. Nothing is sent anywhere.
|
|
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, `*.env`, `credentials.json`, source maps) are left out and named; `--allow-private` packs them. Dotfiles and links that lead out of the folder are never packed. Nothing is sent anywhere.
|
|
28
28
|
|
|
29
29
|
**`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
30
|
|
|
@@ -33,4 +33,6 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
33
33
|
- Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
|
|
34
34
|
- The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI). `TOOLABY_WALL` or `--wall <url>` names any other Wall.
|
|
35
35
|
|
|
36
|
+
`wire` and `create` write only inside the folder they work on, and never through a symbolic link. A manifest, a framework's config or a Wall that names a place outside it (`../`, an absolute path, a link) is refused before anything is written. `create` writes only the files it knows a new extension has.
|
|
37
|
+
|
|
36
38
|
The files the command writes into your extension contain nothing secret: your tool's id, your Wall's address and the *public* key your extension verifies licences with — the same things every copy of your extension carries once it is published.
|
package/bin.mjs
CHANGED
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
// The wiring rules — what a folder is and what its manifest needs — are the
|
|
30
30
|
// platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
|
|
31
31
|
// so this and the dashboard's Wire button never disagree.
|
|
32
|
-
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
|
|
33
|
-
import { join, resolve, dirname, relative, basename } from 'node:path';
|
|
32
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, chmodSync, unlinkSync } from 'node:fs';
|
|
33
|
+
import { join, resolve, dirname, relative, basename, sep } from 'node:path';
|
|
34
34
|
import { deflateRawSync } from 'node:zlib';
|
|
35
35
|
import { homedir } from 'node:os';
|
|
36
36
|
import { spawn } from 'node:child_process';
|
|
@@ -39,8 +39,9 @@ import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
|
|
|
39
39
|
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';
|
|
40
40
|
import { runMcp } from './lib/mcp.js';
|
|
41
41
|
import { runCheck } from './lib/check.js';
|
|
42
|
+
import { inside, signInPage, opener, privateKind, SCAFFOLD_FILES } from './lib/safe.js';
|
|
42
43
|
|
|
43
|
-
const VERSION = '1.6.
|
|
44
|
+
const VERSION = '1.6.1';
|
|
44
45
|
const CLIENT_ID = 'toolaby-cli';
|
|
45
46
|
const argv = process.argv.slice(2);
|
|
46
47
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -81,8 +82,9 @@ async function api(path, { method = 'GET', body, token = storedToken(), raw = fa
|
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// ── login: RFC 8628, the way GitHub's and Stripe's command lines sign in ─────────────────────────────────────────────
|
|
85
|
+
/** Opens the page, never through a shell (lib/safe.js); only a page signInPage() let through gets here. */
|
|
84
86
|
function openBrowser(url) {
|
|
85
|
-
const [cmd, args] = process.platform
|
|
87
|
+
const [cmd, args] = opener(process.platform, url);
|
|
86
88
|
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); } catch { /* the URL is printed anyway */ }
|
|
87
89
|
}
|
|
88
90
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -92,7 +94,10 @@ async function login() {
|
|
|
92
94
|
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.`);
|
|
93
95
|
const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
|
|
94
96
|
const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
|
|
95
|
-
|
|
97
|
+
// The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
|
|
98
|
+
// Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
|
|
99
|
+
const page = signInPage(link, wall);
|
|
100
|
+
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.`);
|
|
96
101
|
out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${user_code} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
|
|
97
102
|
openBrowser(page);
|
|
98
103
|
let every = Math.max(1, Number(interval) || 5) * 1000;
|
|
@@ -176,6 +181,24 @@ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8'))
|
|
|
176
181
|
/** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
|
|
177
182
|
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
|
|
178
183
|
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
184
|
+
/** Whether anything is at the path, a link to nowhere included (existsSync follows a link, and says no for a dangling one). */
|
|
185
|
+
const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Where each path lands in `root`, every one settled before the first is written: inside the folder and through no
|
|
189
|
+
* symbolic link (lib/safe.js), or a refusal and nothing written. What a manifest, a framework's config or the Wall
|
|
190
|
+
* names is theirs to say; where it is written is this command's. `named` says where a path came from, for the refusal.
|
|
191
|
+
*/
|
|
192
|
+
function placesIn(root, paths, named = {}) {
|
|
193
|
+
const at = {}; const refused = [];
|
|
194
|
+
for (const rel of new Set(paths)) {
|
|
195
|
+
const r = inside(root, rel);
|
|
196
|
+
if (r.why) refused.push(`${JSON.stringify(rel)}${named[rel] ? ` (${named[rel]})` : ''}: ${r.why}`);
|
|
197
|
+
else at[rel] = r.path;
|
|
198
|
+
}
|
|
199
|
+
if (refused.length) fail(`Refused:\n\n${refused.map((r) => ` ${r}`).join('\n')}\n\n This command writes only inside ${root}, and never through a symbolic link. Nothing was written.`);
|
|
200
|
+
return at;
|
|
201
|
+
}
|
|
179
202
|
|
|
180
203
|
/** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
|
|
181
204
|
async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
@@ -222,9 +245,19 @@ async function wire() {
|
|
|
222
245
|
const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
|
|
223
246
|
const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
|
|
224
247
|
const targets = filesToWrite(project, appPopup, appSidePanel, { popup: !!wiring?.ownPopup, sidePanel: !!wiring?.ownSidePanel });
|
|
248
|
+
// AGENTS.md and CLAUDE.md, for coding agents, only where the project has none: its own are never replaced.
|
|
249
|
+
const entries = AGENT_ENTRY_FILES.filter((f) => !has(f) && typeof plan.files[f] === 'string');
|
|
250
|
+
// What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
|
|
251
|
+
const stale = [...new Set([project.codeDir, project.popupDir, 'public/', 'src/public/'])].flatMap((under) => STALE_FILES.map((f) => `${under}${f}`)).filter((p) => existsSync(join(dir, p)));
|
|
252
|
+
// The background a manifest names and that is not there is written — at the name the manifest gives, which is the repository's to choose.
|
|
253
|
+
const worker = wiring ? wiring.writeWorker : project.createWorker?.file ?? null;
|
|
254
|
+
const at = placesIn(dir, [...targets, ...entries, ...stale, ...(worker ? [worker] : []), ...(wiring?.did.length ? ['manifest.json'] : [])], worker ? { [worker]: 'the background the manifest names' } : {});
|
|
255
|
+
// Taken for missing, it may only be out of the listing's sight — spelt "./background.js", in dist/, or deeper down:
|
|
256
|
+
// the developer's own background is never replaced by two lines of ours.
|
|
257
|
+
if (worker && there(at[worker])) fail(`${JSON.stringify(worker)}, the background the manifest names, is already in ${dir}; it would have been replaced. Nothing was written.\n Name it in the manifest as the folder spells it — or, if a build writes it, run this with --into the folder whose manifest.json names it.`);
|
|
225
258
|
const present = targets.filter((f) => existsSync(join(dir, f)));
|
|
226
259
|
if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
|
|
227
|
-
const write = (rel, content) => { mkdirSync(dirname(
|
|
260
|
+
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
228
261
|
// A command older than the Wall asks for files the Wall no longer hands out: said, with the way to the current one, rather than a crash.
|
|
229
262
|
// The agent files come from Walls that write them; one that does not is older, not newer, than this command.
|
|
230
263
|
const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
|
|
@@ -232,12 +265,9 @@ async function wire() {
|
|
|
232
265
|
if (missing.length) fail(`This command (${VERSION}) is older than the Wall at ${wall}: it expects ${missing.join(', ')}, which the Wall no longer hands out.\n Update it: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz ${command} ${slug}`);
|
|
233
266
|
const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
|
|
234
267
|
for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
|
|
235
|
-
|
|
236
|
-
for (const f of AGENT_ENTRY_FILES) if (!has(f) && typeof plan.files[f] === 'string') { write(f, plan.files[f]); wrote.push(f); }
|
|
268
|
+
for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
|
|
237
269
|
const ownAgents = has('AGENTS.md') && !readFileSync(join(dir, 'AGENTS.md'), 'utf8').includes('TOOLABY.md');
|
|
238
|
-
|
|
239
|
-
const removed = [];
|
|
240
|
-
for (const at of new Set([project.codeDir, project.popupDir, 'public/', 'src/public/'])) for (const f of STALE_FILES) { const p = join(dir, `${at}${f}`); if (existsSync(p)) { unlinkSync(p); removed.push(`${at}${f}`); } }
|
|
270
|
+
for (const p of stale) unlinkSync(at[p]);
|
|
241
271
|
const did = [];
|
|
242
272
|
let ownWorker = null;
|
|
243
273
|
if (wiring) {
|
|
@@ -256,7 +286,7 @@ async function wire() {
|
|
|
256
286
|
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
257
287
|
out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
|
|
258
288
|
if (ownAgents) out(' Your AGENTS.md is your own: add a line so coding agents find the Wall\'s guide — "Before changing anything paid, read TOOLABY.md."\n');
|
|
259
|
-
if (
|
|
289
|
+
if (stale.length) out(` Removed: ${stale.join(', ')} — earlier files, now inside toolaby.js.\n`);
|
|
260
290
|
for (const sn of project.snippets) out(` Add to ${sn.file} — yours to paste, the manifest being generated:\n\n${sn.code.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
261
291
|
if (ownWorker) out(` Your background, ${ownWorker.file}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
262
292
|
if (project.built) out(` ${BUILD_COPY_NOTE}\n`);
|
|
@@ -271,34 +301,41 @@ async function create() {
|
|
|
271
301
|
const slug = positional[0];
|
|
272
302
|
if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
|
|
273
303
|
const dir = resolve(opt('--dir') ?? slug);
|
|
274
|
-
if (
|
|
304
|
+
if (there(dir)) fail(`${dir} already exists`);
|
|
275
305
|
// Which surfaces this extension is made with, asked first: the files for coding agents name only what is written.
|
|
276
306
|
const asked = opt('--surfaces');
|
|
277
307
|
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
278
308
|
const surfaces = surfacesOf(asked);
|
|
279
309
|
const plan = await filesFor(slug, true, null, surfaces);
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold :
|
|
310
|
+
// The scaffold's own files as the Wall names them (older Walls did not, hence the list), and only names this command
|
|
311
|
+
// knows (lib/safe.js): the Wall's list says which files a new extension has, never where they go. A name joined to
|
|
312
|
+
// the folder as it came let a Wall at another address — --wall, TOOLABY_WALL — write any file, anywhere.
|
|
313
|
+
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : SCAFFOLD_FILES;
|
|
314
|
+
const unknown = scaffold.filter((f) => !SCAFFOLD_FILES.includes(f));
|
|
315
|
+
if (unknown.length) fail(`The Wall at ${wall} named ${unknown.map((f) => JSON.stringify(f)).join(', ')} among a new extension's files: not ${unknown.length > 1 ? 'files' : 'a file'} this command (${VERSION}) writes.\n Nothing was written. If the Wall is newer than this command, update it: npm install -g toolaby@latest`);
|
|
284
316
|
// Both surfaces (the default), one of them, or none at all for a tool that works from the background — and beside each,
|
|
285
317
|
// the Wall's page for it, which toolaby.showPaywall() opens when an action is refused (the extension's own pages stay in front).
|
|
286
318
|
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold), ...(hasPopup(surfaces) ? POPUP_FILES : []), ...(hasSidePanel(surfaces) ? SIDE_PANEL_FILES : [])];
|
|
287
319
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
288
320
|
if (absent.length) fail(`The Wall at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
|
|
321
|
+
// What a coding agent reads: the guide with this tool's features and plans, the skill, AGENTS.md and CLAUDE.md.
|
|
322
|
+
const agentFiles = AGENT_FILES.filter((f) => typeof plan.files[f.name] === 'string');
|
|
323
|
+
const entries = AGENT_ENTRY_FILES.filter((f) => typeof plan.files[f] === 'string');
|
|
324
|
+
const icons = [16, 48, 128].map((s) => `icons/icon${s}.png`);
|
|
325
|
+
const at = placesIn(dir, [...wanted, 'manifest.json', ...icons, ...agentFiles.map((f) => f.path), ...entries]);
|
|
326
|
+
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
289
327
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
290
328
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
291
329
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
292
330
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
293
331
|
// The extension's identity too: every copy gets the id the tool key is bound to, wherever its folder is.
|
|
294
|
-
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(
|
|
295
|
-
for (const
|
|
296
|
-
const res = await fetch(`${wall}/boilerplate
|
|
297
|
-
write(
|
|
332
|
+
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(at['manifest.json'], 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces), key: plan.identity?.manifestKey ?? null }), null, 2) + '\n');
|
|
333
|
+
for (const icon of icons) {
|
|
334
|
+
const res = await fetch(`${wall}/boilerplate/${icon}`);
|
|
335
|
+
write(icon, Buffer.from(await res.arrayBuffer()));
|
|
298
336
|
}
|
|
299
|
-
|
|
300
|
-
for (const f of
|
|
301
|
-
for (const f of AGENT_ENTRY_FILES) if (typeof plan.files[f] === 'string') write(f, plan.files[f]);
|
|
337
|
+
for (const f of agentFiles) write(f.path, plan.files[f.name]);
|
|
338
|
+
for (const f of entries) write(f, plan.files[f]);
|
|
302
339
|
const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
|
|
303
340
|
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.buyer ?? `${plan.site}/tools/${slug}`}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${wall}/docs/surfaces\n`);
|
|
304
341
|
if (typeof plan.files['TOOLABY.md'] === 'string') out(` With a coding agent: AGENTS.md and TOOLABY.md tell it how this extension sells. For Claude Code, the Wall itself:\n claude mcp add toolaby -- npx -y toolaby@latest mcp${live ? ' --live' : ''}\n`);
|
|
@@ -393,7 +430,8 @@ async function webhooks() {
|
|
|
393
430
|
// package and any key but the item's own in an update, and it keeps the id it gave the item either way — the Wall
|
|
394
431
|
// admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
|
|
395
432
|
// or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
|
|
396
|
-
// can ship with.
|
|
433
|
+
// can ship with. What looks private — a key, an environment file, a source map — is left out and named, and so is a
|
|
434
|
+
// link out of the folder: a store zip is public. Nothing is sent anywhere: it reads the folder and writes one file.
|
|
397
435
|
const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
|
|
398
436
|
const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
399
437
|
const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
|
|
@@ -429,17 +467,30 @@ function zipOf(entries) {
|
|
|
429
467
|
return Buffer.concat([...locals, cd, end]);
|
|
430
468
|
}
|
|
431
469
|
|
|
470
|
+
/**
|
|
471
|
+
* What goes in the zip: every file in the folder but dotfiles, node_modules and zips — and, left out with the reason,
|
|
472
|
+
* a file that looks private (lib/safe.js), which --allow-private packs anyway, and a link that leads out of the folder
|
|
473
|
+
* or to nothing, which is never packed: a link in a cloned repository can put ~/.ssh/id_rsa in a store zip as logo.png.
|
|
474
|
+
* A zip is public once the store serves it. A folder reached twice through links is walked once.
|
|
475
|
+
*/
|
|
432
476
|
function filesIn(root) {
|
|
433
|
-
const
|
|
477
|
+
const top = realpathSync(root);
|
|
478
|
+
const found = []; const left = []; const walked = new Set([top]);
|
|
434
479
|
const walk = (dir) => {
|
|
435
480
|
for (const name of readdirSync(dir).sort()) {
|
|
436
481
|
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
437
482
|
const p = join(dir, name);
|
|
438
|
-
|
|
483
|
+
const rel = relative(root, p).split('\\').join('/');
|
|
484
|
+
let real = null; try { real = realpathSync(p); } catch { /* a link to nothing */ }
|
|
485
|
+
if (!real || (real !== top && !real.startsWith(top + sep))) { left.push([rel, real ? 'a link out of the folder' : 'a link to nothing']); continue; }
|
|
486
|
+
if (statSync(p).isDirectory()) { if (!walked.has(real)) { walked.add(real); walk(p); } continue; }
|
|
487
|
+
const why = privateKind(name);
|
|
488
|
+
if (why && !flag('--allow-private')) left.push([rel, why]);
|
|
489
|
+
else found.push(p);
|
|
439
490
|
}
|
|
440
491
|
};
|
|
441
492
|
walk(root);
|
|
442
|
-
return found;
|
|
493
|
+
return { found, left };
|
|
443
494
|
}
|
|
444
495
|
|
|
445
496
|
function pack() {
|
|
@@ -449,7 +500,10 @@ function pack() {
|
|
|
449
500
|
let manifest;
|
|
450
501
|
try { manifest = JSON.parse(readFileSync(join(folder, 'manifest.json'), 'utf8')); } catch (e) { fail(`manifest.json does not parse: ${e.message}`); }
|
|
451
502
|
if (!manifest.version) fail('manifest.json has no "version".');
|
|
452
|
-
|
|
503
|
+
// One to four numbers, as Chrome and the store take it — and a file name here: the zip is named with it, and a
|
|
504
|
+
// version with "../" in it put the zip outside the folder.
|
|
505
|
+
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.`);
|
|
506
|
+
const { found: files, left } = filesIn(folder);
|
|
453
507
|
const problems = [];
|
|
454
508
|
|
|
455
509
|
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
@@ -488,6 +542,13 @@ function pack() {
|
|
|
488
542
|
writeFileSync(target, zipOf(entries));
|
|
489
543
|
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
490
544
|
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
545
|
+
// What was left out, and why — or, asked for, what private file went in: a store zip is public.
|
|
546
|
+
const links = left.filter(([, why]) => why.startsWith('a link'));
|
|
547
|
+
const kept = left.filter(([, why]) => !why.startsWith('a link'));
|
|
548
|
+
if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${f} (${why})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
|
|
549
|
+
if (links.length) out(` Left out: ${links.map(([f, why]) => `${f} (${why})`).join(', ')}. The zip holds the folder's own files: copy in what belongs in it.`);
|
|
550
|
+
const privately = flag('--allow-private') ? files.map((f) => [relative(folder, f).split('\\').join('/'), privateKind(basename(f))]).filter(([, why]) => why) : [];
|
|
551
|
+
if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${f} (${why})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
|
|
491
552
|
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.');
|
|
492
553
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
493
554
|
}
|
|
@@ -517,6 +578,7 @@ function help() {
|
|
|
517
578
|
toolaby tools your tools, with their ids
|
|
518
579
|
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
519
580
|
refuses a Test key or localhost inside --out <file.zip>
|
|
581
|
+
leaves out keys, .env files and source maps --allow-private packs them
|
|
520
582
|
toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
521
583
|
--browser loads it in Chromium (needs Playwright in the project)
|
|
522
584
|
toolaby mcp the Wall for coding agents, an MCP server on stdio:
|
package/lib/check.js
CHANGED
|
@@ -97,8 +97,8 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
97
97
|
const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
|
|
98
98
|
if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
99
99
|
else ok('The manifest has the permissions and host permissions the Wall needs.');
|
|
100
|
-
// The identity: without the `key`, each copy's id follows its folder, and the tool key
|
|
101
|
-
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 the tool key
|
|
100
|
+
// 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.
|
|
101
|
+
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.`);
|
|
102
102
|
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}/* in host_permissions and externally_connectable.`);
|
|
103
103
|
|
|
104
104
|
// 5. The client, as fresh as the Wall's.
|
package/lib/safe.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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, or put a private file in a store zip.
|
|
3
|
+
//
|
|
4
|
+
// Each of these took someone else's word. A cloned repository's manifest named the file `wire` wrote — a service
|
|
5
|
+
// worker at "../../.zshrc" overwrote the shell's startup file — and a link in the folder took a write wherever it
|
|
6
|
+
// pointed. The Wall's list of a new extension's files was joined to the folder as it came, so a Wall at another
|
|
7
|
+
// address (--wall, TOOLABY_WALL) could write any file with any content. And on Windows the sign-in address went
|
|
8
|
+
// through cmd.exe, where an `&` in it ran a command. Hand-written, unlike manifest.js and project.js: this is the
|
|
9
|
+
// command's own boundary, not a wiring rule the browser shares.
|
|
10
|
+
import { lstatSync } from 'node:fs';
|
|
11
|
+
import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The minimal extension's own files, as the Wall names them (SCAFFOLD_FILES in wall/src/packages/wiring/files.ts;
|
|
15
|
+
* a test keeps the two equal): the only names `create` accepts from the Wall's list, which says which of them to
|
|
16
|
+
* write and never where. A name the Wall adds later is refused by a command that predates it — nothing is written,
|
|
17
|
+
* and `npm install -g toolaby@latest` is the way on.
|
|
18
|
+
*/
|
|
19
|
+
export const SCAFFOLD_FILES = ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Where `rel` lands inside `root` — `{ path }` — or why it may not — `{ why }`. Refused: an absolute path, a `..`,
|
|
23
|
+
* a backslash on POSIX (there it is part of a name, not a separator), anything that resolves outside the folder,
|
|
24
|
+
* and a symbolic link at any step below the root, dangling or not: a link in a cloned repository points wherever
|
|
25
|
+
* its author chose, and writing through it writes there. The root itself is the developer's to choose.
|
|
26
|
+
*/
|
|
27
|
+
export function inside(root, rel) {
|
|
28
|
+
if (typeof rel !== 'string' || !rel.trim() || rel.includes('\0')) return { why: 'it is not a file name' };
|
|
29
|
+
if (isAbsolute(rel) || posix.isAbsolute(rel) || win32.isAbsolute(rel)) return { why: 'it is an absolute path' };
|
|
30
|
+
if (sep === '/' && rel.includes('\\')) return { why: 'it has a backslash in it' };
|
|
31
|
+
if (rel.split(/[\\/]/).includes('..')) return { why: 'it has ".." in it' };
|
|
32
|
+
const base = resolve(root);
|
|
33
|
+
const path = resolve(base, rel);
|
|
34
|
+
if (!path.startsWith(base + sep)) return { why: 'it is outside the folder' };
|
|
35
|
+
let at = base;
|
|
36
|
+
for (const part of relative(base, path).split(sep)) {
|
|
37
|
+
at = join(at, part);
|
|
38
|
+
let st;
|
|
39
|
+
try { st = lstatSync(at); } catch { break; } // not there yet, and so nothing below it is
|
|
40
|
+
if (st.isSymbolicLink()) return { why: `${relative(base, at).split(sep).join('/')} is a symbolic link` };
|
|
41
|
+
}
|
|
42
|
+
return { path };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$|\.localhost$/i;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 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
|
+
* machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
|
|
50
|
+
* http, or a scheme such as file: that opens whatever it names.
|
|
51
|
+
*/
|
|
52
|
+
export function signInPage(link, wall) {
|
|
53
|
+
if (typeof link !== 'string') return null;
|
|
54
|
+
let home; let page;
|
|
55
|
+
try { home = new URL(wall); page = new URL(link, home); } catch { return null; }
|
|
56
|
+
if (page.username || page.password) return null;
|
|
57
|
+
if (page.protocol === 'https:' && page.origin === home.origin) return page.href;
|
|
58
|
+
const web = page.protocol === 'http:' || page.protocol === 'https:';
|
|
59
|
+
return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How a page is opened on each platform, the address one argument and no shell in between. On Windows,
|
|
64
|
+
* `cmd /c start "" <url>` read an `&` in the address as a second command; rundll32 hands it to the default browser
|
|
65
|
+
* as it is.
|
|
66
|
+
*/
|
|
67
|
+
export function opener(platform, url) {
|
|
68
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
69
|
+
if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]];
|
|
70
|
+
return ['xdg-open', [url]];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 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 credentials by its
|
|
76
|
+
* name, or a source map, which carries the source. A store zip is public: anyone can download an item and unzip it.
|
|
77
|
+
* `pack` leaves these out and names them; --allow-private packs them. Dotfiles, .env among them, are never packed.
|
|
78
|
+
*/
|
|
79
|
+
export function privateKind(name) {
|
|
80
|
+
const n = name.toLowerCase();
|
|
81
|
+
if (/\.(pem|key|p12|pfx|p8|jks|keystore|ppk)$/.test(n) || /^id_(rsa|dsa|ecdsa|ed25519)$/.test(n)) return 'a private key';
|
|
82
|
+
if (/\.env(\.[^.]+)*$/.test(n)) return 'an environment file';
|
|
83
|
+
if (/^(credentials|secrets?|service[-_]?account[^/]*)\.json$/.test(n)) return 'credentials';
|
|
84
|
+
if (n.endsWith('.map')) return 'a source map';
|
|
85
|
+
return null;
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.1",
|
|
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": {
|