toolaby 1.6.3 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/bin.mjs +178 -57
- package/lib/check.js +42 -6
- package/lib/manifest.js +111 -10
- package/lib/project.js +2 -2
- package/lib/safe.js +344 -32
- package/lib/summary.js +73 -0
- package/package.json +1 -1
package/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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
136
|
-
const until = Date.now() +
|
|
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
|
-
|
|
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
|
|
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,
|
|
285
|
-
|
|
286
|
-
const
|
|
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.
|
|
296
|
-
|
|
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.
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
if (
|
|
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
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
637
|
-
|
|
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,
|
|
674
|
-
a build's copy of a private file
|
|
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
|
-
|
|
60
|
-
const
|
|
61
|
-
const
|
|
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
|
|
109
|
-
const
|
|
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.');
|