toolaby 1.6.3 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,13 +20,16 @@ Every command acts on the test Wall (`test.toolaby.app`) unless told `--live` (`
20
20
 
21
21
  - it adds the Wall's files — a small client library, a config with your tool's id and public key, and the Wall's popup, which stands in front of yours;
22
22
  - it adds three lines to your `manifest.json` (a permission and your Wall address) and creates a background file if you have none;
23
+ - it puts the Wall's background in front of yours, `toolaby.background.js`: yours runs as it did — a classic one's `importScripts()` included — and the Wall starts with it;
23
24
  - your own code does not change. When your popup opens, the Wall's page checks what the Free plan holds and either sends the person straight to your popup or shows sign-in, the free-use meter or the plans.
24
25
 
26
+ It prints what it did in a few rows — `✓` done, `!` worth a look, `→` a step for you, with the exact code — and what to do next, in colour in a terminal (`NO_COLOR` turns it off). When your code sets or reads the popup itself (`chrome.action.setPopup()`, `getPopup()`), it says where; `--own-popup` keeps your popup in front.
27
+
25
28
  If the extension is built with WXT, CRXJS or Plasmo, the command puts the same files where that build expects them and prints the few lines to paste into the framework's config, because the framework generates the manifest itself. Nothing is installed from npm.
26
29
 
27
30
  **`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
28
31
 
29
- **`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. A store zip is public, so files that look private (`*.pem`, `*.key`, `*.p12` and other keys, `id_rsa` and the other SSH keys, certificates, `*.env`, `credentials.json`, a service account's key such as `firebase-adminsdk-….json`, `_netrc`, source maps) are left out and named, and so is a file of any name that holds a private key or a live secret (a Stripe live key, a webhook secret, an AWS access key, a GitHub, Slack or npm token, a password in an address). A build copies what a link in `public/` points at, so `pack` also leaves out a file with the same bytes as one of the project's private files or the command's own session, a folder that is a git repository's own, and a file named as an image that holds none, and it names the links in `public/`. `--allow-private` packs what it left out. Dotfiles and links are never packed. Nothing is sent anywhere.
32
+ **`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. A store zip is public, so files that look private (`*.pem`, `*.key`, `*.p12` and other keys, `id_rsa` and the other SSH keys, certificates, `*.env`, `credentials.json`, a service account's key such as `firebase-adminsdk-….json`, `_netrc`, source maps) are left out and named, and so is a file of any name that holds a private key or a secret (a Stripe key, live or test, a webhook secret, an AWS access key, long-term or temporary, or a secret access key, an OpenAI or Anthropic key, a Google OAuth client secret, a GitHub, Slack or npm token, a PuTTY key, a Supabase `service_role` key, a password in an address), read to its end, and in UTF-16 or base64 too. A Google API key is made to be in a browser: its file is packed and named, for you to restrict the key in Google Cloud. A build copies what a link in `public/` points at, so `pack` also leaves out a file with the same bytes or text as one of the project's private files or the command's own session, in UTF-16 or base64 as well, a folder that is a git repository's own, git's history, index, refs, logs and config under any name, and a file named as an image that holds none, and it names the links in `public/`. A `.gz` or `.br` a build's compression plugin writes is judged by what it holds. It refuses a build when a file the manifest names would be left out, or when a public folder is itself a link into `.git`. `--allow-private` packs what it left out. Dotfiles and links are never packed. Nothing is sent anywhere.
30
33
 
31
34
  **`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.
32
35
 
package/bin.mjs CHANGED
@@ -32,18 +32,19 @@
32
32
  // platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
33
33
  // so this and the dashboard's Wire button never disagree.
34
34
  import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, chmodSync, unlinkSync, renameSync } from 'node:fs';
35
- import { join, resolve, dirname, relative, basename, sep } from 'node:path';
35
+ import { join, resolve, dirname, relative, basename, sep, posix } from 'node:path';
36
36
  import { deflateRawSync } from 'node:zlib';
37
37
  import { homedir } from 'node:os';
38
38
  import { spawn } from 'node:child_process';
39
39
  import { createHmac, randomBytes } from 'node:crypto';
40
- import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
40
+ import { wireManifest, drawsItself, WORKER_LINES, wallWorkerSource, configValues, wrappedBy, namedWorker as workerIn, startsPlatform as startsWall, CLASSIC_CLIENT } from './lib/manifest.js';
41
+ import { wiringSummary, popupSwapIn, ownCode } from './lib/summary.js';
41
42
  import { detectProject, CODE_FILES, POPUP_FILES, SIDE_PANEL_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE, AGENT_FILES, AGENT_ENTRY_FILES } from './lib/project.js';
42
43
  import { runMcp } from './lib/mcp.js';
43
44
  import { runCheck } from './lib/check.js';
44
- import { inside, signInPage, opener, privateKind, privateContent, privateCopies, projectRoot, gitDirectory, notAnImage, publicLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
45
+ import { inside, signInPage, devicePolling, opener, privateKind, privateContent, privateCopies, googleKey, projectRoot, gitDirectory, gitObject, gitObjects, gitFile, notAnImage, unpacked, COMPRESSED, publicLinks, privateLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
45
46
 
46
- const VERSION = '1.6.3';
47
+ const VERSION = '1.7.0';
47
48
  const CLIENT_ID = 'toolaby-cli';
48
49
  const argv = process.argv.slice(2);
49
50
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -66,7 +67,13 @@ const side = wall === WALLS.live ? 'Live' : wall === WALLS.test ? 'Test' : wall;
66
67
  // nor any character that shows nothing — a zero width, a mark of direction, a filler — which could hide a word.
67
68
  const plain = (s) => String(s).replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f\u061c\u115f\u1160\u200b-\u200f\u2028-\u202e\u2060-\u2069\u2800\u3164\ufeff\uffa0]/g, '');
68
69
  const out = (s = '') => console.log(plain(s));
69
- const fail = (s) => { console.error(`\n ${plain(s)}\n`); process.exit(1); };
70
+ // Colour, the command's own: put on after a piece is made plain, never taken from what it prints; off when the output is
71
+ // not a terminal, and when NO_COLOR asks (FORCE_COLOR turns it on). A line of tinted pieces goes out through `say`.
72
+ const colour = !process.env.NO_COLOR && (process.env.FORCE_COLOR ? process.env.FORCE_COLOR !== '0' : !!process.stdout.isTTY && process.env.TERM !== 'dumb');
73
+ const tint = (code) => (s) => (colour ? `\x1b[${code}m${plain(s)}\x1b[0m` : plain(s));
74
+ const [green, yellow, red, cyan, bold, dim] = ['32', '33', '31', '36', '1', '2'].map(tint);
75
+ const say = (s = '') => console.log(s);
76
+ const fail = (s) => { console.error(`\n ${red('✗')} ${plain(s)}\n`); process.exit(1); };
70
77
 
71
78
  // ── The session: one per Wall, in the user's config, readable by them alone ─────────────────────────────────────────
72
79
  const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'toolaby');
@@ -90,14 +97,20 @@ const envElsewhere = () => (process.env.TOOLABY_TOKEN && !envRefused && !envToke
90
97
 
91
98
  /** The most of one answer the command reads from a Wall: none of the Wall's comes near it (the largest, a wiring, is under 1 MB). */
92
99
  const ANSWER_LIMIT = 4 * 1024 * 1024;
100
+ /** How long it waits for one, the whole of it: none of the Wall's takes more than a few seconds. */
101
+ const ANSWER_TIME = 30_000;
93
102
 
94
103
  async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
104
+ // The time ran out (fetchHere's deadline; AbortError where an older Node says so): one line, whichever part was late.
105
+ // A Wall that answered a byte at a time held `login` and `wire` for ever (round 8, 25 Sep 2026).
106
+ const late = (e) => (e?.name === 'TimeoutError' || e?.name === 'AbortError' ? new Error(`The Wall at ${wall} did not answer ${path.split('?')[0]} within ${ANSWER_TIME / 1000} seconds.`) : null);
95
107
  // Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
96
108
  const res = await fetchHere(`${wall}${path}`, {
109
+ ms: ANSWER_TIME,
97
110
  method,
98
111
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
99
112
  body: body ? JSON.stringify(body) : undefined,
100
- }).catch((e) => { throw new Error(`Could not reach ${wall}: ${e.message}`); });
113
+ }).catch((e) => { throw late(e) ?? new Error(`Could not reach ${wall}: ${e.message}`); });
101
114
  if (res.status === 401 && token && token === envToken()) {
102
115
  envRefused = true;
103
116
  const stored = storedToken();
@@ -105,8 +118,8 @@ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
105
118
  }
106
119
  // Read up to a few megabytes and no further: a Wall at another address (--wall, TOOLABY_WALL) that answered without
107
120
  // end filled the command's memory, and the MCP server's, which hands what it reads to an agent (25 Sep 2026).
108
- // An answer cut short reads as no JSON, as it always has; only one past the limit stops the command.
109
- const bytes = await readBounded(res, ANSWER_LIMIT).catch(() => Buffer.alloc(0));
121
+ // An answer cut short reads as no JSON, as it always has; only one past the limit, or the time, stops the command.
122
+ const bytes = await readBounded(res, ANSWER_LIMIT).catch((e) => { const why = late(e); if (why) throw why; return Buffer.alloc(0); });
110
123
  if (!bytes) throw new Error(`The Wall at ${wall} answered ${path.split('?')[0]} with more than ${ANSWER_LIMIT / 1024 / 1024} MB, which no answer of a Wall's is. None of it was used.`);
111
124
  let json = {};
112
125
  try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
@@ -124,19 +137,22 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
124
137
  async function login() {
125
138
  const start = await api('/api/dev-auth/device/code', { method: 'POST', body: { client_id: CLIENT_ID }, token: null });
126
139
  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.`);
127
- const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
140
+ const { device_code, user_code, verification_uri, verification_uri_complete } = start.json;
128
141
  const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
129
142
  // The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
130
143
  // Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
131
144
  const page = signInPage(link, wall);
132
145
  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.`);
133
- out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
146
+ // How often it asks and for how long: what the Wall said, a quarter of an hour at most (lib/safe.js).
147
+ const { every: first, lasts } = devicePolling(start.json);
148
+ out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(lasts / 60_000)} minutes)\n`);
134
149
  openBrowser(page);
135
- let every = Math.max(1, Number(interval) || 5) * 1000;
136
- const until = Date.now() + Number(expires_in) * 1000;
150
+ let every = first;
151
+ const until = Date.now() + lasts;
137
152
  process.stdout.write(' Waiting for your approval');
138
153
  while (Date.now() < until) {
139
- await sleep(every);
154
+ // Never past the code's end: the last ask comes as it ends.
155
+ await sleep(Math.min(every, until - Date.now()));
140
156
  process.stdout.write('.');
141
157
  const poll = await api('/api/dev-auth/device/token', { method: 'POST', body: { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLIENT_ID }, token: null });
142
158
  if (poll.ok && poll.json.access_token) {
@@ -215,7 +231,7 @@ const readJson = (path) => { try { return JSON.parse(readText(path)); } catch {
215
231
  /** A project file's text, or `none` where it is no file to read (lib/safe.js readText): a link to a device reads as nothing. */
216
232
  const readOr = (path, none = null) => { try { return readText(path); } catch { return none; } };
217
233
  /** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
218
- const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readText(path).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
234
+ const startsPlatform = (path) => { try { return startsWall(readText(path)); } catch { return false; } };
219
235
  const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
220
236
  /** Whether anything is at the path, a link to nowhere included (existsSync follows a link, and says no for a dangling one). */
221
237
  const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
@@ -281,9 +297,13 @@ async function wire() {
281
297
  const oldConfig = existsSync(configPath) ? readOr(configPath, '') : '';
282
298
  const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
283
299
  const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
284
- // An extension whose own pages draw the panel — what `create` makes, or "your popup first" — keeps them in front.
285
- const ownInFront = drawsItself(manifest, oldConfig);
286
- const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, rememberedSidePanel, manifestKey, ownInFront }) : null;
300
+ // An extension whose own pages draw the panel — what `create` makes, "your popup first", or asked with --own-popup —
301
+ // keeps them in front.
302
+ const ownInFront = flag('--own-popup') || drawsItself(manifest, oldConfig);
303
+ // The background the manifest names, read from inside the folder only: whether it starts the Wall, or is the Wall's.
304
+ const namedWorker = workerIn(manifest) ?? '';
305
+ const workerSource = namedWorker && has(namedWorker) && !inside(dir, namedWorker).why ? readOr(join(dir, namedWorker), '') : null;
306
+ const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, rememberedSidePanel, manifestKey, ownInFront, workerSource }) : null;
287
307
  const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
288
308
  const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
289
309
  const targets = filesToWrite(project, appPopup, appSidePanel, { popup: !!wiring?.ownPopup, sidePanel: !!wiring?.ownSidePanel });
@@ -292,8 +312,13 @@ async function wire() {
292
312
  // What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
293
313
  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)));
294
314
  // 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.
295
- const worker = wiring ? wiring.writeWorker : project.createWorker?.file ?? null;
296
- const at = placesIn(dir, [...targets, ...entries, ...stale, ...(worker ? [worker] : []), ...(wiring?.did.length ? ['manifest.json'] : [])], worker ? { [worker]: 'the background the manifest names' } : {});
315
+ const worker = wiring ? (wiring.background.kind === 'new' ? wiring.background.file : null) : project.createWorker?.file ?? null;
316
+ // The extension's own background, with the Wall's in front of it — and, for a classic one, the client as a script.
317
+ const wrap = wiring?.background.kind === 'wrap' ? wiring.background : null;
318
+ const classicClient = wrap?.classic ? `${project.codeDir}${CLASSIC_CLIENT}` : null;
319
+ const at = placesIn(dir, [...targets, ...entries, ...stale, ...(worker ? [worker] : []), ...(wrap ? [wrap.file] : []), ...(classicClient ? [classicClient] : []), ...(wiring?.changed ? ['manifest.json'] : [])], { ...(worker ? { [worker]: 'the background the manifest names' } : {}), ...(wrap ? { [wrap.file]: "the Wall's background, beside yours" } : {}) });
320
+ // A file of the developer's own where the Wall's background goes is never replaced: only one the Wall wrote.
321
+ if (wrap && wrap.file !== namedWorker && there(at[wrap.file]) && !wrappedBy(readOr(at[wrap.file], ''))) fail(`${wrap.file} is already in ${dir}, and it is not the Wall's. Rename it, then run this again. Nothing was written.`);
297
322
  // Taken for missing, it may only be out of the listing's sight — spelt "./background.js", in dist/, or deeper down:
298
323
  // the developer's own background is never replaced by two lines of ours.
299
324
  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.`);
@@ -303,41 +328,40 @@ async function wire() {
303
328
  // 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.
304
329
  // The agent files come from Walls that write them; one that does not is older, not newer, than this command.
305
330
  const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
306
- const missing = targets.filter((p) => !agentPaths.has(p)).map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
331
+ const missing = [...targets, ...(classicClient ? [classicClient] : [])].filter((p) => !agentPaths.has(p)).map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
307
332
  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}`);
308
333
  const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
309
334
  for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
310
335
  for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
311
336
  const ownAgents = has('AGENTS.md') && !readOr(join(dir, 'AGENTS.md'), '').includes('TOOLABY.md');
312
337
  for (const p of stale) unlinkSync(at[p]);
313
- const did = [];
314
- let ownWorker = null;
315
338
  if (wiring) {
316
- if (wiring.writeWorker) write(wiring.writeWorker, WORKER_LINES);
317
- if (wiring.did.length) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
318
- did.push(...wiring.did);
319
- if (wiring.ownWorker && !startsPlatform(join(dir, wiring.ownWorker.file))) ownWorker = wiring.ownWorker;
339
+ if (wiring.background.kind === 'new') write(wiring.background.file, WORKER_LINES);
340
+ // The Wall's background in front of theirs, with what toolaby.config.js starts the Wall with (a classic one carries it).
341
+ if (wrap) write(wrap.file, wallWorkerSource(wrap, wrap.classic ? configValues(fillPages(plan.files['toolaby.config.js'], { appPopup, appSidePanel })) : null));
342
+ if (classicClient) write(classicClient, plan.files[CLASSIC_CLIENT]);
343
+ if (wiring.changed) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
320
344
  } else if (project.createWorker) {
321
345
  write(project.createWorker.file, project.createWorker.content);
322
- did.push(`a background entry, ${project.createWorker.file} (it starts the platform)`);
323
- } else if (project.worker && !project.extraFiles && !startsPlatform(join(dir, project.worker))) {
324
- // A framework with its own extension point (WXT's module) starts the platform in the background itself.
325
- ownWorker = { file: project.worker, classic: false };
326
346
  }
327
-
328
- out(`\n ${oneLine(plan.name)} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
329
- // Each thing done is a line, and names what the manifest and the Wall named: the popup, the background, the address.
330
- out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.map(oneLine).join(',\n ')}` : ''}.\n`);
331
- 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');
332
- if (stale.length) out(` Removed: ${stale.join(', ')} — earlier files, now inside toolaby.js.\n`);
333
- 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`);
334
- if (ownWorker) out(` Your background, ${oneLine(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`);
335
- if (project.built) out(` ${BUILD_COPY_NOTE}\n`);
336
- 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.`);
337
- if (project.note) out(` ${project.note}\n`);
338
- 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`);
339
- 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`);
340
- 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`);
347
+ // The extension's own code, read for a popup it swaps while it runs: the Wall's in front of it could be skipped then.
348
+ const code = appPopup ? [...tree].filter(ownCode).slice(0, 400).map((path) => { let text = ''; try { if (statSync(join(dir, path)).size < 512_000) text = readOr(join(dir, path), ''); } catch { /* gone */ } return { path, text }; }) : [];
349
+ const summary = wiringSummary({ project, wiring, appPopup, appSidePanel, workerStarts: !!project.worker && startsPlatform(join(dir, project.worker)), popupSwap: popupSwapIn(code) });
350
+
351
+ // Wired again, a folder takes `upgrade` (wire, replacing the platform's files): the command, ready to paste.
352
+ const into = opt('--into');
353
+ const again = `npx -y toolaby@latest upgrade ${slug}${live ? ' --live' : ''}${into ? ` --into ${/^[\w@%+=:,./-]+$/.test(into) ? into : `'${into.replace(/'/g, "'\\''")}'`}` : ''}`;
354
+ say(`\n ${summary.todo ? `${yellow('!')} ${bold('Almost wired')}` : `${green('✓')} ${bold('Wired')}`} ${plain(oneLine(plan.name))} → ${plain(basename(dir))} ${dim(`· ${side}${project.kind === 'plain' ? '' : ` · ${project.label}`}`)}\n`);
355
+ const width = Math.max(...summary.rows.map((r) => r.label.length));
356
+ for (const r of summary.rows) {
357
+ const mark = r.tone === 'ok' ? green('✓') : r.tone === 'warn' ? yellow('!') : cyan('→');
358
+ say(` ${mark} ${bold(r.label.padEnd(width))} ${plain(oneLine(r.text))}`);
359
+ if (r.fix === 'own-popup') say(` ${' '.repeat(width)} ${cyan(`${again} --own-popup`)}`);
360
+ if (r.code) say(`\n${r.code.split('\n').map((l) => ` ${cyan(l)}`).join('\n')}\n`);
361
+ }
362
+ if (ownAgents) say(` ${dim('Your AGENTS.md is your own: add "Before changing anything paid, read TOOLABY.md."')}`);
363
+ if (stale.length) say(` ${dim(`Removed: ${stale.join(', ')} (now inside toolaby.js)`)}`);
364
+ say(`\n ${bold('Next')} ${summary.rows.some((r) => r.again) ? `${plain(summary.next.replace(/wire again\.$/, 'run'))} ${cyan(again)}` : plain(summary.next)}\n`);
341
365
  }
342
366
 
343
367
  async function create() {
@@ -496,10 +520,52 @@ async function webhooks() {
496
520
  // admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
497
521
  // or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
498
522
  // can ship with. What looks private — a key, an environment file, a source map — is left out and named, and so is a
499
- // link out of the folder: a store zip is public. Nothing is sent anywhere: it reads the folder and writes one file.
523
+ // link out of the folder: a store zip is public. A build it would leave a file of the manifest's out of, which Chrome
524
+ // would not load, is refused, as is one whose public folder is a link into .git (round 9, 25 Sep 2026). Nothing is
525
+ // sent anywhere: it reads the folder and writes one file.
500
526
  const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
501
527
  const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
502
528
  const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
529
+ /** Why a file is left out as no file of the folder's own, not as a private one: never packed, --allow-private or not. */
530
+ const NOT_OWN = ['a link', 'not a file', 'a name no zip should hold'];
531
+
532
+ /**
533
+ * The files a manifest names, each with what it is to the extension, as the zip spells them: no leading ./ or /, no
534
+ * query or fragment. A pattern (a web-accessible resource's `*`) names no one file, and an address none at all.
535
+ */
536
+ function namedFiles(manifest) {
537
+ const named = [];
538
+ const add = (path, what) => {
539
+ if (typeof path !== 'string' || path.includes('*') || /^[a-z][a-z0-9+.-]*:/i.test(path)) return;
540
+ const clean = posix.normalize(path.split(/[?#]/)[0].replace(/^(?:\.?\/)+/, ''));
541
+ if (clean && clean !== '.' && clean !== '..' && !clean.startsWith('../')) named.push([clean, what]);
542
+ };
543
+ const list = (value) => (Array.isArray(value) ? value : []);
544
+ const icons = (value) => { for (const path of value && typeof value === 'object' ? Object.values(value) : [value]) add(path, 'an icon'); };
545
+ add('manifest.json', 'the manifest');
546
+ add(manifest.background?.service_worker, 'the background service worker');
547
+ for (const path of list(manifest.background?.scripts)) add(path, 'a background script');
548
+ add(manifest.background?.page, 'the background page');
549
+ for (const script of list(manifest.content_scripts)) {
550
+ for (const path of list(script?.js)) add(path, 'a content script');
551
+ for (const path of list(script?.css)) add(path, "a content script's style sheet");
552
+ }
553
+ for (const surface of ['action', 'browser_action', 'page_action']) { add(manifest[surface]?.default_popup, 'the popup'); icons(manifest[surface]?.default_icon); }
554
+ add(manifest.side_panel?.default_path, 'the side panel');
555
+ add(manifest.sidebar_action?.default_panel, 'the side panel');
556
+ icons(manifest.sidebar_action?.default_icon);
557
+ add(manifest.options_page, 'the options page');
558
+ add(manifest.options_ui?.page, 'the options page');
559
+ add(manifest.devtools_page, 'the DevTools page');
560
+ if (manifest.chrome_url_overrides && typeof manifest.chrome_url_overrides === 'object') for (const path of Object.values(manifest.chrome_url_overrides)) add(path, 'a page it replaces');
561
+ for (const path of list(manifest.sandbox?.pages)) add(path, 'a sandboxed page');
562
+ icons(manifest.icons);
563
+ for (const entry of list(manifest.web_accessible_resources)) for (const path of typeof entry === 'string' ? [entry] : list(entry?.resources)) add(path, 'a web-accessible resource');
564
+ for (const rules of list(manifest.declarative_net_request?.rule_resources)) add(rules?.path, 'a rule set');
565
+ add(manifest.storage?.managed_schema, 'its managed storage schema');
566
+ if (typeof manifest.default_locale === 'string') add(`_locales/${manifest.default_locale}/messages.json`, 'its default locale');
567
+ return named;
568
+ }
503
569
 
504
570
  const CRC_TABLE = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0; });
505
571
  const crc32 = (buf) => { let c = 0xffffffff; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
@@ -542,10 +608,18 @@ function zipOf(entries) {
542
608
  * A build has already followed the links of the project it came from: what they pointed at is an ordinary file here.
543
609
  * So a file is also private when it is a copy of one of the project's (`copyOf`), a folder when it is a git
544
610
  * repository's own, and a file named as an image when it holds none (round 7, 25 Sep 2026).
611
+ *
612
+ * And a compressed file is what it holds (round 8, 25 Sep 2026): a build's compression plugin writes data.json.gz and
613
+ * data.json.br beside data.json, and `pack` left data.json out as private while it packed both. One beside a file left
614
+ * out goes with it, however large; any other is opened, a few MB of it at most (lib/safe.js), and judged as the file.
615
+ *
616
+ * And git's other files are git's, whatever their names — its index, its refs, its logs, a config (round 9, 25 Sep 2026).
545
617
  */
546
- function filesIn(root, copyOf = () => null) {
618
+ function filesIn(root, copyOf = () => null, fromLink = () => null) {
547
619
  const found = []; const left = []; const privately = [];
548
620
  const allowed = flag('--allow-private');
621
+ const judge = (name, bytes) => privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? gitObject(bytes) ?? gitFile(bytes) ?? notAnImage(name, bytes);
622
+ const judged = new Map(); // what each file found private is, by its path: a compressed one beside it is the same
549
623
  const walk = (dir) => {
550
624
  for (const name of readdirSync(dir).sort()) {
551
625
  if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
@@ -554,15 +628,27 @@ function filesIn(root, copyOf = () => null) {
554
628
  const st = lstatSync(p);
555
629
  if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
556
630
  if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
631
+ // What a build copied from a link into .git, or to a private file, left out whole, as the link's (round 8).
632
+ const via = fromLink(rel);
633
+ if (via) { judged.set(rel, via); if (!allowed) { left.push([rel, via]); continue; } privately.push([rel, via]); }
557
634
  if (st.isDirectory()) {
558
635
  // Left out whole, unwalked: its config and its history are the repository's, whatever the folder is called.
559
- if (gitDirectory(p)) { if (!allowed) { left.push([rel, 'a git directory']); continue; } privately.push([rel, 'a git directory']); }
636
+ const git = gitDirectory(p) ? 'a git directory' : gitObjects(p) ? 'git history' : null;
637
+ if (git && !via) { if (!allowed) { left.push([rel, git]); continue; } privately.push([rel, git]); }
560
638
  walk(p);
561
639
  continue;
562
640
  }
563
641
  if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
564
642
  const bytes = readFileSync(p);
565
- const why = privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? notAnImage(name, bytes);
643
+ let why = judge(name, bytes);
644
+ if (!why) {
645
+ // Sorted, data.json comes before data.json.br and data.json.gz: its verdict is known when theirs is asked.
646
+ const twin = COMPRESSED.test(name) ? judged.get(rel.replace(COMPRESSED, '')) : null;
647
+ const inner = twin ? null : unpacked(name, bytes);
648
+ const held = twin ?? (inner && judge(inner.name, inner.bytes));
649
+ if (held) why = `${held}, compressed`;
650
+ }
651
+ if (why) judged.set(rel, why);
566
652
  if (why && !allowed) left.push([rel, why]);
567
653
  else { found.push(p); if (why) privately.push([rel, why]); }
568
654
  }
@@ -588,8 +674,38 @@ function pack() {
588
674
  // The project the build came from — or, with none above it, the folder alone — and the command's own secrets: what a
589
675
  // build could have copied in under another name, by following a link (lib/safe.js).
590
676
  const project = projectRoot(folder);
677
+ const allowed = flag('--allow-private');
591
678
  const copyOf = privateCopies(project ?? folder, [[CREDENTIALS, `${fromHome(CREDENTIALS)}, this command's session`], [DEV_SECRET_FILE, `${fromHome(DEV_SECRET_FILE)}, this machine's webhook secret`]]);
592
- const { found: files, left, privately } = filesIn(folder, copyOf);
679
+ // What a link in the project's public folders put into the build, when it points into .git or at a private file.
680
+ const leaking = project ? privateLinks(project, folder) : [];
681
+ // A public folder that is itself such a link, or a build whose top is a git repository's own folder, is refused: the
682
+ // build copied what the link points at to the top of its output, among the extension's own files, and nothing tells
683
+ // them apart — `public -> .git` put the repository's index, its logs and its last commit message in the zip (round 9,
684
+ // 25 Sep 2026).
685
+ const whole = leaking.filter((l) => !l.within);
686
+ if (whole.length && !allowed) fail(`Not packed: ${whole.map((l) => `${oneLine(l.link)} is ${l.why}`).join(', ')}. A build copies what a public folder links to, whole, to the top of its output.\n Make ${whole.length > 1 ? 'each' : 'it'} a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
687
+ if (gitDirectory(folder) && !allowed) fail(`Not packed: ${oneLine(relative(process.cwd(), folder) || '.')} is a git repository's own folder (HEAD, config and its history), as a build leaves when its public folder is a link to .git.\n Make the public folder a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
688
+ const fromLink = (rel) => { const hit = leaking.find((l) => l.within && (rel === l.within || rel.startsWith(`${l.within}/`))); return hit ? `from ${hit.link}, ${hit.why}` : null; };
689
+ const { found: files, left, privately } = filesIn(folder, copyOf, fromLink);
690
+ // What was left out as no file of the folder's own, and what as private.
691
+ const links = left.filter(([, why]) => NOT_OWN.includes(why));
692
+ const kept = left.filter((entry) => !links.includes(entry));
693
+
694
+ // A file the manifest names that is left out makes a zip Chrome does not load, or loads without its worker, a page or
695
+ // an icon: refused, and nothing written (round 9, 25 Sep 2026). --allow-private packs a private one; a link, never.
696
+ const broken = [];
697
+ for (const [path, what] of namedFiles(manifest)) {
698
+ const gone = left.find(([rel]) => path.toLowerCase() === rel.toLowerCase() || path.toLowerCase().startsWith(`${rel.toLowerCase()}/`));
699
+ if (gone && !broken.some(([p]) => p === path)) broken.push([path, what, gone[1]]);
700
+ }
701
+ if (broken.length) {
702
+ const secret = broken.filter(([, , why]) => !NOT_OWN.includes(why)).length;
703
+ const advice = [
704
+ secret && `Take out what is private and build again, or pack ${secret > 1 ? 'them' : 'it'} anyway with --allow-private, for anyone who downloads the item to read.`,
705
+ secret < broken.length && 'The zip holds the folder\'s own files, and never a link: make each the file itself.',
706
+ ].filter(Boolean);
707
+ fail(`Not packed: the manifest names ${broken.length > 1 ? 'files' : 'a file'} this leaves out, and the zip would not load:\n\n${broken.map(([path, what, why]) => ` · ${oneLine(path)}, ${what} (${oneLine(why)})`).join('\n')}\n\n ${advice.join('\n ')}`);
708
+ }
593
709
  const problems = [];
594
710
 
595
711
  // Which side the build sells on: the tool keys inside it, wherever the bundler put them.
@@ -632,12 +748,14 @@ function pack() {
632
748
  renameSync(fresh, target);
633
749
  const kb = (statSync(target).size / 1024).toFixed(0);
634
750
  out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
635
- // What was left out, and why — or, asked for, what private file went in: a store zip is public.
636
- const links = left.filter(([, why]) => why === 'a link' || why === 'not a file' || why === 'a name no zip should hold');
637
- const kept = left.filter((entry) => !links.includes(entry));
638
- if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
751
+ // What was left out, and why — or, asked for, what private file went in: a store zip is public. A reason can name a
752
+ // file of the project's (a copy of…, from a link…), and so is put on one line, as the file's own name is.
753
+ if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${oneLine(f)} (${oneLine(why)})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
639
754
  if (links.length) out(` Left out: ${links.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. The zip holds the folder's own files: copy in what belongs in it.`);
640
- if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
755
+ if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${oneLine(f)} (${oneLine(why)})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
756
+ // Packed and named: a Google API key is made to be in a browser, and kept to its use by its restrictions (lib/safe.js).
757
+ const google = entries.filter(([, bytes]) => googleKey(bytes)).map(([name]) => oneLine(name));
758
+ if (google.length) out(` ${google.length > 1 ? 'Google API keys are' : 'A Google API key is'} in ${google.join(', ')}: anyone who downloads the item can read ${google.length > 1 ? 'them' : 'it'}. Restrict ${google.length > 1 ? 'each' : 'it'} to this extension, and to the APIs it calls, in Google Cloud → Credentials.`);
641
759
  // Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
642
760
  const linked = project ? publicLinks(project, folder) : [];
643
761
  if (linked.length) out(` Links in the folders a build copies as they are: ${linked.slice(0, 8).map(oneLine).join(', ')}${linked.length > 8 ? ` and ${linked.length - 8} more` : ''}. The build copies what each points at, a private file too: make each the file itself.`);
@@ -653,7 +771,7 @@ function mcp() {
653
771
  }
654
772
 
655
773
  async function check() {
656
- 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') });
774
+ const passed = await runCheck({ mark: (tone, text) => say(` ${tone === 'ok' ? green('✓') : tone === 'warn' ? yellow('!') : red('✗')} ${plain(text)}`), dir: opt('--into') ?? positional[0] ?? '.', out, api: (path, o = {}) => api(path, o), token: storedToken, detectProject, wireManifest, listTree, readJson, browser: flag('--browser') });
657
775
  if (!passed) process.exit(1);
658
776
  }
659
777
 
@@ -664,14 +782,17 @@ function help() {
664
782
  toolaby wire <tool-id> wire the extension in this folder to your tool, on Test
665
783
  toolaby wire <tool-id> --live the same, on Live: the store build
666
784
  --into <folder> another folder --force replace the platform's files
785
+ --own-popup keep your popup in front (the Wall's opens from toolaby.showPaywall())
667
786
  toolaby upgrade <tool-id> the platform's files replaced with the current ones — the popup, the client — yours untouched
668
787
  toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
669
788
  --surfaces both|popup|side-panel|none what it opens (default: both)
670
789
  toolaby tools your tools, with their ids
671
790
  toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
672
791
  refuses a Test key or localhost inside --out <file.zip>
673
- leaves out keys, certificates, .env and credential files, source maps, live secrets,
674
- a build's copy of a private file, a git folder, an image that is none --allow-private packs them
792
+ leaves out keys, certificates, .env and credential files, source maps, secrets,
793
+ git's files, a build's copy of a private file (compressed, UTF-16 or base64),
794
+ an image that is none; refuses a build if one is a file the manifest names
795
+ --allow-private packs them
675
796
  toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
676
797
  --browser loads it in Chromium (needs Playwright in the project)
677
798
  toolaby mcp the Wall for coding agents, an MCP server on stdio:
package/lib/check.js CHANGED
@@ -8,6 +8,8 @@ import { join, relative, resolve } from 'node:path';
8
8
  import { createHash } from 'node:crypto';
9
9
  import { createRequire } from 'node:module';
10
10
  import { oneLine, keySite, fetchBounded, readText } from './safe.js';
11
+ import { wrappedBy, namedWorker, startsPlatform as startsWall } from './manifest.js';
12
+ import { popupSwapIn, ownCode } from './summary.js';
11
13
 
12
14
  const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
13
15
  /** A tool id as the Wall makes them (lib/mcp.js has the same): the only thing the check puts after a Wall's address. */
@@ -53,12 +55,14 @@ export function featuresUsed(source) {
53
55
  return keys;
54
56
  }
55
57
 
56
- export async function runCheck({ dir: given, out, api, token, detectProject, wireManifest, listTree, readJson, browser }) {
58
+ export async function runCheck({ dir: given, out, api, token, detectProject, wireManifest, listTree, readJson, browser, mark = null }) {
57
59
  const dir = resolve(given ?? '.');
58
60
  const results = [];
59
- const ok = (s) => { results.push('ok'); out(` ✓ ${s}`); };
60
- const warn = (s) => { results.push('warn'); out(` ! ${s}`); };
61
- const bad = (s) => { results.push('bad'); out(` ✗ ${s}`); };
61
+ // `mark` prints a line with the command's own coloured mark (bin.mjs); without it, the plain one.
62
+ const line = (tone, sign, s) => { results.push(tone); if (mark) mark(tone, s); else out(` ${sign} ${s}`); };
63
+ const ok = (s) => line('ok', '✓', s);
64
+ const warn = (s) => line('warn', '!', s);
65
+ const bad = (s) => line('bad', '✗', s);
62
66
  out(`\n Checking ${dir}\n`);
63
67
 
64
68
  // 1. What the folder is.
@@ -105,14 +109,46 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
105
109
 
106
110
  // 4. The manifest, against the platform's own wiring rules: what wiring it again would add.
107
111
  if (project.manifestFile && manifest) {
108
- const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
109
- const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
112
+ const named = namedWorker(manifest) ?? '';
113
+ const workerText = named && has(named) ? readOr(join(dir, named), null) : null;
114
+ const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null, workerSource: workerText });
115
+ // The background is said below, in a line of its own.
116
+ const needed = (again.did ?? []).filter((d) => !/popup|side panel|service worker/i.test(d));
110
117
  if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.map(oneLine).join('; ')}. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
111
118
  else ok('The manifest has the permissions and host permissions the Wall needs.');
112
119
  // The identity: without the `key`, each copy's id follows its folder, and once the extension is named the tool key refuses every copy not chosen.
113
120
  if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
114
121
  } else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}/* in host_permissions and externally_connectable.`);
115
122
 
123
+ // 4b. The background starts the Wall: the popup and every page ask it everything, so without it nothing answers.
124
+ const upgrade = `\`npx -y toolaby@latest upgrade ${key.slug}\``;
125
+ if (project.manifestFile && manifest) {
126
+ const named = namedWorker(manifest) ?? '';
127
+ const text = named && has(named) ? readOr(join(dir, named), null) : null;
128
+ const wraps = wrappedBy(text);
129
+ // What wiring again would do with it, by the same rules (wiring/manifest).
130
+ const b = wireManifest(manifest, { site: key.site, has, workerSource: text }).background;
131
+ if (!named || text === null) bad(`Nothing starts the Wall: the manifest names no background${named ? ` it can find (${oneLine(named)})` : ''}. ${upgrade} adds one.`);
132
+ else if (b.kind === 'gone') bad(`${oneLine(named)} runs ${oneLine(b.wraps)}, which is no longer in the folder. Put your background's file name in manifest.json, then ${upgrade}.`);
133
+ else if (b.kind === 'rename') bad(`The Wall can't load a file named ${JSON.stringify(oneLine(b.file))}. Rename it to ${oneLine(b.to)}, put that in manifest.json, then ${upgrade}.`);
134
+ else if (b.kind === 'lines') bad(`${oneLine(b.file)} never starts the Wall, so the popup cannot reach it. Add at its top: ${oneLine(project.workerLines.trim().replace(/\n+/g, ' '))}`);
135
+ else if (wraps) {
136
+ // A classic one carries the key it starts with: the one in toolaby.config.js, or the background starts with an old one.
137
+ const carried = text.match(/"key":"([^"]+)"/)?.[1];
138
+ if (carried && carried !== keyText) bad(`${oneLine(named)} starts the Wall with another key than toolaby.config.js. \`npx -y toolaby@latest upgrade ${key.slug}\` writes both again.`);
139
+ else ok(`The background starts the Wall, and runs yours (${oneLine(wraps)}).`);
140
+ } else if (startsWall(text)) ok(`The background (${oneLine(named)}) starts the Wall.`);
141
+ else bad(`${oneLine(named)} never starts the Wall, so the popup cannot reach it. ${upgrade} puts the Wall's in front of it; yours stays as it is.`);
142
+ } else if (project.worker && !project.extraFiles) {
143
+ if (startsWall(readOr(join(dir, project.worker), ''))) ok(`The background (${oneLine(project.worker)}) starts the Wall.`);
144
+ else bad(`${oneLine(project.worker)} never starts the Wall, so the popup cannot reach it. Add at its top: ${oneLine(project.workerLines.trim().replace(/\n+/g, ' '))}`);
145
+ }
146
+ // A popup the code swaps while it runs: the Wall's in front of it can be skipped.
147
+ if (project.manifestFile && manifest?.action?.default_popup === 'toolaby-popup.html') {
148
+ const swap = popupSwapIn(sourceFiles(dir).map((f) => relative(dir, f).split('\\').join('/')).filter(ownCode).map((path) => ({ path, text: readOr(join(dir, path), '') })));
149
+ if (swap) warn(`Your code handles the popup itself (${oneLine(swap.call)} in ${oneLine(swap.file)}): \`npx -y toolaby@latest upgrade ${key.slug} --own-popup\` keeps yours in front.`);
150
+ }
151
+
116
152
  // 5. The client, as fresh as the Wall's.
117
153
  const client = await fetchBounded(`${key.site}/client/toolaby.js`).then((r) => (r.ok && r.body ? r.body.toString('utf8') : null)).catch(() => null);
118
154
  if (client === null) warn('The Wall\'s current client could not be read to compare.');