toolaby 1.5.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 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
@@ -6,6 +6,8 @@
6
6
  // toolaby create <tool-id> make a new, minimal extension for your tool in <tool-id>/
7
7
  // toolaby tools your tools, with the ids the commands want
8
8
  // toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's key left out
9
+ // toolaby check [--into <folder>] whether the extension here is wired right; --browser loads it in Chromium too
10
+ // toolaby mcp the Wall for coding agents: an MCP server on stdio (lib/mcp.js)
9
11
  // toolaby webhooks events the six events, with an example of each
10
12
  // toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
11
13
  // toolaby webhooks endpoints the workspace's endpoints
@@ -27,16 +29,19 @@
27
29
  // The wiring rules — what a folder is and what its manifest needs — are the
28
30
  // platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
29
31
  // so this and the dashboard's Wire button never disagree.
30
- import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
31
- 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';
32
34
  import { deflateRawSync } from 'node:zlib';
33
35
  import { homedir } from 'node:os';
34
36
  import { spawn } from 'node:child_process';
35
37
  import { createHmac, randomBytes } from 'node:crypto';
36
- import { wireManifest, WORKER_LINES } from './lib/manifest.js';
37
- import { detectProject, CODE_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
38
+ import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
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
+ import { runMcp } from './lib/mcp.js';
41
+ import { runCheck } from './lib/check.js';
42
+ import { inside, signInPage, opener, privateKind, SCAFFOLD_FILES } from './lib/safe.js';
38
43
 
39
- const VERSION = '1.5.0';
44
+ const VERSION = '1.6.1';
40
45
  const CLIENT_ID = 'toolaby-cli';
41
46
  const argv = process.argv.slice(2);
42
47
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -77,8 +82,9 @@ async function api(path, { method = 'GET', body, token = storedToken(), raw = fa
77
82
  }
78
83
 
79
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. */
80
86
  function openBrowser(url) {
81
- const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
87
+ const [cmd, args] = opener(process.platform, url);
82
88
  try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); } catch { /* the URL is printed anyway */ }
83
89
  }
84
90
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -88,7 +94,10 @@ async function login() {
88
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.`);
89
95
  const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
90
96
  const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
91
- const page = link.startsWith('http') ? link : `${wall}${link}`;
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.`);
92
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`);
93
102
  openBrowser(page);
94
103
  let every = Math.max(1, Number(interval) || 5) * 1000;
@@ -169,14 +178,34 @@ function listTree(dir, depth, prefix = '', acc = new Set()) {
169
178
  return acc;
170
179
  }
171
180
  const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
181
+ /** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
182
+ const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
172
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
+ }
173
202
 
174
203
  /** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
175
- async function filesFor(slug, scaffold, manifestKey = null) {
204
+ async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
176
205
  const token = await tokenOrLogin();
177
- const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey);
206
+ const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey); if (surfaces) q.set('surfaces', surfaces);
178
207
  const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
179
- if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey); }
208
+ if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey, surfaces); }
180
209
  if (res.status === 404) {
181
210
  // Say who the command is: the tool may well exist — in a workspace of another account of theirs.
182
211
  const me = await api('/api/cli/me', { token }).catch(() => null);
@@ -210,44 +239,61 @@ async function wire() {
210
239
  const oldConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
211
240
  const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
212
241
  const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
213
- const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, rememberedSidePanel, manifestKey }) : null;
242
+ // An extension whose own pages draw the panel — what `create` makes, or "your popup first" — keeps them in front.
243
+ const ownInFront = drawsItself(manifest, oldConfig);
244
+ const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, rememberedSidePanel, manifestKey, ownInFront }) : null;
214
245
  const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
215
246
  const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
216
- const targets = filesToWrite(project, appPopup, appSidePanel);
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.`);
217
258
  const present = targets.filter((f) => existsSync(join(dir, f)));
218
259
  if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
219
- const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
260
+ const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
220
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.
221
- const missing = targets.map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
262
+ // The agent files come from Walls that write them; one that does not is older, not newer, than this command.
263
+ const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
264
+ const missing = targets.filter((p) => !agentPaths.has(p)).map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
222
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}`);
223
- for (const path of targets) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
224
- // What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
225
- const removed = [];
226
- 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}`); } }
266
+ const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
267
+ for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
268
+ for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
269
+ const ownAgents = has('AGENTS.md') && !readFileSync(join(dir, 'AGENTS.md'), 'utf8').includes('TOOLABY.md');
270
+ for (const p of stale) unlinkSync(at[p]);
227
271
  const did = [];
228
272
  let ownWorker = null;
229
273
  if (wiring) {
230
274
  if (wiring.writeWorker) write(wiring.writeWorker, WORKER_LINES);
231
275
  if (wiring.did.length) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
232
276
  did.push(...wiring.did);
233
- if (wiring.ownWorker) ownWorker = wiring.ownWorker;
277
+ if (wiring.ownWorker && !startsPlatform(join(dir, wiring.ownWorker.file))) ownWorker = wiring.ownWorker;
234
278
  } else if (project.createWorker) {
235
279
  write(project.createWorker.file, project.createWorker.content);
236
280
  did.push(`a background entry, ${project.createWorker.file} (it starts the platform)`);
237
- } else if (project.worker && !project.extraFiles) {
281
+ } else if (project.worker && !project.extraFiles && !startsPlatform(join(dir, project.worker))) {
238
282
  // A framework with its own extension point (WXT's module) starts the platform in the background itself.
239
283
  ownWorker = { file: project.worker, classic: false };
240
284
  }
241
285
 
242
286
  out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
243
- out(` Written: ${targets.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
244
- if (removed.length) out(` Removed: ${removed.join(', ')} — earlier files, now inside toolaby.js.\n`);
287
+ out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
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');
289
+ if (stale.length) out(` Removed: ${stale.join(', ')} — earlier files, now inside toolaby.js.\n`);
245
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`);
246
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`);
247
292
  if (project.built) out(` ${BUILD_COPY_NOTE}\n`);
248
293
  out(` Next: ${project.kind === 'plain' && !project.built ? '' : 'build or start the dev server, then '}chrome://extensions → reload it. The tool's Set up page says "checked in" the moment it starts.`);
249
294
  if (project.note) out(` ${project.note}\n`);
250
295
  else if (appPopup || appSidePanel) out(` Your ${appPopup && appSidePanel ? 'popup and side panel open' : appSidePanel ? 'side panel opens' : 'popup opens'} as before; what the Free plan holds (Pricing, in the dashboard) decides when the Wall's panel takes its place.\n`);
296
+ else if (ownInFront && (wiring?.ownPopup || wiring?.ownSidePanel)) out(` Your ${wiring.ownPopup && wiring.ownSidePanel ? 'popup and side panel stay' : wiring.ownSidePanel ? 'side panel stays' : 'popup stays'} in front and draw${wiring.ownPopup && wiring.ownSidePanel ? '' : 's'} the panel, as before; toolaby.showPaywall() opens the Wall's page beside it.\n`);
251
297
  else out(` No popup or side panel to stand in front of: count a use where your feature runs — the Set up page shows the line.\n`);
252
298
  }
253
299
 
@@ -255,31 +301,44 @@ async function create() {
255
301
  const slug = positional[0];
256
302
  if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
257
303
  const dir = resolve(opt('--dir') ?? slug);
258
- if (existsSync(dir)) fail(`${dir} already exists`);
259
- const plan = await filesFor(slug, true);
260
- const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
261
- // The scaffold's own files as the Wall names them (older Walls did not, hence the list): a Wall that adds one to the
262
- // minimal extension is written by this command too, instead of a manifest naming a file that never arrived.
263
- const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
264
- // Which surfaces this extension is made with: both (the default), one of them, or none at all for a tool that works
265
- // from the background. Asked for once here, rather than written and then deleted by hand.
304
+ if (there(dir)) fail(`${dir} already exists`);
305
+ // Which surfaces this extension is made with, asked first: the files for coding agents name only what is written.
266
306
  const asked = opt('--surfaces');
267
307
  if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
268
308
  const surfaces = surfacesOf(asked);
269
- const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold)];
309
+ const plan = await filesFor(slug, true, null, surfaces);
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`);
316
+ // Both surfaces (the default), one of them, or none at all for a tool that works from the background — and beside each,
317
+ // the Wall's page for it, which toolaby.showPaywall() opens when an action is refused (the extension's own pages stay in front).
318
+ const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold), ...(hasPopup(surfaces) ? POPUP_FILES : []), ...(hasSidePanel(surfaces) ? SIDE_PANEL_FILES : [])];
270
319
  const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
271
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); };
272
327
  // Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
273
328
  for (const f of wanted) write(f, fillPages(plan.files[f]));
274
329
  // The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
275
330
  // missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
276
- write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces) }), null, 2) + '\n');
277
- for (const s of [16, 48, 128]) {
278
- const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
279
- write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
331
+ // The extension's identity too: every copy gets the id the tool key is bound to, wherever its folder is.
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()));
280
336
  }
337
+ for (const f of agentFiles) write(f.path, plan.files[f.name]);
338
+ for (const f of entries) write(f, plan.files[f]);
281
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];
282
- 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.site}/tools/${slug}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${plan.site}/docs/surfaces\n`);
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`);
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`);
283
342
  }
284
343
 
285
344
  // ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
@@ -371,7 +430,8 @@ async function webhooks() {
371
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
372
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_
373
432
  // or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
374
- // can ship with. Nothing is sent anywhere: it reads the folder and writes one file.
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.
375
435
  const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
376
436
  const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
377
437
  const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
@@ -407,17 +467,30 @@ function zipOf(entries) {
407
467
  return Buffer.concat([...locals, cd, end]);
408
468
  }
409
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
+ */
410
476
  function filesIn(root) {
411
- const found = [];
477
+ const top = realpathSync(root);
478
+ const found = []; const left = []; const walked = new Set([top]);
412
479
  const walk = (dir) => {
413
480
  for (const name of readdirSync(dir).sort()) {
414
481
  if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
415
482
  const p = join(dir, name);
416
- if (statSync(p).isDirectory()) walk(p); else found.push(p);
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);
417
490
  }
418
491
  };
419
492
  walk(root);
420
- return found;
493
+ return { found, left };
421
494
  }
422
495
 
423
496
  function pack() {
@@ -427,7 +500,10 @@ function pack() {
427
500
  let manifest;
428
501
  try { manifest = JSON.parse(readFileSync(join(folder, 'manifest.json'), 'utf8')); } catch (e) { fail(`manifest.json does not parse: ${e.message}`); }
429
502
  if (!manifest.version) fail('manifest.json has no "version".');
430
- const files = filesIn(folder);
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);
431
507
  const problems = [];
432
508
 
433
509
  // Which side the build sells on: the tool keys inside it, wherever the bundler put them.
@@ -466,10 +542,29 @@ function pack() {
466
542
  writeFileSync(target, zipOf(entries));
467
543
  const kb = (statSync(target).size / 1024).toFixed(0);
468
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'}.`);
469
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.');
470
553
  out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
471
554
  }
472
555
 
556
+ // ── mcp and check: the Wall for coding agents ──────────────────────────────────────────────────────────────────────
557
+ function mcp() {
558
+ // Every word to stdout is a protocol message here: the session is the stored one, never a sign-in on the spot.
559
+ runMcp({ version: VERSION, side, wall, token: storedToken, api: (path, o = {}) => api(path, o) });
560
+ return new Promise(() => {}); // until stdin closes
561
+ }
562
+
563
+ async function check() {
564
+ const passed = await runCheck({ dir: opt('--into') ?? positional[0] ?? '.', out, api: (path, o = {}) => api(path, o), token: storedToken, detectProject, wireManifest, listTree, readJson, browser: flag('--browser') });
565
+ if (!passed) process.exit(1);
566
+ }
567
+
473
568
  function help() {
474
569
  out(`
475
570
  toolaby — the Toolaby command line (${VERSION})
@@ -483,6 +578,11 @@ function help() {
483
578
  toolaby tools your tools, with their ids
484
579
  toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
485
580
  refuses a Test key or localhost inside --out <file.zip>
581
+ leaves out keys, .env files and source maps --allow-private packs them
582
+ toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
583
+ --browser loads it in Chromium (needs Playwright in the project)
584
+ toolaby mcp the Wall for coding agents, an MCP server on stdio:
585
+ claude mcp add toolaby -- npx -y toolaby@latest mcp
486
586
  toolaby webhooks events the six events, with an example of each
487
587
  toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
488
588
  toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
@@ -493,7 +593,7 @@ function help() {
493
593
  `);
494
594
  }
495
595
 
496
- const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
596
+ const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, check, mcp, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
497
597
  const run = commands[command];
498
598
  if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
499
599
  Promise.resolve().then(run).catch((e) => fail(e.message));
package/lib/check.js ADDED
@@ -0,0 +1,190 @@
1
+ // `toolaby check`: whether the extension in a folder is wired right, in words an agent can act on. The files the Wall
2
+ // writes, the tool key and the tool it names, the manifest against the platform's own wiring rules and its `key`, the
3
+ // client's freshness, the feature keys the code gates on against the tool's, the paywall's page, whether a copy has
4
+ // checked in and whether the key is current, and — with --browser, where Playwright is installed — the extension
5
+ // loaded in Chromium, its background answering and its own pages opening without an error.
6
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
7
+ import { join, relative, resolve } from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { createRequire } from 'node:module';
10
+
11
+ const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
12
+ const CODE = /\.(m?js|jsx|ts|tsx|html|vue|svelte)$/;
13
+ const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.output', '.wxt', '.plasmo', '.next', 'coverage']);
14
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
15
+
16
+ function decodeKey(key) {
17
+ try {
18
+ const facts = JSON.parse(Buffer.from(key.split('_').slice(2).join('_'), 'base64url').toString('utf8'));
19
+ return typeof facts.s === 'string' && typeof facts.t === 'string' ? { site: facts.s.replace(/\/$/, ''), slug: facts.t, x: facts.k?.x, y: facts.k?.y } : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ /** The extension's own source files, not the Wall's and not a build's. */
24
+ function sourceFiles(dir, acc = [], depth = 4) {
25
+ for (const name of readdirSync(dir)) {
26
+ if (SKIP.has(name) || name.startsWith('.')) continue;
27
+ const p = join(dir, name);
28
+ let st; try { st = statSync(p); } catch { continue; }
29
+ if (st.isDirectory()) { if (depth > 0) sourceFiles(p, acc, depth - 1); continue; }
30
+ if (CODE.test(name) && !name.startsWith('toolaby')) acc.push(p);
31
+ }
32
+ return acc;
33
+ }
34
+
35
+ /**
36
+ * The code without its comments: an example in a comment (the scaffold's background has one) is not a gate. Strings
37
+ * are kept whole, so a `//` inside one is not taken for a comment.
38
+ */
39
+ export function withoutComments(text) {
40
+ return text.replace(/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*|<!--[\s\S]*?-->/g, (m, str) => (str ? m : ''));
41
+ }
42
+
43
+ /** Feature keys the code asks about: gate({ feature: 'x' }) and has('x'), outside comments. */
44
+ export function featuresUsed(source) {
45
+ const text = withoutComments(source);
46
+ const keys = new Set();
47
+ for (const m of text.matchAll(/gate\(\s*\{[^}]*?feature\s*:\s*['"`]([a-z0-9][a-z0-9-]*)['"`]/g)) keys.add(m[1]);
48
+ for (const m of text.matchAll(/\.has\(\s*['"`]([a-z0-9][a-z0-9-]*)['"`]\s*\)/g)) keys.add(m[1]);
49
+ for (const m of text.matchAll(/showPaywall\(\s*\{[^}]*?feature\s*:\s*['"`]([a-z0-9][a-z0-9-]*)['"`]/g)) keys.add(m[1]);
50
+ return keys;
51
+ }
52
+
53
+ export async function runCheck({ dir: given, out, api, token, detectProject, wireManifest, listTree, readJson, browser }) {
54
+ const dir = resolve(given ?? '.');
55
+ const results = [];
56
+ const ok = (s) => { results.push('ok'); out(` ✓ ${s}`); };
57
+ const warn = (s) => { results.push('warn'); out(` ! ${s}`); };
58
+ const bad = (s) => { results.push('bad'); out(` ✗ ${s}`); };
59
+ out(`\n Checking ${dir}\n`);
60
+
61
+ // 1. What the folder is.
62
+ if (!existsSync(dir)) { bad(`${dir} does not exist.`); return false; }
63
+ const tree = listTree(dir, 3);
64
+ const has = (p) => tree.has(p.replace(/\/$/, ''));
65
+ const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
66
+ if (has('manifest.json') && !manifest) { bad('manifest.json is not valid JSON.'); return false; }
67
+ const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
68
+ const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readFileSync(join(dir, configSource), 'utf8') : null, site: 'https://example.invalid', tree });
69
+ if (project.kind === 'unknown') { bad(`Not an extension project: no manifest.json at the top, and not WXT, CRXJS or Plasmo${project.foundManifest ? ` (there is a manifest at ${project.foundManifest}: run this with --into ${project.foundManifest.replace(/\/?manifest\.json$/, '') || '.'})` : ''}.`); return false; }
70
+ ok(project.kind === 'plain' ? 'A plain extension: manifest.json at the top.' : `A ${project.label} project.`);
71
+
72
+ // 2. The Wall's files.
73
+ const at = (f) => join(dir, `${project.codeDir}${f}`);
74
+ const missing = ['toolaby.js', 'toolaby.config.js'].filter((f) => !existsSync(at(f)));
75
+ if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx toolaby wire <tool-id>\` here.`); return false; }
76
+ ok(`The Wall's files are in ${project.codeDir || 'the top folder'}.`);
77
+
78
+ // 3. The tool key, and the tool it names.
79
+ const keyText = readFileSync(at('toolaby.config.js'), 'utf8').match(KEY)?.[0];
80
+ const key = keyText ? decodeKey(keyText) : null;
81
+ if (!key) { bad('toolaby.config.js holds no tool key. Run `npx toolaby upgrade <tool-id>` here.'); return false; }
82
+ const mode = keyText.split('_')[1];
83
+ ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development Wall'} (${key.site}).`);
84
+ const pub = await fetch(`${key.site}/api/tools/${encodeURIComponent(key.slug)}`).then(async (r) => ({ status: r.status, json: await r.json().catch(() => null) })).catch((e) => ({ status: 0, json: null, error: e.message }));
85
+ const tool = pub.status === 200 ? pub.json : null;
86
+ if (!tool) bad(pub.status === 0 ? `The Wall at ${key.site} could not be reached.` : pub.status === 404 ? `The Wall does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx toolaby tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
87
+ else {
88
+ ok(`The Wall knows it: ${tool.name}.`);
89
+ const jwk = tool.publicKeyJwk ?? {};
90
+ if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx toolaby upgrade <tool-id>` writes it in.');
91
+ }
92
+
93
+ // 4. The manifest, against the platform's own wiring rules: what wiring it again would add.
94
+ const configText = readFileSync(at('toolaby.config.js'), 'utf8');
95
+ if (project.manifestFile && manifest) {
96
+ const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
97
+ const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
98
+ if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
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 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
+ } 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
+
104
+ // 5. The client, as fresh as the Wall's.
105
+ const client = await fetch(`${key.site}/client/toolaby.js`).then((r) => (r.ok ? r.text() : null)).catch(() => null);
106
+ if (client === null) warn('The Wall\'s current client could not be read to compare.');
107
+ else if (sha(client) !== sha(readFileSync(at('toolaby.js'), 'utf8'))) warn(`toolaby.js is not the Wall's current client. \`npx toolaby upgrade ${key.slug}\` replaces it (your own files stay).`);
108
+ else ok('toolaby.js is the Wall\'s current client.');
109
+
110
+ // 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
111
+ const sources = sourceFiles(dir).map((f) => readFileSync(f, 'utf8'));
112
+ if (project.manifestFile && manifest && sources.some((s) => /\bshowPaywall\s*\(/.test(withoutComments(s)))) {
113
+ const pages = [manifest.action?.default_popup ? 'toolaby-popup.html' : null, (manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel) ? 'toolaby-sidepanel.html' : null].filter(Boolean);
114
+ const absent = pages.filter((p) => !existsSync(join(dir, `${project.popupDir}${p}`)));
115
+ if (absent.length) warn(`The code calls toolaby.showPaywall(), which opens ${absent.join(' and ')}, and the extension has no such page: the paywall opens the buyer page in a new tab instead. \`npx toolaby upgrade ${key.slug}\` writes ${absent.length > 1 ? 'them' : 'it'}; your pages stay in front.`);
116
+ }
117
+ if (tool) {
118
+ const used = new Set();
119
+ for (const s of sources) for (const k of featuresUsed(s)) used.add(k);
120
+ const known = new Set((tool.features ?? []).map((f) => f.key));
121
+ const unknown = [...used].filter((k) => !known.has(k));
122
+ if (unknown.length) bad(`The code gates on ${unknown.map((k) => `"${k}"`).join(', ')}, which ${unknown.length > 1 ? 'the tool does not have' : 'is not a feature of the tool'}: every buyer would be refused. Add ${unknown.length > 1 ? 'them' : 'it'} (the MCP tool toolaby_set_features, or the tool's Pricing page).`);
123
+ else if (used.size) ok(`The code gates on ${[...used].map((k) => `"${k}"`).join(', ')}: ${used.size > 1 ? 'all features' : 'a feature'} of the tool.`);
124
+ const unsold = [...used].filter((k) => known.has(k) && !(tool.plans ?? []).some((p) => (p.features ?? []).includes(k)) && !(tool.features ?? []).find((f) => f.key === k)?.free);
125
+ if (unsold.length) warn(`No plan unlocks ${unsold.map((k) => `"${k}"`).join(', ')} yet: buyers are offered nothing for ${unsold.length > 1 ? 'them' : 'it'}. Say which plans do (toolaby_set_features with plans, or the Pricing page).`);
126
+ if (tool.access !== 'open' && !(tool.plans ?? []).length) warn('The Free plan does not hold everything, and there is no plan to buy. Add one (toolaby_add_plan, or Pricing).');
127
+ }
128
+
129
+ // 7. A copy that checked in: what the dashboard's Set up page waits for.
130
+ if (tool && token()) {
131
+ const facts = await api(`/api/cli/tools/${encodeURIComponent(key.slug)}`).catch(() => null);
132
+ const seen = facts?.ok ? facts.json?.extension?.checkedInAt : undefined;
133
+ if (seen) ok(`A copy of the extension checked in (${new Date(seen).toISOString().slice(0, 16).replace('T', ' ')} UTC).`);
134
+ else if (facts?.ok) warn('No copy has checked in yet: load it at chrome://extensions and open it once.');
135
+ // The key carries what the Free plan holds and the kind of plan sold: a change since it was written leaves it behind.
136
+ const current = facts?.ok ? facts.json?.toolKey : null;
137
+ if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than the Wall's: what the Free plan holds, the plans or the signing key changed since it was written. \`npx toolaby upgrade ${key.slug}\` writes the current one.`);
138
+ }
139
+
140
+ // 8. In a browser: the background answers.
141
+ if (browser) await browserCheck({ dir, project, manifest, ok, warn, bad });
142
+
143
+ const n = (k) => results.filter((r) => r === k).length;
144
+ out(`\n ${n('bad') ? `${n('bad')} to fix` : 'Nothing to fix'}${n('warn') ? `, ${n('warn')} to look at` : ''}.\n`);
145
+ return n('bad') === 0;
146
+ }
147
+
148
+ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
149
+ let chromium;
150
+ // Playwright's entry is CommonJS: imported, its exports are the module's default.
151
+ try { const pw = await import(createRequire(join(dir, 'package.json')).resolve('playwright')); chromium = pw.chromium ?? pw.default?.chromium; if (!chromium) throw new Error('no chromium'); }
152
+ catch { warn(`--browser needs Playwright in this project: ${existsSync(join(dir, 'package.json')) ? '' : '`npm init -y` (there is no package.json), then '}\`npm i -D playwright && npx playwright install chromium\`.`); return; }
153
+ const built = project.kind === 'plain' ? dir : ['.output/chrome-mv3', 'dist', 'build/chrome-mv3-dev', 'build/chrome-mv3-prod', 'build'].map((d) => join(dir, d)).find((d) => existsSync(join(d, 'manifest.json')));
154
+ if (!built) { warn(`No built extension to load: build first (${project.label}).`); return; }
155
+ const shipped = readJsonSafe(join(built, 'manifest.json')) ?? manifest;
156
+ const ctx = await chromium.launchPersistentContext('', { channel: 'chromium', headless: true, args: [`--disable-extensions-except=${built}`, `--load-extension=${built}`] }).catch((e) => { bad(`Chromium did not start: ${e.message}`); return null; });
157
+ if (!ctx) return;
158
+ try {
159
+ const worker = ctx.serviceWorkers()[0] ?? (await ctx.waitForEvent('serviceworker', { timeout: 15_000 }).catch(() => null));
160
+ if (!worker) { bad('The extension\'s service worker did not start: its code threw while loading. Open chrome://extensions → Errors.'); return; }
161
+ const id = new URL(worker.url()).host;
162
+ ok(`Loaded in Chromium as ${id} (${relative(dir, built) || '.'}).`);
163
+ const pagePath = shipped?.action?.default_popup ?? shipped?.side_panel?.default_path ?? null;
164
+ const page = await ctx.newPage();
165
+ await page.goto(pagePath ? `chrome-extension://${id}/${pagePath}` : `chrome-extension://${id}/manifest.json`);
166
+ const answer = await page.evaluate(() => Promise.race([
167
+ chrome.runtime.sendMessage({ action: 'getLicenseStatus' }).then((r) => r ?? null),
168
+ new Promise((r) => setTimeout(() => r('timeout'), 8000)),
169
+ ])).catch((e) => `error: ${e.message}`);
170
+ if (answer && answer !== 'timeout' && !String(answer).startsWith('error')) ok('The background answers the Wall\'s client: its listeners registered.');
171
+ else bad(`The background did not answer (${answer ?? 'nothing'}): its module threw while loading, so none of its listeners registered.`);
172
+ await page.close().catch(() => {});
173
+ // The extension's own pages, each opened in a tab: one whose script throws is a popup that opens blank.
174
+ const own = [shipped?.action?.default_popup, shipped?.side_panel?.default_path].filter((p, i, all) => typeof p === 'string' && p && all.indexOf(p) === i);
175
+ for (const p of own) {
176
+ const tab = await ctx.newPage();
177
+ const errors = [];
178
+ tab.on('pageerror', (e) => errors.push(e.message));
179
+ await tab.goto(`chrome-extension://${id}/${p}`).catch((e) => errors.push(e.message));
180
+ await tab.waitForTimeout(1500);
181
+ await tab.close().catch(() => {});
182
+ if (errors.length) bad(`${p} threw when it opened: ${errors[0]}`);
183
+ else ok(`${p} opens without an error.`);
184
+ }
185
+ } finally {
186
+ await ctx.close().catch(() => {});
187
+ }
188
+ }
189
+
190
+ function readJsonSafe(p) { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } }
package/lib/manifest.js CHANGED
@@ -12,11 +12,28 @@
12
12
  * that fixes its id, which the tool key is bound to (identity.ts) — unless
13
13
  * the manifest has one of its own, which is then the identity the platform
14
14
  * adopts. Pure: given the manifest and what is on disk, it says what to
15
- * write.
15
+ * write. An extension whose own pages draw the panel (drawsItself) keeps
16
+ * them in front: nothing of the Wall's is put there.
16
17
  */
17
18
  export const WALL_POPUP = 'toolaby-popup.html';
18
19
  export const WALL_SIDE_PANEL = 'toolaby-sidepanel.html';
19
20
  export const WORKER_LINES = "import { toolaby } from './toolaby.js';\n\ntoolaby.startBackground();\n";
21
+ /**
22
+ * Whether an earlier wiring left the extension's own pages drawing the panel: a configuration that says '' for both
23
+ * pages while the manifest names none of the Wall's. That is what `create` makes, and the "your popup first" shape
24
+ * (docs/gating); a wiring keeps it rather than putting the Wall's page back in front. A first wiring (no configuration
25
+ * yet) puts the Wall's in front.
26
+ */
27
+ export function drawsItself(manifest, oldConfig) {
28
+ if (!oldConfig)
29
+ return false;
30
+ const popup = oldConfig.match(/appPopup:\s*'([^']*)'/);
31
+ const side = oldConfig.match(/appSidePanel:\s*'([^']*)'/);
32
+ if (!popup || popup[1] !== '' || (side && side[1] !== ''))
33
+ return false;
34
+ const pages = [manifest?.action?.default_popup, manifest?.side_panel?.default_path, manifest?.sidebar_action?.default_panel];
35
+ return !pages.some((p) => p === WALL_POPUP || p === WALL_SIDE_PANEL);
36
+ }
20
37
  export function wireManifest(input, o) {
21
38
  const manifest = JSON.parse(JSON.stringify(input));
22
39
  const did = [];
@@ -44,7 +61,8 @@ export function wireManifest(input, o) {
44
61
  }
45
62
  // The extension's own popup: the manifest's, or the one remembered when the manifest already names the Wall's.
46
63
  const declared = typeof manifest.action?.default_popup === 'string' ? manifest.action.default_popup : null;
47
- const appPopup = declared && declared !== WALL_POPUP ? declared : declared === WALL_POPUP ? (o.remembered || null) : null;
64
+ const ownPopup = declared && declared !== WALL_POPUP ? declared : declared === WALL_POPUP ? (o.remembered || null) : null;
65
+ const appPopup = o.ownInFront ? null : ownPopup;
48
66
  if (appPopup && declared !== WALL_POPUP) {
49
67
  manifest.action = { ...manifest.action, default_popup: WALL_POPUP };
50
68
  did.push(`the Wall's popup in front of ${appPopup}: it opens on to yours while the device may, and shows the panel when it may not`);
@@ -55,15 +73,17 @@ export function wireManifest(input, o) {
55
73
  const declaredPanel = typeof manifest.side_panel?.default_path === 'string' ? manifest.side_panel.default_path : null;
56
74
  const declaredSidebar = typeof manifest.sidebar_action?.default_panel === 'string' ? manifest.sidebar_action.default_panel : null;
57
75
  const ours = declaredPanel === WALL_SIDE_PANEL || declaredSidebar === WALL_SIDE_PANEL;
58
- const appSidePanel = theirs(declaredPanel) ?? theirs(declaredSidebar) ?? (ours ? o.rememberedSidePanel || null : null);
59
- if (theirs(declaredPanel)) {
76
+ const ownSidePanel = theirs(declaredPanel) ?? theirs(declaredSidebar) ?? (ours ? o.rememberedSidePanel || null : null);
77
+ const appSidePanel = o.ownInFront ? null : ownSidePanel;
78
+ if (o.ownInFront) { /* the extension's own side panel stays in front, as its popup does */ }
79
+ else if (theirs(declaredPanel)) {
60
80
  manifest.side_panel = { ...manifest.side_panel, default_path: WALL_SIDE_PANEL };
61
81
  if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('sidePanel')))
62
82
  manifest.permissions = add(manifest.permissions, 'sidePanel');
63
83
  did.push(`the Wall's side panel in front of ${declaredPanel}: the same as the popup, at the panel's width`);
64
84
  }
65
85
  // Firefox's sidebar, when it names the same page — one that names another is left alone rather than pointed elsewhere.
66
- if (theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
86
+ if (!o.ownInFront && theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
67
87
  manifest.sidebar_action = { ...manifest.sidebar_action, default_panel: WALL_SIDE_PANEL };
68
88
  did.push(`the Wall's side panel in front of ${declaredSidebar} in Firefox's sidebar_action too`);
69
89
  }
@@ -78,5 +98,5 @@ export function wireManifest(input, o) {
78
98
  else {
79
99
  ownWorker = { file: named, classic: manifest.background?.type !== 'module' };
80
100
  }
81
- return { manifest, did, writeWorker, ownWorker, appPopup, appSidePanel, manifestKey };
101
+ return { manifest, did, writeWorker, ownWorker, appPopup, appSidePanel, manifestKey, ownPopup, ownSidePanel };
82
102
  }
package/lib/mcp.js ADDED
@@ -0,0 +1,236 @@
1
+ // `toolaby mcp`: the Wall for coding agents, as a Model Context Protocol server on stdio.
2
+ //
3
+ // An agent (Claude Code, Cursor, Codex, any MCP client) starts it — `claude mcp add toolaby -- npx -y toolaby mcp` —
4
+ // and gets tools to read and change the developer's tools on the Wall: what the Free plan holds, features, plans,
5
+ // grants, and the docs. It acts with the session `toolaby login` stored, on Test unless started with --live, through
6
+ // the same routes and the same checks as the dashboard.
7
+ //
8
+ // The protocol is the part of MCP a tools server needs, written here: newline-delimited JSON-RPC 2.0 on stdin and
9
+ // stdout — initialize, ping, tools/list, tools/call. The official SDK brings seventeen packages (an HTTP server among
10
+ // them) to a command line that has none and that the Wall also serves as one tarball; this is the stdio transport and
11
+ // the tools capability exactly as the specification words them. Nothing but protocol messages goes to stdout.
12
+
13
+ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
14
+
15
+ /** The tools, with JSON Schema for their arguments, and what each calls on the Wall. */
16
+ function toolsFor(side) {
17
+ const tool = { type: 'string', description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
18
+ return [
19
+ {
20
+ name: 'toolaby_whoami',
21
+ title: 'Who is signed in',
22
+ description: `The developer signed in to the ${side} Wall, and their workspaces.`,
23
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
24
+ run: (_a, api) => api('/api/cli/me'),
25
+ },
26
+ {
27
+ name: 'toolaby_list_tools',
28
+ title: 'List tools',
29
+ description: `Every tool in the developer's workspaces on the ${side} Wall, with the id the other tools take.`,
30
+ inputSchema: { type: 'object', properties: {}, additionalProperties: false },
31
+ run: (_a, api) => api('/api/cli/tools'),
32
+ },
33
+ {
34
+ name: 'toolaby_get_tool',
35
+ title: 'Read a tool',
36
+ description: `One tool on the ${side} Wall: what the Free plan holds (freePlan.holds, in a sentence), its features, its plans with what each unlocks, its tool key, and its extension: identityId is the id of every copy whose manifest has the tool's key (such a copy needs no binding, so boundId stays null), checkedInAt says whether one has checked in, inUse which builds. Then the addresses of its pages.`,
37
+ inputSchema: { type: 'object', properties: { tool }, required: ['tool'], additionalProperties: false },
38
+ run: (a, api) => api(`/api/cli/tools/${encodeURIComponent(a.tool)}`),
39
+ },
40
+ {
41
+ name: 'toolaby_create_tool',
42
+ title: 'Create a tool',
43
+ description: `A new tool on the ${side} Wall. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx toolaby create <id>\` for a new one, \`npx toolaby wire <id>\` for an existing one.`,
44
+ inputSchema: {
45
+ type: 'object',
46
+ properties: {
47
+ name: { type: 'string', description: 'What buyers see, like "Word Counter".' },
48
+ id: { type: 'string', description: 'Optional: the id to use within the workspace, lower-case letters, digits and hyphens. Default: from the name.' },
49
+ tagline: { type: 'string', description: 'Optional: one line under the name on the checkout page.' },
50
+ workspace: { type: 'string', description: 'The workspace slug, when the developer has more than one.' },
51
+ },
52
+ required: ['name'],
53
+ additionalProperties: false,
54
+ },
55
+ run: (a, api) => api('/api/cli/tools', { method: 'POST', body: a }),
56
+ },
57
+ {
58
+ name: 'toolaby_set_access',
59
+ title: 'Set what the Free plan holds',
60
+ description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx toolaby upgrade <id>` then writes the new one.',
61
+ inputSchema: {
62
+ type: 'object',
63
+ properties: {
64
+ tool,
65
+ access: { type: 'string', enum: ['open', 'free_uses', 'paid'] },
66
+ freeUses: { type: 'integer', minimum: 1, maximum: 1000, description: 'With "free_uses": how many per device.' },
67
+ signInRequired: { type: 'boolean' },
68
+ },
69
+ required: ['tool', 'access'],
70
+ additionalProperties: false,
71
+ },
72
+ run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/access`, { method: 'POST', body: rest }),
73
+ },
74
+ {
75
+ name: 'toolaby_set_features',
76
+ title: 'Set features, and the plans that unlock them',
77
+ description: 'The tool\'s whole feature list (it replaces the current one: send the features it has too), and which plans unlock which. A feature\'s key is what the extension passes to gate({ feature }) and has(). A feature marked free is on the Free plan too; one that is not free and that no plan unlocks is refused to everyone. The plans must exist first (toolaby_add_plan); a plan not named in `plans` keeps what it unlocks.',
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ tool,
82
+ features: {
83
+ type: 'array',
84
+ items: {
85
+ type: 'object',
86
+ properties: {
87
+ key: { type: 'string', description: 'Lower-case letters, digits and hyphens, like "export".' },
88
+ name: { type: 'string', description: 'What buyers see, like "Export to CSV".' },
89
+ blurb: { type: 'string', description: 'Optional: one sentence the paywall shows.' },
90
+ free: { type: 'boolean', description: 'On the Free plan too.' },
91
+ },
92
+ required: ['key', 'name'],
93
+ additionalProperties: false,
94
+ },
95
+ },
96
+ plans: {
97
+ type: 'array',
98
+ description: 'Optional: for each plan id (see toolaby_get_tool), the feature keys it unlocks.',
99
+ items: { type: 'object', properties: { id: { type: 'string' }, features: { type: 'array', items: { type: 'string' } } }, required: ['id', 'features'], additionalProperties: false },
100
+ },
101
+ },
102
+ required: ['tool', 'features'],
103
+ additionalProperties: false,
104
+ },
105
+ run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/features`, { method: 'POST', body: rest }),
106
+ },
107
+ {
108
+ name: 'toolaby_add_plan',
109
+ title: 'Add a plan',
110
+ description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx toolaby upgrade <id>\` then writes the new one.`,
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ tool,
115
+ billing: { type: 'string', enum: ['one_time', 'subscription'] },
116
+ interval: { type: 'string', enum: ['month', 'year'], description: 'Subscriptions only.' },
117
+ amount: { type: 'number', minimum: 0.5, description: 'In the currency\'s main unit, like 9.99.' },
118
+ currency: { type: 'string', description: 'Three letters, like "usd" or "eur".' },
119
+ name: { type: 'string', description: 'Optional: the plan\'s name, like "Pro". Two plans with one name unlock the same features.' },
120
+ trialDays: { type: 'integer', minimum: 0, maximum: 365, description: 'Subscriptions only: a free trial.' },
121
+ devices: { type: 'integer', minimum: 1, maximum: 10, description: 'Optional: devices one buyer may use it on at once.' },
122
+ perSeat: { type: 'boolean', description: 'Optional: sold per seat, to teams.' },
123
+ },
124
+ required: ['tool', 'billing', 'amount', 'currency'],
125
+ additionalProperties: false,
126
+ },
127
+ run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/plans`, { method: 'POST', body: rest }),
128
+ },
129
+ {
130
+ name: 'toolaby_grant',
131
+ title: 'Grant access',
132
+ description: 'Access to the tool without paying: to test a paid feature on Test, or for a reviewer. An address with no account gets one; the grant applies once that person signs in to the extension with it (the link arrives by email), so testing a paid feature this way needs a person for a moment. For a number of days, or until revoked. On the Free plan a workspace holds 100 grants and other passes at once.',
133
+ inputSchema: {
134
+ type: 'object',
135
+ properties: { tool, email: { type: 'string' }, days: { type: 'integer', minimum: 1, maximum: 3650, description: 'Optional: how long. Default: until revoked.' } },
136
+ required: ['tool', 'email'],
137
+ additionalProperties: false,
138
+ },
139
+ run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/grants`, { method: 'POST', body: rest }),
140
+ },
141
+ {
142
+ name: 'toolaby_docs',
143
+ title: 'Read the docs',
144
+ description: 'The Wall\'s docs as Markdown. Without a page: the index, every page with what it covers. With a page (like "gating", "pricing", "reference/client"): that page.',
145
+ inputSchema: { type: 'object', properties: { page: { type: 'string' } }, additionalProperties: false },
146
+ run: (a, _api, docs) => docs(a.page),
147
+ },
148
+ ];
149
+ }
150
+
151
+ /** What a call answers, as MCP content: the Wall's JSON as text, and as structured content. */
152
+ function resultOf(res) {
153
+ const body = res.json ?? {};
154
+ if (res.ok) return { content: [{ type: 'text', text: typeof res.text === 'string' ? res.text : JSON.stringify(body, null, 2) }], ...(typeof res.text === 'string' ? {} : { structuredContent: body }) };
155
+ const why = body.message ?? body.error ?? `The Wall answered HTTP ${res.status}.`;
156
+ return { content: [{ type: 'text', text: why }], isError: true };
157
+ }
158
+
159
+ /**
160
+ * Runs the server until stdin closes. `api(path, { method, body })` answers `{ ok, status, json }` with the stored
161
+ * session; `token()` says whether there is one.
162
+ */
163
+ export function runMcp({ version, side, wall, api, token }) {
164
+ const tools = toolsFor(side);
165
+ const send = (msg) => process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...msg })}\n`);
166
+ const log = (s) => process.stderr.write(`[toolaby mcp] ${s}\n`);
167
+ const docs = async (page) => {
168
+ const clean = typeof page === 'string' ? page.replace(/^\/+|\.md$|\/+$/g, '').replace(/^docs\//, '') : '';
169
+ if (clean && !/^[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(clean)) return { ok: false, status: 400, json: { message: 'A page is a path like "gating" or "reference/client".' } };
170
+ const url = clean ? `${wall}/docs/${clean}.md` : `${wall}/llms.txt`;
171
+ const res = await fetch(url).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
172
+ const text = await res.text();
173
+ return res.ok ? { ok: true, status: res.status, text } : { ok: false, status: res.status, json: { message: res.status === 404 ? `No docs page "${clean}". Call toolaby_docs without a page for the index.` : `The docs answered HTTP ${res.status}.` } };
174
+ };
175
+ const call = async (name, args) => {
176
+ const t = tools.find((x) => x.name === name);
177
+ if (!t) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
178
+ if (name !== 'toolaby_docs' && !token()) {
179
+ return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx toolaby login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
180
+ }
181
+ try {
182
+ return { result: resultOf(await t.run(args ?? {}, api, docs)) };
183
+ } catch (e) {
184
+ return { result: { content: [{ type: 'text', text: e instanceof Error ? e.message : String(e) }], isError: true } };
185
+ }
186
+ };
187
+ const handle = async (msg) => {
188
+ if (!msg || msg.jsonrpc !== '2.0' || typeof msg.method !== 'string') {
189
+ if (msg && 'id' in msg && !('method' in msg)) return; // a response to nothing we asked: ignored
190
+ return send({ id: msg?.id ?? null, error: { code: -32600, message: 'Invalid request' } });
191
+ }
192
+ const isRequest = 'id' in msg;
193
+ switch (msg.method) {
194
+ case 'initialize': {
195
+ const asked = msg.params?.protocolVersion;
196
+ return send({ id: msg.id, result: {
197
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
198
+ capabilities: { tools: { listChanged: false } },
199
+ serverInfo: { name: 'toolaby', title: 'Toolaby Wall', version },
200
+ instructions: `The Toolaby Wall (${side}, ${wall}): accounts, licences, subscriptions and the paywall for Chrome extensions. Read a tool with toolaby_get_tool before changing it; the extension gates paid work with toolaby.gate({ feature }) and each feature key must exist on the tool (toolaby_set_features). Wire files with the shell: npx toolaby create|wire|upgrade <id>, check with npx toolaby check. toolaby_docs reads the docs.`,
201
+ } });
202
+ }
203
+ case 'notifications/initialized':
204
+ case 'notifications/cancelled':
205
+ return;
206
+ case 'ping':
207
+ return isRequest ? send({ id: msg.id, result: {} }) : undefined;
208
+ case 'tools/list':
209
+ return send({ id: msg.id, result: { tools: tools.map(({ run: _run, ...t }) => t) } });
210
+ case 'tools/call': {
211
+ const out = await call(msg.params?.name, msg.params?.arguments);
212
+ return send({ id: msg.id, ...out });
213
+ }
214
+ default:
215
+ return isRequest ? send({ id: msg.id, error: { code: -32601, message: `Method not found: ${msg.method}` } }) : undefined;
216
+ }
217
+ };
218
+
219
+ log(`${side} · ${wall} · ${token() ? 'signed in' : 'not signed in: tools ask for `npx toolaby login`'}`);
220
+ let buffer = '';
221
+ process.stdin.setEncoding('utf8');
222
+ process.stdin.on('data', (chunk) => {
223
+ buffer += chunk;
224
+ let at;
225
+ while ((at = buffer.indexOf('\n')) >= 0) {
226
+ const line = buffer.slice(0, at).trim();
227
+ buffer = buffer.slice(at + 1);
228
+ if (!line) continue;
229
+ let msg;
230
+ try { msg = JSON.parse(line); } catch { send({ id: null, error: { code: -32700, message: 'Parse error' } }); continue; }
231
+ // A batch (the 2025-03-26 revision allowed them): each message on its own.
232
+ for (const m of Array.isArray(msg) ? msg : [msg]) handle(m).catch((e) => log(e instanceof Error ? e.stack ?? e.message : String(e)));
233
+ }
234
+ });
235
+ process.stdin.on('end', () => process.exit(0));
236
+ }
package/lib/project.js CHANGED
@@ -9,15 +9,27 @@ export const MANIFEST_HELPER = ['toolaby.manifest.js', 'toolaby.manifest.d.ts'];
9
9
  export const POPUP_FILES = ['toolaby-popup.html', 'toolaby-popup.js'];
10
10
  /** The Wall's side panel: the same page at the panel's width, the same two files. */
11
11
  export const SIDE_PANEL_FILES = ['toolaby-sidepanel.html', 'toolaby-sidepanel.js'];
12
+ /** What a coding agent reads, at the project's root: the guide to the wiring, and the skill where Claude Code finds skills (wiring/agents). Rewritten by every wiring. */
13
+ export const AGENT_FILES = [
14
+ { name: 'TOOLABY.md', path: 'TOOLABY.md' },
15
+ { name: 'SKILL.md', path: '.claude/skills/toolaby/SKILL.md' },
16
+ ];
17
+ /** Written only where the project has none: AGENTS.md, which every coding agent reads, and CLAUDE.md, which reads it for Claude Code. */
18
+ export const AGENT_ENTRY_FILES = ['AGENTS.md', 'CLAUDE.md'];
12
19
  /** What earlier wirings wrote and this one does not: an upgrade takes them away, wherever a wiring put them. */
13
20
  export const STALE_FILES = ['toolaby-client.module.js', 'paywall.js', 'paywall.css', 'toolaby-popup.css', 'toolaby-account.js'];
14
- /** Every file a wiring writes, with its path: the code files, and — with a popup or a side panel to stand in front of — the Wall's shell for it. */
15
- export const filesToWrite = (p, appPopup, appSidePanel = null) => [
21
+ /**
22
+ * Every file a wiring writes, with its path: the code files, and — for a popup or a side panel — the Wall's page for it:
23
+ * in front of the extension's (`appPopup`), or beside it when the extension's own page draws the panel (`own`), where
24
+ * showPaywall() opens it.
25
+ */
26
+ export const filesToWrite = (p, appPopup, appSidePanel = null, own = {}) => [
16
27
  ...CODE_FILES.map((f) => `${p.codeDir}${f}`),
17
28
  ...(p.manifestFile ? [] : MANIFEST_HELPER.map((f) => `${p.codeDir}${f}`)),
18
- ...(appPopup ? POPUP_FILES.map((f) => `${p.popupDir}${f}`) : []),
19
- ...(appSidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
29
+ ...(appPopup || own.popup ? POPUP_FILES.map((f) => `${p.popupDir}${f}`) : []),
30
+ ...(appSidePanel || own.sidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
20
31
  ...(p.extraFiles ?? []).map((f) => f.to),
32
+ ...AGENT_FILES.map((f) => f.path),
21
33
  ];
22
34
  export const SURFACES = ['both', 'popup', 'side-panel', 'none'];
23
35
  export const surfacesOf = (v) => SURFACES.includes(v ?? '') ? v : 'both';
@@ -34,6 +46,8 @@ export const scaffoldFilesFor = (s, all) => all.filter((f) => (f === 'popup.html
34
46
  */
35
47
  export function scaffoldManifest(manifest, wrote) {
36
48
  const out = { ...manifest };
49
+ if (wrote.key && typeof out.key !== 'string')
50
+ out.key = wrote.key;
37
51
  if (wrote.popup === false) {
38
52
  const action = { ...out.action };
39
53
  delete action.default_popup;
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,7 +1,7 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.5.0",
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.",
3
+ "version": "1.6.1",
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": {
7
7
  "toolaby": "bin.mjs"
@@ -11,6 +11,9 @@
11
11
  "lib",
12
12
  "README.md"
13
13
  ],
14
+ "scripts": {
15
+ "test": "node --test"
16
+ },
14
17
  "engines": {
15
18
  "node": ">=18"
16
19
  },