toolaby 1.8.3 → 1.8.4
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 +10 -10
- package/bin.mjs +32 -32
- package/lib/check.js +21 -21
- package/lib/manifest.js +6 -6
- package/lib/mcp.js +10 -10
- package/lib/project.js +4 -4
- package/lib/summary.js +13 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# toolaby
|
|
2
2
|
|
|
3
|
-
The Toolaby command line. It puts
|
|
3
|
+
The Toolaby command line. It puts Toolaby — sign-in, a Free plan, paid plans, licences and subscriptions — into a Chrome extension you already have, or makes a new one for you.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npm i -g toolaby # once; or run every command through `npx -y toolaby@latest …`
|
|
@@ -12,18 +12,18 @@ The command is `toolaby-cli`: on Windows, the Command Prompt runs a `toolaby.js`
|
|
|
12
12
|
|
|
13
13
|
In a repository you did not write, use the installed command: `npx` can run a `toolaby` the project carries, or fetch one from a registry the project's `.npmrc` names.
|
|
14
14
|
|
|
15
|
-
Every command acts on
|
|
15
|
+
Every command acts on Test (`toolaby.app/test`) unless told `--live` (`toolaby.app`), as Stripe's command line does. `toolaby tools` lists your ids on either side.
|
|
16
16
|
|
|
17
17
|
## What it does
|
|
18
18
|
|
|
19
|
-
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools. It opens only
|
|
19
|
+
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools. It opens only Toolaby's own page, over https (or on `localhost`, for a server on your machine); any other address is refused and nothing is signed in.
|
|
20
20
|
|
|
21
21
|
**`toolaby wire <tool-id>`**, run in your extension's folder, wires that extension to your tool:
|
|
22
22
|
|
|
23
|
-
- it adds
|
|
24
|
-
- it adds three lines to your `manifest.json` (a permission and your
|
|
25
|
-
- it puts
|
|
26
|
-
- your own code does not change. When your popup opens,
|
|
23
|
+
- it adds Toolaby's files — a small client library, a config with your tool's id and public key, and Toolaby's popup, which stands in front of yours;
|
|
24
|
+
- it adds three lines to your `manifest.json` (a permission and your Toolaby address) and creates a background file if you have none;
|
|
25
|
+
- it puts Toolaby's background in front of yours, `toolaby.background.js`: yours runs as it did — a classic one's `importScripts()` included — and Toolaby starts with it;
|
|
26
|
+
- your own code does not change. When your popup opens, Toolaby'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.
|
|
27
27
|
|
|
28
28
|
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.
|
|
29
29
|
|
|
@@ -38,8 +38,8 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
38
38
|
## Where things live
|
|
39
39
|
|
|
40
40
|
- Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
|
|
41
|
-
- The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI), and is sent to one
|
|
41
|
+
- The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI), and is sent to one server only: Live (`toolaby.app`), or the one `TOOLABY_TOKEN_WALL` names — `TOOLABY_TOKEN_WALL=https://toolaby.app/test` for a Test session. If that server refuses it, the session `toolaby login` stored for that server is used instead. `TOOLABY_WALL` or `--wall <url>` names any other server.
|
|
42
42
|
|
|
43
|
-
`wire` and `create` write only inside the folder they work on, and never through a symbolic link. A manifest, a framework's config or a
|
|
43
|
+
`wire` and `create` write only inside the folder they work on, and never through a symbolic link. A manifest, a framework's config or a server that names a place outside it (`../`, an absolute path, a link) is refused before anything is written. `create` writes only the files it knows a new extension has.
|
|
44
44
|
|
|
45
|
-
The files the command writes into your extension contain nothing secret: your tool's id, your
|
|
45
|
+
The files the command writes into your extension contain nothing secret: your tool's id, your Toolaby address and the *public* key your extension verifies licences with — the same things every copy of your extension carries once it is published.
|
package/bin.mjs
CHANGED
|
@@ -44,7 +44,7 @@ import { runMcp } from './lib/mcp.js';
|
|
|
44
44
|
import { runCheck } from './lib/check.js';
|
|
45
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';
|
|
46
46
|
|
|
47
|
-
const VERSION = '1.8.
|
|
47
|
+
const VERSION = '1.8.4';
|
|
48
48
|
const CLIENT_ID = 'toolaby-cli';
|
|
49
49
|
const argv = process.argv.slice(2);
|
|
50
50
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -104,7 +104,7 @@ const ANSWER_TIME = 30_000;
|
|
|
104
104
|
async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
|
|
105
105
|
// The time ran out (fetchHere's deadline; AbortError where an older Node says so): one line, whichever part was late.
|
|
106
106
|
// A Wall that answered a byte at a time held `login` and `wire` for ever (round 8, 25 Sep 2026).
|
|
107
|
-
const late = (e) => (e?.name === 'TimeoutError' || e?.name === 'AbortError' ? new Error(`
|
|
107
|
+
const late = (e) => (e?.name === 'TimeoutError' || e?.name === 'AbortError' ? new Error(`Toolaby at ${wall} did not answer ${path.split('?')[0]} within ${ANSWER_TIME / 1000} seconds.`) : null);
|
|
108
108
|
// Only the Wall answers for the Wall: a redirect elsewhere — Live's /test is Test's — is refused (lib/safe.js).
|
|
109
109
|
const res = await fetchHere(`${wall}${path}`, {
|
|
110
110
|
ms: ANSWER_TIME,
|
|
@@ -122,7 +122,7 @@ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
|
|
|
122
122
|
// end filled the command's memory, and the MCP server's, which hands what it reads to an agent (25 Sep 2026).
|
|
123
123
|
// An answer cut short reads as no JSON, as it always has; only one past the limit, or the time, stops the command.
|
|
124
124
|
const bytes = await readBounded(res, ANSWER_LIMIT).catch((e) => { const why = late(e); if (why) throw why; return Buffer.alloc(0); });
|
|
125
|
-
if (!bytes) throw new Error(`
|
|
125
|
+
if (!bytes) throw new Error(`Toolaby at ${wall} answered ${path.split('?')[0]} with more than ${ANSWER_LIMIT / 1024 / 1024} MB, which no answer of Toolaby's is. None of it was used.`);
|
|
126
126
|
let json = {};
|
|
127
127
|
try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
|
|
128
128
|
return { status: res.status, ok: res.ok, json };
|
|
@@ -138,16 +138,16 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
138
138
|
|
|
139
139
|
async function login() {
|
|
140
140
|
const start = await api('/api/dev-auth/device/code', { method: 'POST', body: { client_id: CLIENT_ID }, token: null });
|
|
141
|
-
if (!start.ok || !start.json.device_code) fail(`
|
|
141
|
+
if (!start.ok || !start.json.device_code) fail(`Toolaby at ${wall} did not start a sign-in (HTTP ${start.status}). Is the address right? --wall names another.`);
|
|
142
142
|
const { device_code, user_code, verification_uri, verification_uri_complete } = start.json;
|
|
143
143
|
const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
|
|
144
144
|
// The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
|
|
145
145
|
// Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
|
|
146
146
|
const page = signInPage(link, wall);
|
|
147
|
-
if (!page) fail(`
|
|
147
|
+
if (!page) fail(`Toolaby 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.`);
|
|
148
148
|
// How often it asks and for how long: what the Wall said, a quarter of an hour at most (lib/safe.js).
|
|
149
149
|
const { every: first, lasts } = devicePolling(start.json);
|
|
150
|
-
out(`\n Sign in to
|
|
150
|
+
out(`\n Sign in to Toolaby (${side}) to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(lasts / 60_000)} minutes)\n`);
|
|
151
151
|
openBrowser(page);
|
|
152
152
|
let every = first;
|
|
153
153
|
const until = Date.now() + lasts;
|
|
@@ -171,7 +171,7 @@ async function login() {
|
|
|
171
171
|
if (why === 'slow_down') every += 5000;
|
|
172
172
|
else if (why === 'expired_token') fail('The code expired before it was approved. Run `npx -y toolaby@latest login` again.');
|
|
173
173
|
else if (why === 'access_denied') fail('The sign-in was denied in the browser. Nothing was signed in.');
|
|
174
|
-
else if (why && why !== 'authorization_pending') fail(`
|
|
174
|
+
else if (why && why !== 'authorization_pending') fail(`Toolaby answered "${oneLine(why)}"${poll.json.error_description ? `: ${oneLine(poll.json.error_description)}` : ''}.`);
|
|
175
175
|
}
|
|
176
176
|
fail('The code expired before it was approved. Run `npx -y toolaby@latest login` again.');
|
|
177
177
|
}
|
|
@@ -188,7 +188,7 @@ async function whoami() {
|
|
|
188
188
|
if (!token) fail(`Not signed in to ${side} (${wall}). Run \`npx -y toolaby@latest login${live ? ' --live' : ''}\`.${envElsewhere()}`);
|
|
189
189
|
const me = await api('/api/cli/me', { token });
|
|
190
190
|
if (me.status === 401) fail(`The session for ${wall} is no longer valid. Run \`npx -y toolaby@latest login\`.`);
|
|
191
|
-
if (!me.ok) fail(`
|
|
191
|
+
if (!me.ok) fail(`Toolaby answered HTTP ${me.status}.`);
|
|
192
192
|
out(`\n ${oneLine(me.json.email)} · ${side} · ${wall}\n`);
|
|
193
193
|
for (const w of me.json.workspaces) out(` ${oneLine(w.name)} (${oneLine(w.slug)}, ${oneLine(w.role)}) ${oneLine(w.site)}`);
|
|
194
194
|
out();
|
|
@@ -202,7 +202,7 @@ async function logout() {
|
|
|
202
202
|
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ?? envToken() }).catch(() => ({ ok: false, status: 0 }));
|
|
203
203
|
delete all[wall];
|
|
204
204
|
writeCredentials(all);
|
|
205
|
-
out(res.ok ? `\n Signed out of ${side} (${wall}): the session was revoked and forgotten.\n` : `\n The session was forgotten here, but
|
|
205
|
+
out(res.ok ? `\n Signed out of ${side} (${wall}): the session was revoked and forgotten.\n` : `\n The session was forgotten here, but Toolaby could not be reached to revoke it (HTTP ${res.status}); it expires on its own, or sign it out from the dashboard.\n`);
|
|
206
206
|
if (envToken()) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
|
|
207
207
|
}
|
|
208
208
|
|
|
@@ -210,7 +210,7 @@ async function tools() {
|
|
|
210
210
|
const token = await tokenOrLogin();
|
|
211
211
|
const res = await api('/api/cli/tools', { token });
|
|
212
212
|
if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`npx -y toolaby@latest login${live ? ' --live' : ''}\`.`);
|
|
213
|
-
if (!res.ok) fail(`
|
|
213
|
+
if (!res.ok) fail(`Toolaby answered HTTP ${res.status}.`);
|
|
214
214
|
if (!res.json.tools.length) { out(`\n No tools on ${side} yet. Make one in the dashboard, then \`toolaby wire <tool-id>${live ? ' --live' : ''}\` in your extension's folder.\n`); return; }
|
|
215
215
|
out(`\n ${side}\n`);
|
|
216
216
|
for (const t of res.json.tools) out(` ${oneLine(t.slug).padEnd(40)} ${oneLine(t.name)}${t.bundle ? ' (bundle)' : ''} ${oneLine(t.site)}`);
|
|
@@ -272,7 +272,7 @@ async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
|
272
272
|
const who = me?.ok ? `\n\n You are signed in as ${oneLine(me.json.email)}${me.json.workspaces.length ? ` (workspaces: ${me.json.workspaces.map((w) => oneLine(w.slug)).join(', ')})` : ' (no workspace yet)'}.\n Another account owns it? \`toolaby logout\`, sign in to the dashboard as that account, then run this again.` : '';
|
|
273
273
|
fail(`${oneLine(res.json.message ?? `No tool "${slug}" in your workspaces.`)}${who}`);
|
|
274
274
|
}
|
|
275
|
-
if (!res.ok) fail(oneLine(res.json.message ?? `
|
|
275
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `Toolaby answered HTTP ${res.status}.`));
|
|
276
276
|
return res.json;
|
|
277
277
|
}
|
|
278
278
|
|
|
@@ -325,9 +325,9 @@ async function wire() {
|
|
|
325
325
|
// The extension's own background, with the Wall's in front of it — and, for a classic one, the client as a script.
|
|
326
326
|
const wrap = wiring?.background.kind === 'wrap' ? wiring.background : null;
|
|
327
327
|
const classicClient = wrap?.classic ? `${project.codeDir}${CLASSIC_CLIENT}` : null;
|
|
328
|
-
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]: "
|
|
328
|
+
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]: "Toolaby's background, beside yours" } : {}) });
|
|
329
329
|
// A file of the developer's own where the Wall's background goes is never replaced: only one the Wall wrote.
|
|
330
|
-
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
|
|
330
|
+
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 Toolaby's. Rename it, then run this again. Nothing was written.`);
|
|
331
331
|
// Taken for missing, it may only be out of the listing's sight — spelt "./background.js", in dist/, or deeper down:
|
|
332
332
|
// the developer's own background is never replaced by two lines of ours.
|
|
333
333
|
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.`);
|
|
@@ -338,7 +338,7 @@ async function wire() {
|
|
|
338
338
|
// The agent files come from Walls that write them; one that does not is older, not newer, than this command.
|
|
339
339
|
const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
|
|
340
340
|
const missing = [...targets, ...(classicClient ? [classicClient] : [])].filter((p) => !agentPaths.has(p)).map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
|
|
341
|
-
if (missing.length) fail(`This command (${VERSION}) is older than
|
|
341
|
+
if (missing.length) fail(`This command (${VERSION}) is older than Toolaby at ${wall}: it expects ${missing.join(', ')}, which Toolaby no longer hands out.\n Update it: npm install -g toolaby@latest — or run Toolaby's own copy: npx --yes ${wall}/toolaby.tgz ${command} ${slug}`);
|
|
342
342
|
const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
|
|
343
343
|
for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
|
|
344
344
|
for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
|
|
@@ -386,12 +386,12 @@ async function create() {
|
|
|
386
386
|
// the folder as it came let a Wall at another address — --wall, TOOLABY_WALL — write any file, anywhere.
|
|
387
387
|
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : SCAFFOLD_FILES;
|
|
388
388
|
const unknown = scaffold.filter((f) => !SCAFFOLD_FILES.includes(f));
|
|
389
|
-
if (unknown.length) fail(`
|
|
389
|
+
if (unknown.length) fail(`Toolaby at ${wall} named ${unknown.map((f) => JSON.stringify(f)).join(', ')} among a new extension's files: not ${unknown.length > 1 ? 'files' : 'a file'} this command (${VERSION}) writes.\n Nothing was written. If Toolaby is newer than this command, update it: npm install -g toolaby@latest`);
|
|
390
390
|
// Both surfaces (the default), one of them, or none at all for a tool that works from the background — and beside each,
|
|
391
391
|
// the Wall's page for it, which toolaby.showPaywall() opens when an action is refused (the extension's own pages stay in front).
|
|
392
392
|
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold), ...(hasPopup(surfaces) ? POPUP_FILES : []), ...(hasSidePanel(surfaces) ? SIDE_PANEL_FILES : [])];
|
|
393
393
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
394
|
-
if (absent.length) fail(`
|
|
394
|
+
if (absent.length) fail(`Toolaby at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run Toolaby's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
|
|
395
395
|
// What a coding agent reads: the guide with this tool's features and plans, the skill, AGENTS.md and CLAUDE.md.
|
|
396
396
|
const agentFiles = AGENT_FILES.filter((f) => typeof plan.files[f.name] === 'string');
|
|
397
397
|
const entries = AGENT_ENTRY_FILES.filter((f) => typeof plan.files[f] === 'string');
|
|
@@ -403,7 +403,7 @@ async function create() {
|
|
|
403
403
|
const iconBytes = {};
|
|
404
404
|
for (const icon of icons) {
|
|
405
405
|
const got = await fetchBounded(`${wall}/boilerplate/${icon}`).catch((e) => ({ why: e.name === 'TimeoutError' ? 'it took longer than 30 seconds' : oneLine(e.message) }));
|
|
406
|
-
if (got.why || !got.body) fail(`
|
|
406
|
+
if (got.why || !got.body) fail(`Toolaby at ${wall} did not send ${icon}: ${got.why ?? 'it sent more than a megabyte, which no icon is'}. Nothing was written.`);
|
|
407
407
|
iconBytes[icon] = got.body;
|
|
408
408
|
}
|
|
409
409
|
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
@@ -418,7 +418,7 @@ async function create() {
|
|
|
418
418
|
for (const f of entries) write(f, plan.files[f]);
|
|
419
419
|
const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
|
|
420
420
|
out(`\n ${oneLine(plan.name)} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${oneLine(plan.buyer ?? `${plan.site}/tools/${slug}`)}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${wall}/docs/surfaces\n`);
|
|
421
|
-
if (typeof plan.files['TOOLABY.md'] === 'string') out(` With a coding agent: AGENTS.md and TOOLABY.md tell it how this extension sells. For Claude Code,
|
|
421
|
+
if (typeof plan.files['TOOLABY.md'] === 'string') out(` With a coding agent: AGENTS.md and TOOLABY.md tell it how this extension sells. For Claude Code, Toolaby itself:\n claude mcp add toolaby -- npx -y toolaby@latest mcp${live ? ' --live' : ''}\n`);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
424
|
// ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
|
|
@@ -445,7 +445,7 @@ function devSecret() {
|
|
|
445
445
|
async function catalogue() {
|
|
446
446
|
const res = await api('/api/cli/webhooks/events', { token: await tokenOrLogin() });
|
|
447
447
|
if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`npx -y toolaby@latest login${live ? ' --live' : ''}\`.`);
|
|
448
|
-
if (!res.ok) fail(`
|
|
448
|
+
if (!res.ok) fail(`Toolaby answered HTTP ${res.status}.`);
|
|
449
449
|
return res.json.events;
|
|
450
450
|
}
|
|
451
451
|
async function webhooks() {
|
|
@@ -468,7 +468,7 @@ async function webhooks() {
|
|
|
468
468
|
const to = opt('--to');
|
|
469
469
|
if (!to) {
|
|
470
470
|
const res = await api('/api/cli/webhooks/trigger', { method: 'POST', body: { type, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
471
|
-
if (!res.ok) fail(oneLine(res.json.message ?? `
|
|
471
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `Toolaby answered HTTP ${res.status}.`));
|
|
472
472
|
out(`\n Sent ${oneLine(res.json.sent)} to every endpoint of ${oneLine(res.json.workspace)} · ${side}. Its id is ${oneLine(res.json.id)}; the delivery log is under Configure → Webhooks.\n`);
|
|
473
473
|
return;
|
|
474
474
|
}
|
|
@@ -494,7 +494,7 @@ async function webhooks() {
|
|
|
494
494
|
if (sub === 'endpoints') {
|
|
495
495
|
const ws = opt('--workspace');
|
|
496
496
|
const res = await api(`/api/cli/webhooks/endpoints${ws ? `?workspace=${encodeURIComponent(ws)}` : ''}`, { token: await tokenOrLogin() });
|
|
497
|
-
if (!res.ok) fail(oneLine(res.json.message ?? `
|
|
497
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `Toolaby answered HTTP ${res.status}.`));
|
|
498
498
|
out(`\n ${oneLine(res.json.workspace)} · ${side}\n`);
|
|
499
499
|
if (res.json.endpoints.length === 0) out(' None yet. `npx -y toolaby@latest webhooks add <url>` registers one.');
|
|
500
500
|
for (const e of res.json.endpoints) out(` ${oneLine(e.id)} ${e.disabled ? 'paused ' : 'on '} ${oneLine(e.url)}${e.description ? ` — ${oneLine(e.description)}` : ''}${e.events.length ? ` (${oneLine(e.events.join(', '))})` : ''}`);
|
|
@@ -506,7 +506,7 @@ async function webhooks() {
|
|
|
506
506
|
if (!url) fail('usage: npx -y toolaby@latest webhooks add <url> [--description <text>] [--events a.b,c.d] [--workspace <slug>]');
|
|
507
507
|
const events = (opt('--events') ?? '').split(',').map((e) => e.trim()).filter(Boolean);
|
|
508
508
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'POST', body: { url, description: opt('--description') ?? '', events, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
509
|
-
if (!res.ok) fail(oneLine(res.json.message ?? `
|
|
509
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `Toolaby answered HTTP ${res.status}.`));
|
|
510
510
|
out(`\n Added ${oneLine(res.json.id)} → ${oneLine(res.json.url)} · ${oneLine(res.json.workspace)} · ${side}\n\n Signing secret, shown once — keep it where your server keeps secrets:\n\n ${oneLine(res.json.secret)}\n`);
|
|
511
511
|
return;
|
|
512
512
|
}
|
|
@@ -514,7 +514,7 @@ async function webhooks() {
|
|
|
514
514
|
const id = positional[1];
|
|
515
515
|
if (!id) fail('usage: npx -y toolaby@latest webhooks remove <ep_…> [--workspace <slug>] — `npx -y toolaby@latest webhooks endpoints` lists them');
|
|
516
516
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'DELETE', body: { id, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
517
|
-
if (!res.ok) fail(oneLine(res.json.message ?? `
|
|
517
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `Toolaby answered HTTP ${res.status}.`));
|
|
518
518
|
out(`\n Removed ${oneLine(res.json.removed)} · ${oneLine(res.json.workspace)} · ${side}. Deliveries to it stop at once.\n`);
|
|
519
519
|
return;
|
|
520
520
|
}
|
|
@@ -731,8 +731,8 @@ function pack() {
|
|
|
731
731
|
try { const facts = JSON.parse(Buffer.from(k.split('_')[2], 'base64url').toString('utf8')); if (typeof facts.s === 'string') sites.add(facts.s.replace(/\/$/, '')); } catch { /* not a key after all */ }
|
|
732
732
|
}
|
|
733
733
|
}
|
|
734
|
-
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development
|
|
735
|
-
if (modes.size === 0) out('\n No tool key found in the build: it is not wired to
|
|
734
|
+
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development server'}. Run \`npx -y toolaby@latest wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
|
|
735
|
+
if (modes.size === 0) out('\n No tool key found in the build: it is not wired to Toolaby, or the key is somewhere this cannot read. Packing anyway.');
|
|
736
736
|
|
|
737
737
|
// Addresses of this machine have no place in a store build.
|
|
738
738
|
const local = [...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])].filter((m) => typeof m === 'string' && LOCAL.test(m));
|
|
@@ -751,7 +751,7 @@ function pack() {
|
|
|
751
751
|
const keys = new Set([...sites].map(sitePattern));
|
|
752
752
|
const strangers = [...new Set([...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])])]
|
|
753
753
|
.filter((m) => typeof m === 'string' && !keys.has(m) && walls.some((w) => { const h = hostOf(m); return !!h && (h === w || h.endsWith(`.${w}`)); }));
|
|
754
|
-
if (strangers.length && !flag('--allow-wall-hosts')) problems.push(`The manifest names ${strangers.map(oneLine).join(', ')}:
|
|
754
|
+
if (strangers.length && !flag('--allow-wall-hosts')) problems.push(`The manifest names ${strangers.map(oneLine).join(', ')}: Toolaby's, and no key's in this build — another workspace, or the other side's Test address. A store build asks its buyers for ${strangers.length > 1 ? 'these' : 'it'} at install for nothing: take ${strangers.length > 1 ? 'them' : 'it'} out of host_permissions and externally_connectable. (--allow-wall-hosts packs it anyway.)`);
|
|
755
755
|
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
756
756
|
|
|
757
757
|
const hadKey = typeof manifest.key === 'string';
|
|
@@ -782,7 +782,7 @@ function pack() {
|
|
|
782
782
|
// Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
|
|
783
783
|
const linked = project ? publicLinks(project, folder) : [];
|
|
784
784
|
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.`);
|
|
785
|
-
if (hadKey) out(' The manifest\'s "key" is left out: the store keeps the id it gives the item. Your unpacked copy keeps the key and its own id;
|
|
785
|
+
if (hadKey) out(' The manifest\'s "key" is left out: the store keeps the id it gives the item. Your unpacked copy keeps the key and its own id; Toolaby admits both once the listing\'s address is on the tool.');
|
|
786
786
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
787
787
|
}
|
|
788
788
|
|
|
@@ -807,13 +807,13 @@ function help() {
|
|
|
807
807
|
wire <tool-id> wire the extension in this folder to your tool, on Test
|
|
808
808
|
wire <tool-id> --live the same, on Live: the store build
|
|
809
809
|
--into <folder> another folder --force replace the platform's files
|
|
810
|
-
--own-popup keep your popup in front (
|
|
810
|
+
--own-popup keep your popup in front (Toolaby's opens from toolaby.showPaywall())
|
|
811
811
|
upgrade <tool-id> the platform's files replaced with the current ones — the popup, the client — yours untouched
|
|
812
812
|
create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
|
|
813
813
|
--surfaces both|popup|side-panel|none what it opens (default: both)
|
|
814
814
|
tools your tools, with their ids
|
|
815
815
|
pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
816
|
-
refuses a Test key, localhost, or another of
|
|
816
|
+
refuses a Test key, localhost, or another of Toolaby's hosts inside
|
|
817
817
|
--out <file.zip>
|
|
818
818
|
leaves out keys, certificates, .env and credential files, source maps, secrets,
|
|
819
819
|
git's files, a build's copy of a private file (compressed, UTF-16 or base64),
|
|
@@ -821,7 +821,7 @@ function help() {
|
|
|
821
821
|
--allow-private packs them
|
|
822
822
|
check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
823
823
|
--browser loads it in Chromium (needs Playwright in the project)
|
|
824
|
-
mcp
|
|
824
|
+
mcp Toolaby for coding agents, an MCP server on stdio:
|
|
825
825
|
claude mcp add toolaby -- npx -y toolaby@latest mcp
|
|
826
826
|
webhooks events the six events, with an example of each
|
|
827
827
|
webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
|
|
@@ -829,8 +829,8 @@ function help() {
|
|
|
829
829
|
login · whoami · logout the session, per side; a command that needs one signs you in itself
|
|
830
830
|
|
|
831
831
|
Every command acts on Test (toolaby.app/test) unless told --live (toolaby.app).
|
|
832
|
-
--wall <url> or TOOLABY_WALL names any other
|
|
833
|
-
sent to Live alone, or to the one
|
|
832
|
+
--wall <url> or TOOLABY_WALL names any other server; TOOLABY_TOKEN a session, for CI —
|
|
833
|
+
sent to Live alone, or to the one server TOOLABY_TOKEN_WALL names (https://toolaby.app/test for Test).
|
|
834
834
|
|
|
835
835
|
In a repository you did not write, install it (npm i -g toolaby) and type toolaby-cli: npx can run a toolaby
|
|
836
836
|
the project carries, or fetch one from a registry its .npmrc names.
|
package/lib/check.js
CHANGED
|
@@ -81,7 +81,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
81
81
|
const at = (f) => join(dir, `${project.codeDir}${f}`);
|
|
82
82
|
const missing = ['toolaby.js', 'toolaby.config.js'].filter((f) => !existsSync(at(f)));
|
|
83
83
|
if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx -y toolaby@latest wire <tool-id>\` here.`); return false; }
|
|
84
|
-
ok(`
|
|
84
|
+
ok(`Toolaby's files are in ${project.codeDir || 'the top folder'}.`);
|
|
85
85
|
|
|
86
86
|
// 3. The tool key, and the tool it names — from a file: a link to a device or a pipe is read as nothing.
|
|
87
87
|
const configText = readOr(at('toolaby.config.js'), '');
|
|
@@ -94,15 +94,15 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
94
94
|
// project's key sent the two requests wherever it named, and a tool id of ".." made the signed-in one a request for
|
|
95
95
|
// another route (25 Sep 2026).
|
|
96
96
|
if (!keySite(key.site) || !TOOL_ID.test(key.slug)) {
|
|
97
|
-
bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a
|
|
97
|
+
bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a Toolaby server: its address is https (http only on this machine) with nothing after the host, and its id lowercase letters, digits and dashes. Nothing was asked of it. \`npx -y toolaby@latest upgrade <tool-id>\` writes the tool's own key.`);
|
|
98
98
|
return false;
|
|
99
99
|
}
|
|
100
|
-
ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development
|
|
100
|
+
ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development server'} (${key.site}).`);
|
|
101
101
|
const pub = await fetchBounded(`${key.site}/api/tools/${encodeURIComponent(key.slug)}`).then((r) => ({ status: r.status, json: jsonOf(r.body) })).catch(() => ({ status: 0, json: null }));
|
|
102
102
|
const tool = pub.status === 200 ? pub.json : null;
|
|
103
|
-
if (!tool) bad(pub.status === 0 ? `
|
|
103
|
+
if (!tool) bad(pub.status === 0 ? `Toolaby at ${key.site} could not be reached.` : pub.status === 404 ? `Toolaby does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx -y toolaby@latest tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `Toolaby answered HTTP ${pub.status} for the tool.`);
|
|
104
104
|
else {
|
|
105
|
-
ok(`
|
|
105
|
+
ok(`Toolaby knows it: ${oneLine(tool.name)}.`);
|
|
106
106
|
const jwk = tool.publicKeyJwk ?? {};
|
|
107
107
|
if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx -y toolaby@latest upgrade <tool-id>` writes it in.');
|
|
108
108
|
}
|
|
@@ -114,8 +114,8 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
114
114
|
const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null, workerSource: workerText });
|
|
115
115
|
// The background is said below, in a line of its own.
|
|
116
116
|
const needed = (again.did ?? []).filter((d) => !/popup|side panel|service worker/i.test(d));
|
|
117
|
-
if (needed.length) bad(`The manifest lacks what
|
|
118
|
-
else ok('The manifest has the permissions and host permissions
|
|
117
|
+
if (needed.length) bad(`The manifest lacks what Toolaby needs: ${needed.map(oneLine).join('; ')}. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
|
|
118
|
+
else ok('The manifest has the permissions and host permissions Toolaby needs.');
|
|
119
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.
|
|
120
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.`);
|
|
121
121
|
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${sitePattern(key.site)} in host_permissions and externally_connectable.`);
|
|
@@ -128,20 +128,20 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
128
128
|
const wraps = wrappedBy(text);
|
|
129
129
|
// What wiring again would do with it, by the same rules (wiring/manifest).
|
|
130
130
|
const b = wireManifest(manifest, { site: key.site, has, workerSource: text }).background;
|
|
131
|
-
if (!named || text === null) bad(`Nothing starts
|
|
131
|
+
if (!named || text === null) bad(`Nothing starts Toolaby: the manifest names no background${named ? ` it can find (${oneLine(named)})` : ''}. ${upgrade} adds one.`);
|
|
132
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(`
|
|
134
|
-
else if (b.kind === 'lines') bad(`${oneLine(b.file)} never starts
|
|
133
|
+
else if (b.kind === 'rename') bad(`Toolaby 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 Toolaby, so the popup cannot reach it. Add at its top: ${oneLine(project.workerLines.trim().replace(/\n+/g, ' '))}`);
|
|
135
135
|
else if (wraps) {
|
|
136
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
137
|
const carried = text.match(/"key":"([^"]+)"/)?.[1];
|
|
138
|
-
if (carried && carried !== keyText) bad(`${oneLine(named)} starts
|
|
139
|
-
else ok(`The background starts
|
|
140
|
-
} else if (startsWall(text)) ok(`The background (${oneLine(named)}) starts
|
|
141
|
-
else bad(`${oneLine(named)} never starts
|
|
138
|
+
if (carried && carried !== keyText) bad(`${oneLine(named)} starts Toolaby with another key than toolaby.config.js. \`npx -y toolaby@latest upgrade ${key.slug}\` writes both again.`);
|
|
139
|
+
else ok(`The background starts Toolaby, and runs yours (${oneLine(wraps)}).`);
|
|
140
|
+
} else if (startsWall(text)) ok(`The background (${oneLine(named)}) starts Toolaby.`);
|
|
141
|
+
else bad(`${oneLine(named)} never starts Toolaby, so the popup cannot reach it. ${upgrade} puts Toolaby's in front of it; yours stays as it is.`);
|
|
142
142
|
} else if (project.worker && !project.extraFiles) {
|
|
143
|
-
if (startsWall(readOr(join(dir, project.worker), ''))) ok(`The background (${oneLine(project.worker)}) starts
|
|
144
|
-
else bad(`${oneLine(project.worker)} never starts
|
|
143
|
+
if (startsWall(readOr(join(dir, project.worker), ''))) ok(`The background (${oneLine(project.worker)}) starts Toolaby.`);
|
|
144
|
+
else bad(`${oneLine(project.worker)} never starts Toolaby, so the popup cannot reach it. Add at its top: ${oneLine(project.workerLines.trim().replace(/\n+/g, ' '))}`);
|
|
145
145
|
}
|
|
146
146
|
// A popup the code swaps while it runs: the Wall's in front of it can be skipped.
|
|
147
147
|
if (project.manifestFile && manifest?.action?.default_popup === 'toolaby-popup.html') {
|
|
@@ -151,9 +151,9 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
151
151
|
|
|
152
152
|
// 5. The client, as fresh as the Wall's.
|
|
153
153
|
const client = await fetchBounded(`${key.site}/client/toolaby.js`).then((r) => (r.ok && r.body ? r.body.toString('utf8') : null)).catch(() => null);
|
|
154
|
-
if (client === null) warn('
|
|
155
|
-
else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not
|
|
156
|
-
else ok('toolaby.js is
|
|
154
|
+
if (client === null) warn('Toolaby\'s current client could not be read to compare.');
|
|
155
|
+
else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not Toolaby's current client. \`npx -y toolaby@latest upgrade ${key.slug}\` replaces it (your own files stay).`);
|
|
156
|
+
else ok('toolaby.js is Toolaby\'s current client.');
|
|
157
157
|
|
|
158
158
|
// 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
|
|
159
159
|
const sources = sourceFiles(dir).map((f) => { try { return readText(f); } catch { return ''; } });
|
|
@@ -182,7 +182,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
182
182
|
else if (facts?.ok) warn('No copy has checked in yet: load it at chrome://extensions and open it once.');
|
|
183
183
|
// The key carries what the Free plan holds and the kind of plan sold: a change since it was written leaves it behind.
|
|
184
184
|
const current = facts?.ok ? facts.json?.toolKey : null;
|
|
185
|
-
if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than
|
|
185
|
+
if (typeof current === 'string' && current.split('_')[1] === mode && current !== keyText) warn(`The tool key in toolaby.config.js is older than Toolaby's: what the Free plan holds, the plans or the signing key changed since it was written. \`npx -y toolaby@latest upgrade ${key.slug}\` writes the current one.`);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
// 8. In a browser: the background answers.
|
|
@@ -215,7 +215,7 @@ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
|
215
215
|
chrome.runtime.sendMessage({ action: 'getLicenseStatus' }).then((r) => r ?? null),
|
|
216
216
|
new Promise((r) => setTimeout(() => r('timeout'), 8000)),
|
|
217
217
|
])).catch((e) => `error: ${e.message}`);
|
|
218
|
-
if (answer && answer !== 'timeout' && !String(answer).startsWith('error')) ok('The background answers
|
|
218
|
+
if (answer && answer !== 'timeout' && !String(answer).startsWith('error')) ok('The background answers Toolaby\'s client: its listeners registered.');
|
|
219
219
|
else bad(`The background did not answer (${oneLine(answer ?? 'nothing')}): its module threw while loading, so none of its listeners registered.`);
|
|
220
220
|
await page.close().catch(() => {});
|
|
221
221
|
// The extension's own pages, each opened in a tab: one whose script throws is a popup that opens blank.
|
package/lib/manifest.js
CHANGED
|
@@ -49,7 +49,7 @@ export function wallWorkerSource(b, config) {
|
|
|
49
49
|
const depth = b.file.split('/').length - 1;
|
|
50
50
|
const up = '../'.repeat(depth);
|
|
51
51
|
const own = b.wraps.slice(b.wraps.lastIndexOf('/') + 1);
|
|
52
|
-
const head = `//
|
|
52
|
+
const head = `// Toolaby's background, in front of yours: ${b.wraps} runs as it did, then Toolaby starts.\n// Written by Toolaby's wiring (toolaby wire): keep it, never edit it. Your code stays in ${b.wraps}.\n// toolaby-wraps: ${b.wraps}\n`;
|
|
53
53
|
if (!b.classic)
|
|
54
54
|
return `${head}import { toolaby } from '${up || './'}toolaby.js';\nimport './${own}';\n\ntoolaby.ensureBackground();\n`;
|
|
55
55
|
const c = config ?? { key: '', appPopup: '', appSidePanel: '' };
|
|
@@ -164,12 +164,12 @@ export function wireManifest(input, o) {
|
|
|
164
164
|
const appPopup = o.ownInFront ? null : ownPopup;
|
|
165
165
|
if (appPopup && declared !== WALL_POPUP) {
|
|
166
166
|
manifest.action = { ...manifest.action, default_popup: WALL_POPUP };
|
|
167
|
-
did.push(`
|
|
167
|
+
did.push(`Toolaby's popup in front of ${appPopup}: it opens on to yours while the device may, and shows the panel when it may not`);
|
|
168
168
|
}
|
|
169
169
|
// Asked to keep the extension's own in front after a wiring stood the Wall's there: theirs goes back.
|
|
170
170
|
if (o.ownInFront && declared === WALL_POPUP && ownPopup) {
|
|
171
171
|
manifest.action = { ...manifest.action, default_popup: ownPopup };
|
|
172
|
-
did.push(`${ownPopup} back in front: toolaby.showPaywall() opens
|
|
172
|
+
did.push(`${ownPopup} back in front: toolaby.showPaywall() opens Toolaby's popup`);
|
|
173
173
|
}
|
|
174
174
|
// The extension's own side panel, the same way: the Wall's in front, with the permission a side panel needs.
|
|
175
175
|
// Chrome spells it side_panel.default_path, Firefox sidebar_action.default_panel; a manifest may carry both.
|
|
@@ -183,7 +183,7 @@ export function wireManifest(input, o) {
|
|
|
183
183
|
// The extension's own side panel stays in front, as its popup does — and goes back there after a wiring put the Wall's.
|
|
184
184
|
if (declaredPanel === WALL_SIDE_PANEL && ownSidePanel) {
|
|
185
185
|
manifest.side_panel = { ...manifest.side_panel, default_path: ownSidePanel };
|
|
186
|
-
did.push(`${ownSidePanel} back in front: toolaby.showPaywall() opens
|
|
186
|
+
did.push(`${ownSidePanel} back in front: toolaby.showPaywall() opens Toolaby's side panel`);
|
|
187
187
|
}
|
|
188
188
|
if (declaredSidebar === WALL_SIDE_PANEL && ownSidePanel)
|
|
189
189
|
manifest.sidebar_action = { ...manifest.sidebar_action, default_panel: ownSidePanel };
|
|
@@ -192,12 +192,12 @@ export function wireManifest(input, o) {
|
|
|
192
192
|
manifest.side_panel = { ...manifest.side_panel, default_path: WALL_SIDE_PANEL };
|
|
193
193
|
if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('sidePanel')))
|
|
194
194
|
manifest.permissions = add(manifest.permissions, 'sidePanel');
|
|
195
|
-
did.push(`
|
|
195
|
+
did.push(`Toolaby's side panel in front of ${declaredPanel}: the same as the popup, at the panel's width`);
|
|
196
196
|
}
|
|
197
197
|
// Firefox's sidebar, when it names the same page — one that names another is left alone rather than pointed elsewhere.
|
|
198
198
|
if (!o.ownInFront && theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
|
|
199
199
|
manifest.sidebar_action = { ...manifest.sidebar_action, default_panel: WALL_SIDE_PANEL };
|
|
200
|
-
did.push(`
|
|
200
|
+
did.push(`Toolaby's side panel in front of ${declaredSidebar} in Firefox's sidebar_action too`);
|
|
201
201
|
}
|
|
202
202
|
// The background. None named, or named and not there: one of the Wall's alone, a module. The extension's own: left as
|
|
203
203
|
// it is, and the Wall's stood in front of it — beside it, a module for a module and a classic script for a classic
|
package/lib/mcp.js
CHANGED
|
@@ -35,7 +35,7 @@ function toolsFor(side) {
|
|
|
35
35
|
{
|
|
36
36
|
name: 'toolaby_whoami',
|
|
37
37
|
title: 'Who is signed in',
|
|
38
|
-
description: `The developer signed in to
|
|
38
|
+
description: `The developer signed in to Toolaby (${side}), and their workspaces.`,
|
|
39
39
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
40
|
annotations: READS,
|
|
41
41
|
run: (_a, api) => api('/api/cli/me'),
|
|
@@ -43,7 +43,7 @@ function toolsFor(side) {
|
|
|
43
43
|
{
|
|
44
44
|
name: 'toolaby_list_tools',
|
|
45
45
|
title: 'List tools',
|
|
46
|
-
description: `Every tool in the developer's workspaces on
|
|
46
|
+
description: `Every tool in the developer's workspaces on Toolaby (${side}), with the id the other tools take.`,
|
|
47
47
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
48
48
|
annotations: READS,
|
|
49
49
|
run: (_a, api) => api('/api/cli/tools'),
|
|
@@ -51,7 +51,7 @@ function toolsFor(side) {
|
|
|
51
51
|
{
|
|
52
52
|
name: 'toolaby_get_tool',
|
|
53
53
|
title: 'Read a tool',
|
|
54
|
-
description: `One tool on
|
|
54
|
+
description: `One tool on Toolaby (${side}): what the Free plan holds (freePlan.holds, in a sentence), its features, its plans with what each unlocks, its tool key, and its extension: identityId is the id of every copy whose manifest has the tool's key (such a copy needs no binding, so boundId stays null), checkedInAt says whether one has checked in, inUse which builds. Then the addresses of its pages.`,
|
|
55
55
|
inputSchema: { type: 'object', properties: { tool }, required: ['tool'], additionalProperties: false },
|
|
56
56
|
annotations: READS,
|
|
57
57
|
run: (a, api) => api(`/api/cli/tools/${encodeURIComponent(a.tool)}`),
|
|
@@ -59,7 +59,7 @@ function toolsFor(side) {
|
|
|
59
59
|
{
|
|
60
60
|
name: 'toolaby_create_tool',
|
|
61
61
|
title: 'Create a tool',
|
|
62
|
-
description: `A new tool on
|
|
62
|
+
description: `A new tool on Toolaby (${side}). This changes what the developer's own workspace sells: one more tool. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx -y toolaby@latest create <id>\` for a new one, \`npx -y toolaby@latest wire <id>\` for an existing one.`,
|
|
63
63
|
inputSchema: {
|
|
64
64
|
type: 'object',
|
|
65
65
|
properties: {
|
|
@@ -165,7 +165,7 @@ function toolsFor(side) {
|
|
|
165
165
|
{
|
|
166
166
|
name: 'toolaby_docs',
|
|
167
167
|
title: 'Read the docs',
|
|
168
|
-
description: '
|
|
168
|
+
description: 'Toolaby\'s docs as Markdown. Without a page: the index, every page with what it covers. With a page (like "gating", "pricing", "reference/client"): that page.',
|
|
169
169
|
inputSchema: { type: 'object', properties: { page: { type: 'string' } }, additionalProperties: false },
|
|
170
170
|
annotations: READS,
|
|
171
171
|
run: (a, _api, docs) => docs(a.page),
|
|
@@ -177,7 +177,7 @@ function toolsFor(side) {
|
|
|
177
177
|
function resultOf(res) {
|
|
178
178
|
const body = res.json ?? {};
|
|
179
179
|
if (res.ok) return { content: [{ type: 'text', text: typeof res.text === 'string' ? res.text : JSON.stringify(body, null, 2) }], ...(typeof res.text === 'string' ? {} : { structuredContent: body }) };
|
|
180
|
-
const why = body.message ?? body.error ?? `
|
|
180
|
+
const why = body.message ?? body.error ?? `Toolaby answered HTTP ${res.status}.`;
|
|
181
181
|
return { content: [{ type: 'text', text: why }], isError: true };
|
|
182
182
|
}
|
|
183
183
|
|
|
@@ -195,7 +195,7 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
195
195
|
const url = clean ? `${wall}/docs/${clean}.md` : `${wall}/llms.txt`;
|
|
196
196
|
// The Wall's own pages, a few megabytes at most: a redirect elsewhere handed an agent any host's page as the docs (25 Sep 2026).
|
|
197
197
|
const res = await fetchBounded(url, { limit: 4 * 1024 * 1024 }).catch(() => ({ ok: false, status: 0, body: null }));
|
|
198
|
-
if (res.ok && !res.body) return { ok: false, status: 413, json: { message: 'The docs page is larger than any
|
|
198
|
+
if (res.ok && !res.body) return { ok: false, status: 413, json: { message: 'The docs page is larger than any Toolaby serves.' } };
|
|
199
199
|
const text = res.body ? res.body.toString('utf8') : '';
|
|
200
200
|
return res.ok ? { ok: true, status: res.status, text } : { ok: false, status: res.status, json: { message: res.status === 404 ? `No docs page "${clean}". Call toolaby_docs without a page for the index.` : `The docs answered HTTP ${res.status}.` } };
|
|
201
201
|
};
|
|
@@ -208,7 +208,7 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
208
208
|
return { result: { content: [{ type: 'text', text: 'A tool id is lowercase letters, digits and dashes, as toolaby_list_tools gives it.' }], isError: true } };
|
|
209
209
|
}
|
|
210
210
|
if (name !== 'toolaby_docs' && !token()) {
|
|
211
|
-
return { result: { content: [{ type: 'text', text: `Not signed in to
|
|
211
|
+
return { result: { content: [{ type: 'text', text: `Not signed in to Toolaby (${side}, ${wall}). Run \`npx -y toolaby@latest login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
|
|
212
212
|
}
|
|
213
213
|
try {
|
|
214
214
|
return { result: resultOf(await t.run(args ?? {}, api, docs)) };
|
|
@@ -228,8 +228,8 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
228
228
|
return send({ id: msg.id, result: {
|
|
229
229
|
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
230
230
|
capabilities: { tools: { listChanged: false } },
|
|
231
|
-
serverInfo: { name: 'toolaby', title: 'Toolaby
|
|
232
|
-
instructions: `
|
|
231
|
+
serverInfo: { name: 'toolaby', title: 'Toolaby', version },
|
|
232
|
+
instructions: `Toolaby (${side}, ${wall}): accounts, licences, subscriptions and the paywall for Chrome extensions. Read a tool with toolaby_get_tool before changing it; the extension gates paid work with toolaby.gate({ feature }) and each feature key must exist on the tool (toolaby_set_features). Wire files with the shell: npx -y toolaby@latest create|wire|upgrade <id>, check with npx -y toolaby@latest check. toolaby_docs reads the docs.`,
|
|
233
233
|
} });
|
|
234
234
|
}
|
|
235
235
|
case 'notifications/initialized':
|
package/lib/project.js
CHANGED
|
@@ -131,7 +131,7 @@ export function detectProject(o) {
|
|
|
131
131
|
kind: 'plasmo', label: 'Plasmo', codeDir: '', popupDir: '', manifestFile: null, wrapper: false, appPopup: null, appSidePanel: null, worker, built: false, foundManifest: null,
|
|
132
132
|
createWorker: worker ? null : { file: workerFile, content: workerLines },
|
|
133
133
|
workerLines, snippets: [{ file: 'package.json', language: 'json', code }],
|
|
134
|
-
note: 'Plasmo builds only the pages its manifest names, so
|
|
134
|
+
note: 'Plasmo builds only the pages its manifest names, so Toolaby\'s popup cannot stand in front of yours: ask the gate in your popup — the lines are in the docs, under Plasmo.',
|
|
135
135
|
};
|
|
136
136
|
}
|
|
137
137
|
if (crxjs && !has('manifest.json')) {
|
|
@@ -147,10 +147,10 @@ export function detectProject(o) {
|
|
|
147
147
|
const workerFile = worker ?? 'src/background.ts';
|
|
148
148
|
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
149
149
|
const code = [
|
|
150
|
-
`// ${configFile ?? 'manifest.config.ts'} —
|
|
150
|
+
`// ${configFile ?? 'manifest.config.ts'} — Toolaby's needs come from toolaby.config.js: import once, spread inside defineManifest({ … })`,
|
|
151
151
|
`import { manifest as toolaby } from '${importPath(configFile ?? 'manifest.config.ts').replace(/toolaby\.js$/, 'toolaby.manifest.js')}';`,
|
|
152
152
|
``,
|
|
153
|
-
`...toolaby, // the extension's id, the storage permission,
|
|
153
|
+
`...toolaby, // the extension's id, the storage permission, Toolaby's address`,
|
|
154
154
|
...(appPopup ? [`action: { default_popup: 'toolaby-popup.html' },`] : []),
|
|
155
155
|
...(appSidePanel ? [`side_panel: { default_path: 'toolaby-sidepanel.html' }, // and 'sidePanel' among the permissions`] : []),
|
|
156
156
|
...(worker ? [] : [`background: { service_worker: '${workerFile}', type: 'module' },`]),
|
|
@@ -159,7 +159,7 @@ export function detectProject(o) {
|
|
|
159
159
|
const viteFile = first(has, ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) ?? 'vite.config.ts';
|
|
160
160
|
const inputs = [appPopup ? `popup: '${appPopup}'` : null, appSidePanel ? `sidepanel: '${appSidePanel}'` : null].filter(Boolean).join(', ');
|
|
161
161
|
const vite = [
|
|
162
|
-
`// ${viteFile} — CRXJS builds only the pages the manifest names: this keeps ${appPopup && appSidePanel ? 'your popup and side panel' : appSidePanel ? 'your side panel' : 'your popup'} built, for
|
|
162
|
+
`// ${viteFile} — CRXJS builds only the pages the manifest names: this keeps ${appPopup && appSidePanel ? 'your popup and side panel' : appSidePanel ? 'your side panel' : 'your popup'} built, for Toolaby's to hand over to`,
|
|
163
163
|
`export default defineConfig({`,
|
|
164
164
|
` plugins: [crx({ manifest })],`,
|
|
165
165
|
` build: { rollupOptions: { input: { ${inputs} } } },`,
|
package/lib/summary.js
CHANGED
|
@@ -21,51 +21,51 @@ export function wiringSummary(o) {
|
|
|
21
21
|
if (o.appPopup) {
|
|
22
22
|
rows.push(o.popupSwap
|
|
23
23
|
? { tone: 'warn', label: 'Popup', text: `Your code handles the popup itself (${o.popupSwap.call} in ${o.popupSwap.file}), so keep yours in front`, fix: 'own-popup' }
|
|
24
|
-
: { tone: 'ok', label: 'Popup', text: `
|
|
24
|
+
: { tone: 'ok', label: 'Popup', text: `Toolaby checks first, then opens ${o.appPopup}` });
|
|
25
25
|
}
|
|
26
26
|
else if (ownPopup && o.keptForSwap && o.popupSwap)
|
|
27
27
|
rows.push({ tone: 'ok', label: 'Popup', text: `Yours stays in front, as your code handles it (${o.popupSwap.call} in ${o.popupSwap.file})` });
|
|
28
28
|
else if (ownPopup)
|
|
29
|
-
rows.push({ tone: 'ok', label: 'Popup', text: `Yours stays in front; toolaby.showPaywall() opens
|
|
29
|
+
rows.push({ tone: 'ok', label: 'Popup', text: `Yours stays in front; toolaby.showPaywall() opens Toolaby's` });
|
|
30
30
|
else if (p.note)
|
|
31
31
|
rows.push({ tone: 'warn', label: 'Popup', text: p.note });
|
|
32
32
|
else if (!o.appSidePanel && !w?.ownSidePanel)
|
|
33
|
-
rows.push({ tone: 'ok', label: 'Popup', text: 'None:
|
|
33
|
+
rows.push({ tone: 'ok', label: 'Popup', text: 'None: Toolaby steps in where your code calls toolaby.gate()' });
|
|
34
34
|
if (o.appSidePanel)
|
|
35
|
-
rows.push({ tone: 'ok', label: 'Side panel', text: `
|
|
35
|
+
rows.push({ tone: 'ok', label: 'Side panel', text: `Toolaby checks first, then opens ${o.appSidePanel}` });
|
|
36
36
|
else if (w?.ownSidePanel)
|
|
37
|
-
rows.push({ tone: 'ok', label: 'Side panel', text: `Yours stays in front; toolaby.showPaywall() opens
|
|
37
|
+
rows.push({ tone: 'ok', label: 'Side panel', text: `Yours stays in front; toolaby.showPaywall() opens Toolaby's` });
|
|
38
38
|
// The background: what starts the Wall.
|
|
39
39
|
if (w) {
|
|
40
40
|
const b = w.background;
|
|
41
41
|
if (b.kind === 'new')
|
|
42
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts
|
|
42
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts Toolaby` });
|
|
43
43
|
else if (b.kind === 'wrap')
|
|
44
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${b.wraps} runs as before, and
|
|
44
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.wraps} runs as before, and Toolaby starts with it` });
|
|
45
45
|
else if (b.kind === 'started')
|
|
46
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts
|
|
46
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${b.file} starts Toolaby` });
|
|
47
47
|
else if (b.kind === 'lines')
|
|
48
48
|
rows.push({ tone: 'todo', label: 'Background', text: `Add these 2 lines to the top of ${b.file}:`, code: `import { toolaby } from '${importPath(b.file)}';\n\ntoolaby.startBackground();` });
|
|
49
49
|
else if (b.kind === 'gone')
|
|
50
50
|
rows.push({ tone: 'todo', label: 'Background', text: `${b.wraps} is no longer in the folder. Put your background's file name in manifest.json:`, code: '"background": { "service_worker": "your-background.js" }', again: true });
|
|
51
51
|
else
|
|
52
|
-
rows.push({ tone: 'todo', label: 'Background', text: `
|
|
52
|
+
rows.push({ tone: 'todo', label: 'Background', text: `Toolaby can't load a file named "${b.file}". Rename it to ${b.to}, and in manifest.json:`, code: `"background": { "service_worker": "${b.to}" }`, again: true });
|
|
53
53
|
}
|
|
54
54
|
else if (p.extraFiles?.length)
|
|
55
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${p.label} starts
|
|
55
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.label} starts Toolaby (${p.extraFiles.map((f) => f.to).join(', ')})` });
|
|
56
56
|
else if (p.createWorker)
|
|
57
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${p.createWorker.file} starts
|
|
57
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.createWorker.file} starts Toolaby` });
|
|
58
58
|
else if (p.worker && !o.workerStarts)
|
|
59
59
|
rows.push({ tone: 'todo', label: 'Background', text: `Add these 2 lines to the top of ${p.worker}:`, code: p.workerLines.trim() });
|
|
60
60
|
else if (p.worker)
|
|
61
|
-
rows.push({ tone: 'ok', label: 'Background', text: `${p.worker} starts
|
|
61
|
+
rows.push({ tone: 'ok', label: 'Background', text: `${p.worker} starts Toolaby` });
|
|
62
62
|
// With no page of the Wall's in front, the paywall opens only where the code asks for it: when nothing does, the lines.
|
|
63
63
|
if (!o.appPopup && !o.appSidePanel && o.gates === false && p.kind === 'plain') {
|
|
64
64
|
rows.push({ tone: 'todo', label: 'Paywall', text: p.built ? 'Nothing opens it yet. Where your paid action starts, add (the import is the path to toolaby.js):' : 'Nothing opens it yet. Where your paid action starts, in a module script, add:', code: `import { toolaby } from '${p.built ? './toolaby.js' : '/toolaby.js'}';\n\nconst permit = await toolaby.gate();\nif (!permit.allowed) return toolaby.showPaywall(permit);` });
|
|
65
65
|
}
|
|
66
66
|
// The manifest: what it gained, or, generated by a framework, what its config needs.
|
|
67
67
|
if (w)
|
|
68
|
-
rows.push({ tone: 'ok', label: 'Manifest', text: w.added.length ? w.added.join(' · ') : 'Has what
|
|
68
|
+
rows.push({ tone: 'ok', label: 'Manifest', text: w.added.length ? w.added.join(' · ') : 'Has what Toolaby needs' });
|
|
69
69
|
for (const sn of p.snippets)
|
|
70
70
|
rows.push({ tone: 'todo', label: 'Manifest', text: `Add to ${sn.file}:`, code: sn.code.trim() });
|
|
71
71
|
if (p.built)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.4",
|
|
4
4
|
"description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one; check it, and give coding agents the Wall as an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|