toolaby 1.6.0 → 1.6.2
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 +5 -3
- package/bin.mjs +210 -84
- package/lib/check.js +31 -18
- package/lib/mcp.js +40 -8
- package/lib/project.js +5 -3
- package/lib/safe.js +198 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Every command acts on the test Wall (`test.toolaby.app`) unless told `--live` (`
|
|
|
12
12
|
|
|
13
13
|
## What it does
|
|
14
14
|
|
|
15
|
-
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools.
|
|
15
|
+
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools. It opens only the Wall's own page, over https (or on `localhost`, for a Wall on your machine); any other address is refused and nothing is signed in.
|
|
16
16
|
|
|
17
17
|
**`toolaby wire <tool-id>`**, run in your extension's folder, wires that extension to your tool:
|
|
18
18
|
|
|
@@ -24,13 +24,15 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
|
|
|
24
24
|
|
|
25
25
|
**`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
|
|
26
26
|
|
|
27
|
-
**`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. Nothing is sent anywhere.
|
|
27
|
+
**`toolaby pack [folder]`** makes the zip for the Chrome Web Store from your built extension: the manifest's `key` left out (the store refuses one it did not issue), and refused if the build carries a Test key or points at `localhost`. A store zip is public, so files that look private (`*.pem`, `*.key`, `*.p12` and other keys, `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; `--allow-private` packs them. Dotfiles and links that lead out of the folder are never packed. Nothing is sent anywhere.
|
|
28
28
|
|
|
29
29
|
**`toolaby tools`** lists your tools and their ids. **`toolaby whoami`** says who you are signed in as. **`toolaby logout`** signs the command out and revokes its session on the server.
|
|
30
30
|
|
|
31
31
|
## Where things live
|
|
32
32
|
|
|
33
33
|
- Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
|
|
34
|
-
- The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI). `TOOLABY_WALL` or `--wall <url>` names any other Wall.
|
|
34
|
+
- The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI), and is sent to one Wall only: Live (`toolaby.app`), or the one `TOOLABY_TOKEN_WALL` names — `TOOLABY_TOKEN_WALL=https://test.toolaby.app` for a Test session. If that Wall refuses it, the session `toolaby login` stored for that Wall is used instead. `TOOLABY_WALL` or `--wall <url>` names any other Wall.
|
|
35
|
+
|
|
36
|
+
`wire` and `create` write only inside the folder they work on, and never through a symbolic link. A manifest, a framework's config or a Wall that names a place outside it (`../`, an absolute path, a link) is refused before anything is written. `create` writes only the files it knows a new extension has.
|
|
35
37
|
|
|
36
38
|
The files the command writes into your extension contain nothing secret: your tool's id, your Wall's address and the *public* key your extension verifies licences with — the same things every copy of your extension carries once it is published.
|
package/bin.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// toolaby mcp the Wall for coding agents: an MCP server on stdio (lib/mcp.js)
|
|
11
11
|
// toolaby webhooks events the six events, with an example of each
|
|
12
12
|
// toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
|
|
13
|
+
// toolaby webhooks secret this machine's development secret, which --to signs with
|
|
13
14
|
// toolaby webhooks endpoints the workspace's endpoints
|
|
14
15
|
// toolaby webhooks add <url> register an endpoint; the secret is printed once
|
|
15
16
|
// toolaby webhooks remove <id> remove one
|
|
@@ -23,14 +24,15 @@
|
|
|
23
24
|
// session lives in ~/.config/toolaby/credentials.json (mode 600), one per
|
|
24
25
|
// side; a command that needs one and has none signs you in on the spot.
|
|
25
26
|
// `logout` revokes it on the server before forgetting it. TOOLABY_TOKEN
|
|
26
|
-
// stands in for the file where there is no home (CI)
|
|
27
|
-
//
|
|
27
|
+
// stands in for the file where there is no home (CI), for one Wall alone:
|
|
28
|
+
// Live, or the one TOOLABY_TOKEN_WALL names. TOOLABY_WALL or `--wall <url>`
|
|
29
|
+
// names any other Wall (a laptop's, a self-hosted one).
|
|
28
30
|
//
|
|
29
31
|
// The wiring rules — what a folder is and what its manifest needs — are the
|
|
30
32
|
// platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
|
|
31
33
|
// so this and the dashboard's Wire button never disagree.
|
|
32
|
-
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
|
|
33
|
-
import { join, resolve, dirname, relative, basename } from 'node:path';
|
|
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';
|
|
34
36
|
import { deflateRawSync } from 'node:zlib';
|
|
35
37
|
import { homedir } from 'node:os';
|
|
36
38
|
import { spawn } from 'node:child_process';
|
|
@@ -39,8 +41,9 @@ import { wireManifest, drawsItself, WORKER_LINES } from './lib/manifest.js';
|
|
|
39
41
|
import { detectProject, CODE_FILES, POPUP_FILES, SIDE_PANEL_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE, AGENT_FILES, AGENT_ENTRY_FILES } from './lib/project.js';
|
|
40
42
|
import { runMcp } from './lib/mcp.js';
|
|
41
43
|
import { runCheck } from './lib/check.js';
|
|
44
|
+
import { inside, signInPage, opener, privateKind, privateContent, oneLine, fetchBounded, fetchHere, readText, SCAFFOLD_FILES } from './lib/safe.js';
|
|
42
45
|
|
|
43
|
-
const VERSION = '1.6.
|
|
46
|
+
const VERSION = '1.6.2';
|
|
44
47
|
const CLIENT_ID = 'toolaby-cli';
|
|
45
48
|
const argv = process.argv.slice(2);
|
|
46
49
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -52,11 +55,18 @@ const opt = (k, d = null) => { const i = rest.indexOf(k); return i >= 0 && rest[
|
|
|
52
55
|
const flag = (k) => rest.includes(k) || (k === '--force' && command === 'upgrade');
|
|
53
56
|
const WALLS = { test: 'https://test.toolaby.app', live: 'https://toolaby.app' };
|
|
54
57
|
const live = flag('--live');
|
|
55
|
-
|
|
58
|
+
// An address as fetch() reads it: a URL loses its tabs and line breaks when it is parsed, and kept in the name every
|
|
59
|
+
// message prints, a --wall or a TOOLABY_WALL with one in it drew lines of its own (25 Sep 2026).
|
|
60
|
+
const address = (s) => s.replace(/[\t\n\r]/g, '').replace(/\/$/, '');
|
|
61
|
+
const wall = address(opt('--wall') ?? process.env.TOOLABY_WALL ?? (live ? WALLS.live : WALLS.test));
|
|
56
62
|
/** The side, for every message: what a developer must never be unsure of. */
|
|
57
63
|
const side = wall === WALLS.live ? 'Live' : wall === WALLS.test ? 'Test' : wall;
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
// Every line leaves without terminal controls: names, paths and answers come from folders and servers this command does
|
|
65
|
+
// not own, and one could clear the screen, draw a fake ✓ and hide what followed (25 Sep 2026). The command writes none,
|
|
66
|
+
// nor any character that shows nothing — a zero width, a mark of direction, a filler — which could hide a word.
|
|
67
|
+
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
|
+
const out = (s = '') => console.log(plain(s));
|
|
69
|
+
const fail = (s) => { console.error(`\n ${plain(s)}\n`); process.exit(1); };
|
|
60
70
|
|
|
61
71
|
// ── The session: one per Wall, in the user's config, readable by them alone ─────────────────────────────────────────
|
|
62
72
|
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'toolaby');
|
|
@@ -67,22 +77,38 @@ function writeCredentials(all) {
|
|
|
67
77
|
writeFileSync(CREDENTIALS, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
68
78
|
try { chmodSync(CREDENTIALS, 0o600); } catch { /* not every filesystem has modes */ }
|
|
69
79
|
}
|
|
70
|
-
|
|
80
|
+
// TOOLABY_TOKEN goes to one Wall, the one it was made for: the one TOOLABY_TOKEN_WALL names, or — with none named —
|
|
81
|
+
// Live. A --wall or a TOOLABY_WALL, which a cloned project's settings can set, once received it wherever it pointed,
|
|
82
|
+
// and with no Wall named it went to Test and Live alike, so a CI job's Live session reached Test too (25 Sep 2026).
|
|
83
|
+
// Refused there (401), it gives way to the session `toolaby login` stored for that Wall, and is not sent again.
|
|
84
|
+
const tokenWall = address(process.env.TOOLABY_TOKEN_WALL || WALLS.live);
|
|
85
|
+
let envRefused = false;
|
|
86
|
+
const envToken = () => (process.env.TOOLABY_TOKEN && !envRefused && tokenWall === wall ? process.env.TOOLABY_TOKEN : null);
|
|
87
|
+
const storedToken = () => envToken() || readCredentials()[wall]?.token || null;
|
|
88
|
+
/** Said where a session is missing and TOOLABY_TOKEN is set: it is kept for another Wall, and a CI job should know. */
|
|
89
|
+
const envElsewhere = () => (process.env.TOOLABY_TOKEN && !envRefused && !envToken() ? `\n TOOLABY_TOKEN is sent to ${tokenWall} alone; TOOLABY_TOKEN_WALL=${wall} sends it here instead.` : '');
|
|
71
90
|
|
|
72
91
|
async function api(path, { method = 'GET', body, token = storedToken(), raw = false } = {}) {
|
|
73
|
-
|
|
92
|
+
// Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
|
|
93
|
+
const res = await fetchHere(`${wall}${path}`, {
|
|
74
94
|
method,
|
|
75
95
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
76
96
|
body: body ? JSON.stringify(body) : undefined,
|
|
77
97
|
}).catch((e) => { throw new Error(`Could not reach ${wall}: ${e.message}`); });
|
|
98
|
+
if (res.status === 401 && token && token === envToken()) {
|
|
99
|
+
envRefused = true;
|
|
100
|
+
const stored = storedToken();
|
|
101
|
+
if (stored && stored !== token) return api(path, { method, body, token: stored, raw });
|
|
102
|
+
}
|
|
78
103
|
if (raw) return res;
|
|
79
104
|
const json = await res.json().catch(() => ({}));
|
|
80
105
|
return { status: res.status, ok: res.ok, json };
|
|
81
106
|
}
|
|
82
107
|
|
|
83
108
|
// ── login: RFC 8628, the way GitHub's and Stripe's command lines sign in ─────────────────────────────────────────────
|
|
109
|
+
/** Opens the page, never through a shell (lib/safe.js); only a page signInPage() let through gets here. */
|
|
84
110
|
function openBrowser(url) {
|
|
85
|
-
const [cmd, args] = process.platform
|
|
111
|
+
const [cmd, args] = opener(process.platform, url);
|
|
86
112
|
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); } catch { /* the URL is printed anyway */ }
|
|
87
113
|
}
|
|
88
114
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
@@ -92,8 +118,11 @@ async function login() {
|
|
|
92
118
|
if (!start.ok || !start.json.device_code) fail(`The Wall at ${wall} did not start a sign-in (HTTP ${start.status}). Is the address right? --wall names another.`);
|
|
93
119
|
const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
|
|
94
120
|
const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
// The Wall's own page, over https (http on this machine), or nothing opened and nothing signed in: an address the
|
|
122
|
+
// Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
|
|
123
|
+
const page = signInPage(link, wall);
|
|
124
|
+
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.`);
|
|
125
|
+
out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
|
|
97
126
|
openBrowser(page);
|
|
98
127
|
let every = Math.max(1, Number(interval) || 5) * 1000;
|
|
99
128
|
const until = Date.now() + Number(expires_in) * 1000;
|
|
@@ -109,44 +138,46 @@ async function login() {
|
|
|
109
138
|
const all = readCredentials();
|
|
110
139
|
all[wall] = { token, email, at: new Date().toISOString() };
|
|
111
140
|
writeCredentials(all);
|
|
112
|
-
out(`\n\n Signed in${email ? ` as ${email}` : ''} · ${side}.\n`);
|
|
141
|
+
out(`\n\n Signed in${email ? ` as ${oneLine(email)}` : ''} · ${side}.\n`);
|
|
113
142
|
return token;
|
|
114
143
|
}
|
|
115
144
|
const why = poll.json.error;
|
|
116
145
|
if (why === 'slow_down') every += 5000;
|
|
117
146
|
else if (why === 'expired_token') fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
118
147
|
else if (why === 'access_denied') fail('The sign-in was denied in the browser. Nothing was signed in.');
|
|
119
|
-
else if (why && why !== 'authorization_pending') fail(`The Wall answered "${why}"${poll.json.error_description ? `: ${poll.json.error_description}` : ''}.`);
|
|
148
|
+
else if (why && why !== 'authorization_pending') fail(`The Wall answered "${oneLine(why)}"${poll.json.error_description ? `: ${oneLine(poll.json.error_description)}` : ''}.`);
|
|
120
149
|
}
|
|
121
150
|
fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
122
151
|
}
|
|
123
152
|
|
|
124
153
|
/** A token to act with: the stored one, else a sign-in right here — `wire` in a fresh terminal is one command, not two. */
|
|
125
154
|
async function tokenOrLogin() {
|
|
126
|
-
|
|
155
|
+
const token = storedToken();
|
|
156
|
+
if (!token && envElsewhere()) out(envElsewhere());
|
|
157
|
+
return token ?? (await login());
|
|
127
158
|
}
|
|
128
159
|
|
|
129
160
|
async function whoami() {
|
|
130
161
|
const token = storedToken();
|
|
131
|
-
if (!token) fail(`Not signed in to ${side} (${wall}). Run \`toolaby login${live ? ' --live' : ''}
|
|
162
|
+
if (!token) fail(`Not signed in to ${side} (${wall}). Run \`toolaby login${live ? ' --live' : ''}\`.${envElsewhere()}`);
|
|
132
163
|
const me = await api('/api/cli/me', { token });
|
|
133
164
|
if (me.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
|
|
134
165
|
if (!me.ok) fail(`The Wall answered HTTP ${me.status}.`);
|
|
135
|
-
out(`\n ${me.json.email} · ${side} · ${wall}\n`);
|
|
136
|
-
for (const w of me.json.workspaces) out(` ${w.name} (${w.slug}, ${w.role}) ${w.site}`);
|
|
166
|
+
out(`\n ${oneLine(me.json.email)} · ${side} · ${wall}\n`);
|
|
167
|
+
for (const w of me.json.workspaces) out(` ${oneLine(w.name)} (${oneLine(w.slug)}, ${oneLine(w.role)}) ${oneLine(w.site)}`);
|
|
137
168
|
out();
|
|
138
169
|
}
|
|
139
170
|
|
|
140
171
|
async function logout() {
|
|
141
172
|
const all = readCredentials();
|
|
142
173
|
const entry = all[wall];
|
|
143
|
-
if (!entry && !
|
|
174
|
+
if (!entry && !envToken()) { out(`\n Not signed in to ${side} (${wall}).\n`); return; }
|
|
144
175
|
// Revoked on the server first; forgotten here whatever the server said — but the difference is reported.
|
|
145
|
-
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ??
|
|
176
|
+
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ?? envToken() }).catch(() => ({ ok: false, status: 0 }));
|
|
146
177
|
delete all[wall];
|
|
147
178
|
writeCredentials(all);
|
|
148
179
|
out(res.ok ? `\n Signed out of ${side} (${wall}): the session was revoked and forgotten.\n` : `\n The session was forgotten here, but the Wall could not be reached to revoke it (HTTP ${res.status}); it expires on its own, or sign it out from the dashboard.\n`);
|
|
149
|
-
if (
|
|
180
|
+
if (envToken()) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
|
|
150
181
|
}
|
|
151
182
|
|
|
152
183
|
async function tools() {
|
|
@@ -156,7 +187,7 @@ async function tools() {
|
|
|
156
187
|
if (!res.ok) fail(`The Wall answered HTTP ${res.status}.`);
|
|
157
188
|
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; }
|
|
158
189
|
out(`\n ${side}\n`);
|
|
159
|
-
for (const t of res.json.tools) out(` ${t.slug.padEnd(40)} ${t.name}${t.bundle ? ' (bundle)' : ''} ${t.site}`);
|
|
190
|
+
for (const t of res.json.tools) out(` ${oneLine(t.slug).padEnd(40)} ${oneLine(t.name)}${t.bundle ? ' (bundle)' : ''} ${oneLine(t.site)}`);
|
|
160
191
|
out();
|
|
161
192
|
}
|
|
162
193
|
|
|
@@ -172,30 +203,56 @@ function listTree(dir, depth, prefix = '', acc = new Set()) {
|
|
|
172
203
|
}
|
|
173
204
|
return acc;
|
|
174
205
|
}
|
|
175
|
-
const readJson = (path) => { try { return JSON.parse(
|
|
206
|
+
const readJson = (path) => { try { return JSON.parse(readText(path)); } catch { return null; } };
|
|
207
|
+
/** 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. */
|
|
208
|
+
const readOr = (path, none = null) => { try { return readText(path); } catch { return none; } };
|
|
176
209
|
/** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
|
|
177
|
-
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(
|
|
210
|
+
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readText(path).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
|
|
178
211
|
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
212
|
+
/** Whether anything is at the path, a link to nowhere included (existsSync follows a link, and says no for a dangling one). */
|
|
213
|
+
const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Where each path lands in `root`, every one settled before the first is written: inside the folder and through no
|
|
217
|
+
* symbolic link (lib/safe.js), or a refusal and nothing written. What a manifest, a framework's config or the Wall
|
|
218
|
+
* names is theirs to say; where it is written is this command's. `named` says where a path came from, for the refusal.
|
|
219
|
+
*/
|
|
220
|
+
function placesIn(root, paths, named = {}) {
|
|
221
|
+
const at = {}; const refused = [];
|
|
222
|
+
for (const rel of new Set(paths)) {
|
|
223
|
+
const r = inside(root, rel);
|
|
224
|
+
if (r.why) refused.push(`${JSON.stringify(rel)}${named[rel] ? ` (${named[rel]})` : ''}: ${oneLine(r.why)}`);
|
|
225
|
+
else at[rel] = r.path;
|
|
226
|
+
}
|
|
227
|
+
if (refused.length) fail(`Refused:\n\n${refused.map((r) => ` ${r}`).join('\n')}\n\n This command writes only inside ${root}, and never through a symbolic link. Nothing was written.`);
|
|
228
|
+
return at;
|
|
229
|
+
}
|
|
179
230
|
|
|
180
231
|
/** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
|
|
181
232
|
async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
182
233
|
const token = await tokenOrLogin();
|
|
183
234
|
const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey); if (surfaces) q.set('surfaces', surfaces);
|
|
235
|
+
// The folder's manifest names another extension than the tool's: switched only when asked (--adopt-key).
|
|
236
|
+
if (manifestKey && flag('--adopt-key')) q.set('adoptKey', '1');
|
|
184
237
|
const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
|
|
185
238
|
if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey, surfaces); }
|
|
239
|
+
if (res.status === 409 && res.json?.error === 'identity_conflict') {
|
|
240
|
+
const offered = oneLine(res.json.offered); const current = oneLine(res.json.current);
|
|
241
|
+
fail(`This folder's manifest names the extension ${offered}; ${slug} is bound to ${current}. Nothing was written.\n\n Run it again with --adopt-key to make ${offered} the tool's extension: its key then works from that one, and ${current}'s copies are named no more.\n Or take the key out of this folder's manifest, and the tool's own goes in.`);
|
|
242
|
+
}
|
|
186
243
|
if (res.status === 404) {
|
|
187
244
|
// Say who the command is: the tool may well exist — in a workspace of another account of theirs.
|
|
188
245
|
const me = await api('/api/cli/me', { token }).catch(() => null);
|
|
189
|
-
const who = me?.ok ? `\n\n You are signed in as ${me.json.email}${me.json.workspaces.length ? ` (workspaces: ${me.json.workspaces.map((w) => 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.` : '';
|
|
190
|
-
fail(`${res.json.message ?? `No tool "${slug}" in your workspaces.`}${who}`);
|
|
246
|
+
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.` : '';
|
|
247
|
+
fail(`${oneLine(res.json.message ?? `No tool "${slug}" in your workspaces.`)}${who}`);
|
|
191
248
|
}
|
|
192
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
249
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
193
250
|
return res.json;
|
|
194
251
|
}
|
|
195
252
|
|
|
196
253
|
async function wire() {
|
|
197
254
|
const slug = positional[0];
|
|
198
|
-
if (!isId(slug)) fail(command === 'upgrade' ? 'usage: toolaby upgrade <tool-id> [--into <folder>] — `toolaby tools` lists your ids' : 'usage: toolaby wire <tool-id> [--into <folder>] [--force] — `toolaby tools` lists your ids');
|
|
255
|
+
if (!isId(slug)) fail(command === 'upgrade' ? 'usage: toolaby upgrade <tool-id> [--into <folder>] — `toolaby tools` lists your ids' : 'usage: toolaby wire <tool-id> [--into <folder>] [--force] [--adopt-key] — `toolaby tools` lists your ids');
|
|
199
256
|
const dir = resolve(opt('--into') ?? '.');
|
|
200
257
|
if (!existsSync(dir)) fail(`${dir} does not exist`);
|
|
201
258
|
const tree = listTree(dir, 3);
|
|
@@ -207,13 +264,13 @@ async function wire() {
|
|
|
207
264
|
const site = plan.site;
|
|
208
265
|
const manifestKey = plan.identity?.manifestKey ?? null;
|
|
209
266
|
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
210
|
-
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ?
|
|
267
|
+
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readOr(join(dir, configSource)) : null, site, tree, manifestKey });
|
|
211
268
|
// Nothing recognised: nothing is written and nothing guessed. A manifest below the top is where to point --into; otherwise the Set up page's Do it myself has the files and the three things to paste.
|
|
212
|
-
if (project.kind === 'unknown') fail(`${dir} has no manifest.json at the top and is not a WXT, CRXJS or Plasmo project${project.foundManifest ? ` — there is one at ${project.foundManifest}: run this with --into ${dirname(project.foundManifest) || '.'}` : ''}.\n Nothing was written. For a layout of your own, the tool's Set up page → Do it myself has the files and the three things to paste.`);
|
|
269
|
+
if (project.kind === 'unknown') fail(`${dir} has no manifest.json at the top and is not a WXT, CRXJS or Plasmo project${project.foundManifest ? ` — there is one at ${oneLine(project.foundManifest)}: run this with --into ${oneLine(dirname(project.foundManifest)) || '.'}` : ''}.\n Nothing was written. For a layout of your own, the tool's Set up page → Do it myself has the files and the three things to paste.`);
|
|
213
270
|
|
|
214
271
|
// The extension's own popup and side panel: the manifest's, or — wired before — what the existing configuration remembers.
|
|
215
272
|
const configPath = join(dir, `${project.codeDir}toolaby.config.js`);
|
|
216
|
-
const oldConfig = existsSync(configPath) ?
|
|
273
|
+
const oldConfig = existsSync(configPath) ? readOr(configPath, '') : '';
|
|
217
274
|
const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
|
|
218
275
|
const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
|
|
219
276
|
// An extension whose own pages draw the panel — what `create` makes, or "your popup first" — keeps them in front.
|
|
@@ -222,9 +279,19 @@ async function wire() {
|
|
|
222
279
|
const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
|
|
223
280
|
const appSidePanel = wiring ? wiring.appSidePanel : project.wrapper ? (project.appSidePanel ?? rememberedSidePanel) : null;
|
|
224
281
|
const targets = filesToWrite(project, appPopup, appSidePanel, { popup: !!wiring?.ownPopup, sidePanel: !!wiring?.ownSidePanel });
|
|
282
|
+
// AGENTS.md and CLAUDE.md, for coding agents, only where the project has none: its own are never replaced.
|
|
283
|
+
const entries = AGENT_ENTRY_FILES.filter((f) => !has(f) && typeof plan.files[f] === 'string');
|
|
284
|
+
// What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
|
|
285
|
+
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)));
|
|
286
|
+
// 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.
|
|
287
|
+
const worker = wiring ? wiring.writeWorker : project.createWorker?.file ?? null;
|
|
288
|
+
const at = placesIn(dir, [...targets, ...entries, ...stale, ...(worker ? [worker] : []), ...(wiring?.did.length ? ['manifest.json'] : [])], worker ? { [worker]: 'the background the manifest names' } : {});
|
|
289
|
+
// Taken for missing, it may only be out of the listing's sight — spelt "./background.js", in dist/, or deeper down:
|
|
290
|
+
// the developer's own background is never replaced by two lines of ours.
|
|
291
|
+
if (worker && there(at[worker])) fail(`${JSON.stringify(worker)}, the background the manifest names, is already in ${dir}; it would have been replaced. Nothing was written.\n Name it in the manifest as the folder spells it — or, if a build writes it, run this with --into the folder whose manifest.json names it.`);
|
|
225
292
|
const present = targets.filter((f) => existsSync(join(dir, f)));
|
|
226
293
|
if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
|
|
227
|
-
const write = (rel, content) => { mkdirSync(dirname(
|
|
294
|
+
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
228
295
|
// A command older than the Wall asks for files the Wall no longer hands out: said, with the way to the current one, rather than a crash.
|
|
229
296
|
// The agent files come from Walls that write them; one that does not is older, not newer, than this command.
|
|
230
297
|
const agentPaths = new Set(AGENT_FILES.map((f) => f.path));
|
|
@@ -232,12 +299,9 @@ async function wire() {
|
|
|
232
299
|
if (missing.length) fail(`This command (${VERSION}) is older than the Wall at ${wall}: it expects ${missing.join(', ')}, which the Wall no longer hands out.\n Update it: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz ${command} ${slug}`);
|
|
233
300
|
const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
|
|
234
301
|
for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
// What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
|
|
239
|
-
const removed = [];
|
|
240
|
-
for (const at of new Set([project.codeDir, project.popupDir, 'public/', 'src/public/'])) for (const f of STALE_FILES) { const p = join(dir, `${at}${f}`); if (existsSync(p)) { unlinkSync(p); removed.push(`${at}${f}`); } }
|
|
302
|
+
for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
|
|
303
|
+
const ownAgents = has('AGENTS.md') && !readOr(join(dir, 'AGENTS.md'), '').includes('TOOLABY.md');
|
|
304
|
+
for (const p of stale) unlinkSync(at[p]);
|
|
241
305
|
const did = [];
|
|
242
306
|
let ownWorker = null;
|
|
243
307
|
if (wiring) {
|
|
@@ -253,12 +317,13 @@ async function wire() {
|
|
|
253
317
|
ownWorker = { file: project.worker, classic: false };
|
|
254
318
|
}
|
|
255
319
|
|
|
256
|
-
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
257
|
-
|
|
320
|
+
out(`\n ${oneLine(plan.name)} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
321
|
+
// Each thing done is a line, and names what the manifest and the Wall named: the popup, the background, the address.
|
|
322
|
+
out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.map(oneLine).join(',\n ')}` : ''}.\n`);
|
|
258
323
|
if (ownAgents) out(' Your AGENTS.md is your own: add a line so coding agents find the Wall\'s guide — "Before changing anything paid, read TOOLABY.md."\n');
|
|
259
|
-
if (
|
|
324
|
+
if (stale.length) out(` Removed: ${stale.join(', ')} — earlier files, now inside toolaby.js.\n`);
|
|
260
325
|
for (const sn of project.snippets) out(` Add to ${sn.file} — yours to paste, the manifest being generated:\n\n${sn.code.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
261
|
-
if (ownWorker) out(` Your background, ${ownWorker.file}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
326
|
+
if (ownWorker) out(` Your background, ${oneLine(ownWorker.file)}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
262
327
|
if (project.built) out(` ${BUILD_COPY_NOTE}\n`);
|
|
263
328
|
out(` Next: ${project.kind === 'plain' && !project.built ? '' : 'build or start the dev server, then '}chrome://extensions → reload it. The tool's Set up page says "checked in" the moment it starts.`);
|
|
264
329
|
if (project.note) out(` ${project.note}\n`);
|
|
@@ -271,36 +336,49 @@ async function create() {
|
|
|
271
336
|
const slug = positional[0];
|
|
272
337
|
if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
|
|
273
338
|
const dir = resolve(opt('--dir') ?? slug);
|
|
274
|
-
if (
|
|
339
|
+
if (there(dir)) fail(`${dir} already exists`);
|
|
275
340
|
// Which surfaces this extension is made with, asked first: the files for coding agents name only what is written.
|
|
276
341
|
const asked = opt('--surfaces');
|
|
277
342
|
if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
|
|
278
343
|
const surfaces = surfacesOf(asked);
|
|
279
344
|
const plan = await filesFor(slug, true, null, surfaces);
|
|
280
|
-
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold :
|
|
345
|
+
// The scaffold's own files as the Wall names them (older Walls did not, hence the list), and only names this command
|
|
346
|
+
// knows (lib/safe.js): the Wall's list says which files a new extension has, never where they go. A name joined to
|
|
347
|
+
// the folder as it came let a Wall at another address — --wall, TOOLABY_WALL — write any file, anywhere.
|
|
348
|
+
const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : SCAFFOLD_FILES;
|
|
349
|
+
const unknown = scaffold.filter((f) => !SCAFFOLD_FILES.includes(f));
|
|
350
|
+
if (unknown.length) fail(`The Wall at ${wall} named ${unknown.map((f) => JSON.stringify(f)).join(', ')} among a new extension's files: not ${unknown.length > 1 ? 'files' : 'a file'} this command (${VERSION}) writes.\n Nothing was written. If the Wall is newer than this command, update it: npm install -g toolaby@latest`);
|
|
284
351
|
// Both surfaces (the default), one of them, or none at all for a tool that works from the background — and beside each,
|
|
285
352
|
// the Wall's page for it, which toolaby.showPaywall() opens when an action is refused (the extension's own pages stay in front).
|
|
286
353
|
const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold), ...(hasPopup(surfaces) ? POPUP_FILES : []), ...(hasSidePanel(surfaces) ? SIDE_PANEL_FILES : [])];
|
|
287
354
|
const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
|
|
288
355
|
if (absent.length) fail(`The Wall at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
|
|
356
|
+
// What a coding agent reads: the guide with this tool's features and plans, the skill, AGENTS.md and CLAUDE.md.
|
|
357
|
+
const agentFiles = AGENT_FILES.filter((f) => typeof plan.files[f.name] === 'string');
|
|
358
|
+
const entries = AGENT_ENTRY_FILES.filter((f) => typeof plan.files[f] === 'string');
|
|
359
|
+
const icons = [16, 48, 128].map((s) => `icons/icon${s}.png`);
|
|
360
|
+
const at = placesIn(dir, [...wanted, 'manifest.json', ...icons, ...agentFiles.map((f) => f.path), ...entries]);
|
|
361
|
+
// The icons first, each within a megabyte and half a minute, and nothing written until they are in: a Wall at another
|
|
362
|
+
// address (--wall, TOOLABY_WALL) could send one without end, and the new folder took it until the disk was full
|
|
363
|
+
// (25 Sep 2026).
|
|
364
|
+
const iconBytes = {};
|
|
365
|
+
for (const icon of icons) {
|
|
366
|
+
const got = await fetchBounded(`${wall}/boilerplate/${icon}`).catch((e) => ({ why: e.name === 'TimeoutError' ? 'it took longer than 30 seconds' : oneLine(e.message) }));
|
|
367
|
+
if (got.why || !got.body) fail(`The Wall at ${wall} did not send ${icon}: ${got.why ?? 'it sent more than a megabyte, which no icon is'}. Nothing was written.`);
|
|
368
|
+
iconBytes[icon] = got.body;
|
|
369
|
+
}
|
|
370
|
+
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
289
371
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
290
372
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
291
373
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
292
374
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
293
375
|
// The extension's identity too: every copy gets the id the tool key is bound to, wherever its folder is.
|
|
294
|
-
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(
|
|
295
|
-
for (const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
// What a coding agent reads: the guide with this tool's features and plans, the skill, AGENTS.md and CLAUDE.md.
|
|
300
|
-
for (const f of AGENT_FILES) if (typeof plan.files[f.name] === 'string') write(f.path, plan.files[f.name]);
|
|
301
|
-
for (const f of AGENT_ENTRY_FILES) if (typeof plan.files[f] === 'string') write(f, plan.files[f]);
|
|
376
|
+
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readText(at['manifest.json'])), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces), key: plan.identity?.manifestKey ?? null }), null, 2) + '\n');
|
|
377
|
+
for (const icon of icons) write(icon, iconBytes[icon]);
|
|
378
|
+
for (const f of agentFiles) write(f.path, plan.files[f.name]);
|
|
379
|
+
for (const f of entries) write(f, plan.files[f]);
|
|
302
380
|
const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
|
|
303
|
-
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.buyer ?? `${plan.site}/tools/${slug}`}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${wall}/docs/surfaces\n`);
|
|
381
|
+
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`);
|
|
304
382
|
if (typeof plan.files['TOOLABY.md'] === 'string') out(` With a coding agent: AGENTS.md and TOOLABY.md tell it how this extension sells. For Claude Code, the Wall itself:\n claude mcp add toolaby -- npx -y toolaby@latest mcp${live ? ' --live' : ''}\n`);
|
|
305
383
|
}
|
|
306
384
|
|
|
@@ -313,7 +391,18 @@ async function create() {
|
|
|
313
391
|
// (or one it prints) and POSTs it to the URL — a server on localhost, no
|
|
314
392
|
// tunnel, no deploy — so a handler can be written and run before anything
|
|
315
393
|
// is registered.
|
|
316
|
-
|
|
394
|
+
// This machine's development secret: made once, kept beside the credentials (mode 600), never the same as anyone
|
|
395
|
+
// else's. It was one constant, shipped in the package and printed in the docs, and a server deployed with it took
|
|
396
|
+
// forged events from anyone (25 Sep 2026).
|
|
397
|
+
const DEV_SECRET_FILE = join(CONFIG_DIR, 'dev-webhook-secret');
|
|
398
|
+
function devSecret() {
|
|
399
|
+
try { const kept = readFileSync(DEV_SECRET_FILE, 'utf8').trim(); if (/^whsec_[A-Za-z0-9+/=]{20,}$/.test(kept)) return kept; } catch { /* made below */ }
|
|
400
|
+
const made = `whsec_${randomBytes(24).toString('base64')}`;
|
|
401
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
402
|
+
writeFileSync(DEV_SECRET_FILE, made + '\n', { mode: 0o600 });
|
|
403
|
+
try { chmodSync(DEV_SECRET_FILE, 0o600); } catch { /* not every filesystem has modes */ }
|
|
404
|
+
return made;
|
|
405
|
+
}
|
|
317
406
|
async function catalogue() {
|
|
318
407
|
const res = await api('/api/cli/webhooks/events', { token: await tokenOrLogin() });
|
|
319
408
|
if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`toolaby login${live ? ' --live' : ''}\`.`);
|
|
@@ -325,8 +414,13 @@ async function webhooks() {
|
|
|
325
414
|
if (sub === 'events') {
|
|
326
415
|
const events = await catalogue();
|
|
327
416
|
out(`\n ${side}\n`);
|
|
328
|
-
for (const e of events) out(` ${e.type.padEnd(26)} ${e.description}`);
|
|
329
|
-
out(`\n toolaby webhooks trigger <event> → your registered endpoints\n toolaby webhooks trigger <event> --to <url> → a local server, signed with
|
|
417
|
+
for (const e of events) out(` ${oneLine(e.type).padEnd(26)} ${oneLine(e.description)}`);
|
|
418
|
+
out(`\n toolaby webhooks trigger <event> → your registered endpoints\n toolaby webhooks trigger <event> --to <url> → a local server, signed with this machine's development secret\n toolaby webhooks secret → that secret, for the local server's WALL_WEBHOOK_SECRET\n`);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (sub === 'secret') {
|
|
422
|
+
// For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx toolaby webhooks secret) node server.mjs`.
|
|
423
|
+
process.stdout.write(devSecret() + '\n');
|
|
330
424
|
return;
|
|
331
425
|
}
|
|
332
426
|
if (sub === 'trigger') {
|
|
@@ -335,14 +429,14 @@ async function webhooks() {
|
|
|
335
429
|
const to = opt('--to');
|
|
336
430
|
if (!to) {
|
|
337
431
|
const res = await api('/api/cli/webhooks/trigger', { method: 'POST', body: { type, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
338
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
339
|
-
out(`\n Sent ${res.json.sent} to every endpoint of ${res.json.workspace} · ${side}. Its id is ${res.json.id}; the delivery log is under Configure → Webhooks.\n`);
|
|
432
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
433
|
+
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`);
|
|
340
434
|
return;
|
|
341
435
|
}
|
|
342
436
|
const events = await catalogue();
|
|
343
437
|
const event = events.find((e) => e.type === type);
|
|
344
438
|
if (!event) fail(`No event "${type}". \`toolaby webhooks events\` lists them.`);
|
|
345
|
-
const secret = opt('--secret') ??
|
|
439
|
+
const secret = opt('--secret') ?? devSecret();
|
|
346
440
|
if (!/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) fail('--secret must be a Standard Webhooks secret: whsec_ followed by base64.');
|
|
347
441
|
const id = `msg_${randomBytes(12).toString('base64url')}`;
|
|
348
442
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
@@ -353,18 +447,18 @@ async function webhooks() {
|
|
|
353
447
|
res = await fetch(to, { method: 'POST', headers: { 'Content-Type': 'application/json', 'webhook-id': id, 'webhook-timestamp': String(timestamp), 'webhook-signature': signature, 'User-Agent': `toolaby-cli/${VERSION}` }, body });
|
|
354
448
|
} catch (e) { fail(`Could not reach ${to}: ${e.message}`); }
|
|
355
449
|
const text = (await res.text()).slice(0, 200);
|
|
356
|
-
out(`\n POST ${to} → ${res.status}${text ? ` ${text
|
|
357
|
-
out(` ${type}, signed with ${opt('--secret') ? 'your secret' :
|
|
450
|
+
out(`\n POST ${to} → ${res.status}${text ? ` ${oneLine(text)}` : ''}`);
|
|
451
|
+
out(` ${type}, signed with ${opt('--secret') ? 'your secret' : "this machine's development secret — `npx toolaby webhooks secret` prints it for WALL_WEBHOOK_SECRET"}.\n`);
|
|
358
452
|
if (res.status >= 300) out(' A real delivery would be retried until your server answers 2xx.\n');
|
|
359
453
|
return;
|
|
360
454
|
}
|
|
361
455
|
if (sub === 'endpoints') {
|
|
362
456
|
const ws = opt('--workspace');
|
|
363
457
|
const res = await api(`/api/cli/webhooks/endpoints${ws ? `?workspace=${encodeURIComponent(ws)}` : ''}`, { token: await tokenOrLogin() });
|
|
364
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
365
|
-
out(`\n ${res.json.workspace} · ${side}\n`);
|
|
458
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
459
|
+
out(`\n ${oneLine(res.json.workspace)} · ${side}\n`);
|
|
366
460
|
if (res.json.endpoints.length === 0) out(' None yet. `toolaby webhooks add <url>` registers one.');
|
|
367
|
-
for (const e of res.json.endpoints) out(` ${e.id} ${e.disabled ? 'paused ' : 'on '} ${e.url}${e.description ? ` — ${e.description}` : ''}${e.events.length ? ` (${e.events.join(', ')})` : ''}`);
|
|
461
|
+
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(', '))})` : ''}`);
|
|
368
462
|
out();
|
|
369
463
|
return;
|
|
370
464
|
}
|
|
@@ -373,16 +467,16 @@ async function webhooks() {
|
|
|
373
467
|
if (!url) fail('usage: toolaby webhooks add <url> [--description <text>] [--events a.b,c.d] [--workspace <slug>]');
|
|
374
468
|
const events = (opt('--events') ?? '').split(',').map((e) => e.trim()).filter(Boolean);
|
|
375
469
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'POST', body: { url, description: opt('--description') ?? '', events, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
376
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
377
|
-
out(`\n Added ${res.json.id} → ${res.json.url} · ${res.json.workspace} · ${side}\n\n Signing secret, shown once — keep it where your server keeps secrets:\n\n ${res.json.secret}\n`);
|
|
470
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
471
|
+
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`);
|
|
378
472
|
return;
|
|
379
473
|
}
|
|
380
474
|
if (sub === 'remove') {
|
|
381
475
|
const id = positional[1];
|
|
382
476
|
if (!id) fail('usage: toolaby webhooks remove <ep_…> [--workspace <slug>] — `toolaby webhooks endpoints` lists them');
|
|
383
477
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'DELETE', body: { id, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
384
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
385
|
-
out(`\n Removed ${res.json.removed} · ${res.json.workspace} · ${side}. Deliveries to it stop at once.\n`);
|
|
478
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
479
|
+
out(`\n Removed ${oneLine(res.json.removed)} · ${oneLine(res.json.workspace)} · ${side}. Deliveries to it stop at once.\n`);
|
|
386
480
|
return;
|
|
387
481
|
}
|
|
388
482
|
fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
|
|
@@ -393,7 +487,8 @@ async function webhooks() {
|
|
|
393
487
|
// package and any key but the item's own in an update, and it keeps the id it gave the item either way — the Wall
|
|
394
488
|
// admits that id once the listing's address is on the tool. It refuses a build that would sell on Test (a tk_test_
|
|
395
489
|
// or tk_dev_ key inside) or that points at this machine (localhost in the manifest), the two mistakes a store build
|
|
396
|
-
// can ship with.
|
|
490
|
+
// can ship with. What looks private — a key, an environment file, a source map — is left out and named, and so is a
|
|
491
|
+
// link out of the folder: a store zip is public. Nothing is sent anywhere: it reads the folder and writes one file.
|
|
397
492
|
const BUILD_DIRS = ['.', '.output/chrome-mv3', 'dist', 'build/chrome-mv3-prod', 'build'];
|
|
398
493
|
const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
399
494
|
const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
|
|
@@ -429,17 +524,32 @@ function zipOf(entries) {
|
|
|
429
524
|
return Buffer.concat([...locals, cd, end]);
|
|
430
525
|
}
|
|
431
526
|
|
|
527
|
+
/**
|
|
528
|
+
* What goes in the zip: every file in the folder but dotfiles, node_modules and zips — and, left out with the reason,
|
|
529
|
+
* a file that looks private by its name or its contents (lib/safe.js), which --allow-private packs anyway, and never a
|
|
530
|
+
* link: one out of the folder can put ~/.ssh/id_rsa in a store zip as logo.png, and one inside it the folder's own
|
|
531
|
+
* .env or .git, under an innocent name (25 Sep 2026). Nor a name no zip should hold — a backslash, which unzips as a
|
|
532
|
+
* folder, or a control character — nor anything but a file. A zip is public once the store serves it.
|
|
533
|
+
*/
|
|
432
534
|
function filesIn(root) {
|
|
433
|
-
const found = [];
|
|
535
|
+
const found = []; const left = []; const privately = [];
|
|
434
536
|
const walk = (dir) => {
|
|
435
537
|
for (const name of readdirSync(dir).sort()) {
|
|
436
538
|
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
437
539
|
const p = join(dir, name);
|
|
438
|
-
|
|
540
|
+
const rel = relative(root, p).split(sep).join('/');
|
|
541
|
+
const st = lstatSync(p);
|
|
542
|
+
if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
|
|
543
|
+
if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
|
|
544
|
+
if (st.isDirectory()) { walk(p); continue; }
|
|
545
|
+
if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
|
|
546
|
+
const why = privateKind(name) ?? privateContent(readFileSync(p));
|
|
547
|
+
if (why && !flag('--allow-private')) left.push([rel, why]);
|
|
548
|
+
else { found.push(p); if (why) privately.push([rel, why]); }
|
|
439
549
|
}
|
|
440
550
|
};
|
|
441
551
|
walk(root);
|
|
442
|
-
return found;
|
|
552
|
+
return { found, left, privately };
|
|
443
553
|
}
|
|
444
554
|
|
|
445
555
|
function pack() {
|
|
@@ -447,9 +557,13 @@ function pack() {
|
|
|
447
557
|
const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
448
558
|
if (!folder || !existsSync(join(folder, 'manifest.json'))) fail(given ? `No manifest.json in ${given}.` : `No manifest.json here or in ${BUILD_DIRS.slice(1).join(', ')}. Build first, or name the folder: toolaby pack <folder>.`);
|
|
449
559
|
let manifest;
|
|
450
|
-
|
|
560
|
+
// The parser's message quotes the file it could not read, line breaks and all.
|
|
561
|
+
try { manifest = JSON.parse(readText(join(folder, 'manifest.json'))); } catch (e) { fail(`manifest.json does not parse: ${oneLine(e.message)}`); }
|
|
451
562
|
if (!manifest.version) fail('manifest.json has no "version".');
|
|
452
|
-
|
|
563
|
+
// One to four numbers, as Chrome and the store take it — and a file name here: the zip is named with it, and a
|
|
564
|
+
// version with "../" in it put the zip outside the folder.
|
|
565
|
+
if (typeof manifest.version !== 'string' || !/^\d{1,5}(\.\d{1,5}){0,3}$/.test(manifest.version)) fail(`manifest.json's "version" is ${JSON.stringify(manifest.version)}: Chrome takes one to four numbers separated by dots, like 1.2.0.`);
|
|
566
|
+
const { found: files, left, privately } = filesIn(folder);
|
|
453
567
|
const problems = [];
|
|
454
568
|
|
|
455
569
|
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
@@ -466,28 +580,38 @@ function pack() {
|
|
|
466
580
|
|
|
467
581
|
// Addresses of this machine have no place in a store build.
|
|
468
582
|
const local = [...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])].filter((m) => typeof m === 'string' && LOCAL.test(m));
|
|
469
|
-
if (local.length && !flag('--allow-local')) problems.push(`The manifest points at this machine: ${local.join(', ')}. A store build has no localhost in host_permissions or externally_connectable. (--allow-local packs it anyway.)`);
|
|
583
|
+
if (local.length && !flag('--allow-local')) problems.push(`The manifest points at this machine: ${local.map(oneLine).join(', ')}. A store build has no localhost in host_permissions or externally_connectable. (--allow-local packs it anyway.)`);
|
|
470
584
|
|
|
471
585
|
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
472
586
|
for (const site of sites) {
|
|
473
587
|
const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
|
|
474
|
-
if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${site}/* in both host_permissions and externally_connectable: sign-in and the purchase would not reach the extension. \`npx toolaby wire <tool-id> --live --force\` adds them.`);
|
|
588
|
+
if (!hosts.includes(`${site}/*`) || !ec.includes(`${site}/*`)) problems.push(`The manifest does not name ${oneLine(site)}/* in both host_permissions and externally_connectable: sign-in and the purchase would not reach the extension. \`npx toolaby wire <tool-id> --live --force\` adds them.`);
|
|
475
589
|
}
|
|
476
590
|
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
477
591
|
|
|
478
592
|
const hadKey = typeof manifest.key === 'string';
|
|
479
593
|
const { key: _key, ...forStore } = manifest;
|
|
480
594
|
const entries = files.map((f) => {
|
|
481
|
-
const name = relative(folder, f).split(
|
|
595
|
+
const name = relative(folder, f).split(sep).join('/');
|
|
482
596
|
return [name, name === 'manifest.json' ? Buffer.from(JSON.stringify(forStore, null, 2) + '\n') : readFileSync(f)];
|
|
483
597
|
});
|
|
484
598
|
// Named after the extension, or after its folder when the name is a __MSG_…__ placeholder.
|
|
485
599
|
const named = typeof manifest.name === 'string' && !manifest.name.startsWith('__MSG_') ? manifest.name : basename(folder);
|
|
486
600
|
const safe = named.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'extension';
|
|
487
601
|
const target = resolve(opt('--out') ?? `${safe}-${manifest.version}.zip`);
|
|
488
|
-
|
|
602
|
+
// Written beside it under a name nobody could have made, then moved over it: a link a cloned project left under the
|
|
603
|
+
// zip's own name took the write wherever it pointed (25 Sep 2026). A move replaces the link itself.
|
|
604
|
+
const fresh = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.part`;
|
|
605
|
+
writeFileSync(fresh, zipOf(entries), { flag: 'wx' });
|
|
606
|
+
renameSync(fresh, target);
|
|
489
607
|
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
490
608
|
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
609
|
+
// What was left out, and why — or, asked for, what private file went in: a store zip is public.
|
|
610
|
+
const links = left.filter(([, why]) => why === 'a link' || why === 'not a file' || why === 'a name no zip should hold');
|
|
611
|
+
const kept = left.filter((entry) => !links.includes(entry));
|
|
612
|
+
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.`);
|
|
613
|
+
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.`);
|
|
614
|
+
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'}.`);
|
|
491
615
|
if (hadKey) out(' The manifest\'s "key" is left out: the store keeps the id it gives the item. Your unpacked copy keeps the key and its own id; the Wall admits both once the listing\'s address is on the tool.');
|
|
492
616
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
493
617
|
}
|
|
@@ -517,6 +641,7 @@ function help() {
|
|
|
517
641
|
toolaby tools your tools, with their ids
|
|
518
642
|
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
519
643
|
refuses a Test key or localhost inside --out <file.zip>
|
|
644
|
+
leaves out keys, certificates, .env and credential files, source maps --allow-private packs them
|
|
520
645
|
toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
521
646
|
--browser loads it in Chromium (needs Playwright in the project)
|
|
522
647
|
toolaby mcp the Wall for coding agents, an MCP server on stdio:
|
|
@@ -527,11 +652,12 @@ function help() {
|
|
|
527
652
|
toolaby login · whoami · logout the session, per side; a command that needs one signs you in itself
|
|
528
653
|
|
|
529
654
|
Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
|
|
530
|
-
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI
|
|
655
|
+
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI —
|
|
656
|
+
sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://test.toolaby.app for Test).
|
|
531
657
|
`);
|
|
532
658
|
}
|
|
533
659
|
|
|
534
660
|
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, upgrade: wire, create, webhooks, pack, check, mcp, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
535
661
|
const run = commands[command];
|
|
536
662
|
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
537
|
-
Promise.resolve().then(run).catch((e) => fail(e.message));
|
|
663
|
+
Promise.resolve().then(run).catch((e) => fail(oneLine(e.message)));
|
package/lib/check.js
CHANGED
|
@@ -3,12 +3,15 @@
|
|
|
3
3
|
// client's freshness, the feature keys the code gates on against the tool's, the paywall's page, whether a copy has
|
|
4
4
|
// checked in and whether the key is current, and — with --browser, where Playwright is installed — the extension
|
|
5
5
|
// loaded in Chromium, its background answering and its own pages opening without an error.
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
7
7
|
import { join, relative, resolve } from 'node:path';
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
|
+
import { oneLine, keySite, fetchBounded, readText } from './safe.js';
|
|
10
11
|
|
|
11
12
|
const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
|
|
13
|
+
/** 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. */
|
|
14
|
+
const TOOL_ID = /^[a-z0-9][a-z0-9-]{1,118}$/;
|
|
12
15
|
const CODE = /\.(m?js|jsx|ts|tsx|html|vue|svelte)$/;
|
|
13
16
|
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.output', '.wxt', '.plasmo', '.next', 'coverage']);
|
|
14
17
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
@@ -65,8 +68,9 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
65
68
|
const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
|
|
66
69
|
if (has('manifest.json') && !manifest) { bad('manifest.json is not valid JSON.'); return false; }
|
|
67
70
|
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
68
|
-
const
|
|
69
|
-
|
|
71
|
+
const readOr = (p, none = null) => { try { return readText(p); } catch { return none; } };
|
|
72
|
+
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readOr(join(dir, configSource)) : null, site: 'https://example.invalid', tree });
|
|
73
|
+
if (project.kind === 'unknown') { bad(`Not an extension project: no manifest.json at the top, and not WXT, CRXJS or Plasmo${project.foundManifest ? ` (there is a manifest at ${oneLine(project.foundManifest)}: run this with --into ${oneLine(project.foundManifest.replace(/\/?manifest\.json$/, '')) || '.'})` : ''}.`); return false; }
|
|
70
74
|
ok(project.kind === 'plain' ? 'A plain extension: manifest.json at the top.' : `A ${project.label} project.`);
|
|
71
75
|
|
|
72
76
|
// 2. The Wall's files.
|
|
@@ -75,40 +79,48 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
75
79
|
if (missing.length) { bad(`${missing.map((f) => `${project.codeDir}${f}`).join(' and ')} missing. Run \`npx toolaby wire <tool-id>\` here.`); return false; }
|
|
76
80
|
ok(`The Wall's files are in ${project.codeDir || 'the top folder'}.`);
|
|
77
81
|
|
|
78
|
-
// 3. The tool key, and the tool it names.
|
|
79
|
-
const
|
|
82
|
+
// 3. The tool key, and the tool it names — from a file: a link to a device or a pipe is read as nothing.
|
|
83
|
+
const configText = readOr(at('toolaby.config.js'), '');
|
|
84
|
+
const keyText = configText.match(KEY)?.[0];
|
|
80
85
|
const key = keyText ? decodeKey(keyText) : null;
|
|
81
86
|
if (!key) { bad('toolaby.config.js holds no tool key. Run `npx toolaby upgrade <tool-id>` here.'); return false; }
|
|
82
87
|
const mode = keyText.split('_')[1];
|
|
88
|
+
// The key is the project's: the Wall it names is asked twice below, with no session, and its tool id goes into a path
|
|
89
|
+
// the developer's session asks of their own Wall. So only a Wall's address (lib/safe.js), and only a tool id. A cloned
|
|
90
|
+
// project's key sent the two requests wherever it named, and a tool id of ".." made the signed-in one a request for
|
|
91
|
+
// another route (25 Sep 2026).
|
|
92
|
+
if (!keySite(key.site) || !TOOL_ID.test(key.slug)) {
|
|
93
|
+
bad(`The tool key names ${JSON.stringify(key.slug)} at ${JSON.stringify(key.site)}, which is not a tool on a Wall: 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 toolaby upgrade <tool-id>\` writes the tool's own key.`);
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
83
96
|
ok(`The tool key names ${key.slug}, on ${mode === 'live' ? 'Live' : mode === 'test' ? 'Test' : 'a development Wall'} (${key.site}).`);
|
|
84
|
-
const pub = await
|
|
97
|
+
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 }));
|
|
85
98
|
const tool = pub.status === 200 ? pub.json : null;
|
|
86
99
|
if (!tool) bad(pub.status === 0 ? `The Wall at ${key.site} could not be reached.` : pub.status === 404 ? `The Wall does not sell ${key.slug}: deleted, switched off, or on the other side. \`npx toolaby tools${mode === 'live' ? ' --live' : ''}\` lists yours.` : `The Wall answered HTTP ${pub.status} for the tool.`);
|
|
87
100
|
else {
|
|
88
|
-
ok(`The Wall knows it: ${tool.name}.`);
|
|
101
|
+
ok(`The Wall knows it: ${oneLine(tool.name)}.`);
|
|
89
102
|
const jwk = tool.publicKeyJwk ?? {};
|
|
90
103
|
if (key.x && jwk.x && (key.x !== jwk.x || key.y !== jwk.y)) warn('The workspace\'s signing key was rotated after this key was written. Installed copies fetch the new one; `npx toolaby upgrade <tool-id>` writes it in.');
|
|
91
104
|
}
|
|
92
105
|
|
|
93
106
|
// 4. The manifest, against the platform's own wiring rules: what wiring it again would add.
|
|
94
|
-
const configText = readFileSync(at('toolaby.config.js'), 'utf8');
|
|
95
107
|
if (project.manifestFile && manifest) {
|
|
96
108
|
const again = wireManifest(manifest, { site: key.site, has, remembered: null, rememberedSidePanel: null, manifestKey: typeof manifest.key === 'string' ? manifest.key : null });
|
|
97
109
|
const needed = (again.did ?? []).filter((d) => !/popup|side panel/i.test(d));
|
|
98
|
-
if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
110
|
+
if (needed.length) bad(`The manifest lacks what the Wall needs: ${needed.map(oneLine).join('; ')}. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
99
111
|
else ok('The manifest has the permissions and host permissions the Wall needs.');
|
|
100
|
-
// The identity: without the `key`, each copy's id follows its folder, and the tool key
|
|
101
|
-
if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and the tool key
|
|
112
|
+
// 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
|
+
if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx toolaby upgrade ${key.slug}\` adds it.`);
|
|
102
114
|
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}/* in host_permissions and externally_connectable.`);
|
|
103
115
|
|
|
104
116
|
// 5. The client, as fresh as the Wall's.
|
|
105
|
-
const client = await
|
|
117
|
+
const client = await fetchBounded(`${key.site}/client/toolaby.js`).then((r) => (r.ok && r.body ? r.body.toString('utf8') : null)).catch(() => null);
|
|
106
118
|
if (client === null) warn('The Wall\'s current client could not be read to compare.');
|
|
107
|
-
else if (sha(client) !== sha(
|
|
119
|
+
else if (sha(client) !== sha(readOr(at('toolaby.js'), ''))) warn(`toolaby.js is not the Wall's current client. \`npx toolaby upgrade ${key.slug}\` replaces it (your own files stay).`);
|
|
108
120
|
else ok('toolaby.js is the Wall\'s current client.');
|
|
109
121
|
|
|
110
122
|
// 6. The features the code gates on, against the tool's; and the page showPaywall() opens.
|
|
111
|
-
const sources = sourceFiles(dir).map((f) =>
|
|
123
|
+
const sources = sourceFiles(dir).map((f) => { try { return readText(f); } catch { return ''; } });
|
|
112
124
|
if (project.manifestFile && manifest && sources.some((s) => /\bshowPaywall\s*\(/.test(withoutComments(s)))) {
|
|
113
125
|
const pages = [manifest.action?.default_popup ? 'toolaby-popup.html' : null, (manifest.side_panel?.default_path || manifest.sidebar_action?.default_panel) ? 'toolaby-sidepanel.html' : null].filter(Boolean);
|
|
114
126
|
const absent = pages.filter((p) => !existsSync(join(dir, `${project.popupDir}${p}`)));
|
|
@@ -168,7 +180,7 @@ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
|
168
180
|
new Promise((r) => setTimeout(() => r('timeout'), 8000)),
|
|
169
181
|
])).catch((e) => `error: ${e.message}`);
|
|
170
182
|
if (answer && answer !== 'timeout' && !String(answer).startsWith('error')) ok('The background answers the Wall\'s client: its listeners registered.');
|
|
171
|
-
else bad(`The background did not answer (${answer ?? 'nothing'}): its module threw while loading, so none of its listeners registered.`);
|
|
183
|
+
else bad(`The background did not answer (${oneLine(answer ?? 'nothing')}): its module threw while loading, so none of its listeners registered.`);
|
|
172
184
|
await page.close().catch(() => {});
|
|
173
185
|
// The extension's own pages, each opened in a tab: one whose script throws is a popup that opens blank.
|
|
174
186
|
const own = [shipped?.action?.default_popup, shipped?.side_panel?.default_path].filter((p, i, all) => typeof p === 'string' && p && all.indexOf(p) === i);
|
|
@@ -179,12 +191,13 @@ async function browserCheck({ dir, project, manifest, ok, warn, bad }) {
|
|
|
179
191
|
await tab.goto(`chrome-extension://${id}/${p}`).catch((e) => errors.push(e.message));
|
|
180
192
|
await tab.waitForTimeout(1500);
|
|
181
193
|
await tab.close().catch(() => {});
|
|
182
|
-
if (errors.length) bad(`${p} threw when it opened: ${errors[0]}`);
|
|
183
|
-
else ok(`${p} opens without an error.`);
|
|
194
|
+
if (errors.length) bad(`${oneLine(p)} threw when it opened: ${oneLine(errors[0])}`);
|
|
195
|
+
else ok(`${oneLine(p)} opens without an error.`);
|
|
184
196
|
}
|
|
185
197
|
} finally {
|
|
186
198
|
await ctx.close().catch(() => {});
|
|
187
199
|
}
|
|
188
200
|
}
|
|
189
201
|
|
|
190
|
-
function readJsonSafe(p) { try { return JSON.parse(
|
|
202
|
+
function readJsonSafe(p) { try { return JSON.parse(readText(p)); } catch { return null; } }
|
|
203
|
+
function jsonOf(body) { try { return body ? JSON.parse(body.toString('utf8')) : null; } catch { return null; } }
|
package/lib/mcp.js
CHANGED
|
@@ -10,17 +10,34 @@
|
|
|
10
10
|
// them) to a command line that has none and that the Wall also serves as one tarball; this is the stdio transport and
|
|
11
11
|
// the tools capability exactly as the specification words them. Nothing but protocol messages goes to stdout.
|
|
12
12
|
|
|
13
|
+
import { fetchBounded } from './safe.js';
|
|
14
|
+
|
|
13
15
|
const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
|
|
16
|
+
/**
|
|
17
|
+
* A tool id as the Wall makes them. Checked before it goes into a path: `..`, encoded or not, is a path the address
|
|
18
|
+
* resolves away, and `/api/cli/tools/../x` asked for `/api/cli/x` (25 Sep 2026).
|
|
19
|
+
*/
|
|
20
|
+
const TOOL_ID = /^[a-z0-9][a-z0-9-]{1,118}$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a tool does, as the specification's annotations say it (2025-03-26 on), for a client deciding what it may run
|
|
24
|
+
* without asking: a read changes nothing, a write changes the developer's own workspace — destructive where it takes
|
|
25
|
+
* away or gives away (what the Free plan holds, the feature list, access to someone), idempotent where a second call
|
|
26
|
+
* changes nothing more. Every tool acts on that workspace on the Wall and nothing beyond it. The writes carried no
|
|
27
|
+
* annotations, and a client took them for whatever its defaults were (25 Sep 2026).
|
|
28
|
+
*/
|
|
29
|
+
const READS = { readOnlyHint: true, openWorldHint: false };
|
|
14
30
|
|
|
15
31
|
/** The tools, with JSON Schema for their arguments, and what each calls on the Wall. */
|
|
16
32
|
function toolsFor(side) {
|
|
17
|
-
const tool = { type: 'string', description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
|
|
33
|
+
const tool = { type: 'string', pattern: TOOL_ID.source, description: 'The tool id, as `toolaby_list_tools` gives it (like "acme--word-counter").' };
|
|
18
34
|
return [
|
|
19
35
|
{
|
|
20
36
|
name: 'toolaby_whoami',
|
|
21
37
|
title: 'Who is signed in',
|
|
22
38
|
description: `The developer signed in to the ${side} Wall, and their workspaces.`,
|
|
23
39
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
40
|
+
annotations: READS,
|
|
24
41
|
run: (_a, api) => api('/api/cli/me'),
|
|
25
42
|
},
|
|
26
43
|
{
|
|
@@ -28,6 +45,7 @@ function toolsFor(side) {
|
|
|
28
45
|
title: 'List tools',
|
|
29
46
|
description: `Every tool in the developer's workspaces on the ${side} Wall, with the id the other tools take.`,
|
|
30
47
|
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
48
|
+
annotations: READS,
|
|
31
49
|
run: (_a, api) => api('/api/cli/tools'),
|
|
32
50
|
},
|
|
33
51
|
{
|
|
@@ -35,12 +53,13 @@ function toolsFor(side) {
|
|
|
35
53
|
title: 'Read a tool',
|
|
36
54
|
description: `One tool on the ${side} Wall: what the Free plan holds (freePlan.holds, in a sentence), its features, its plans with what each unlocks, its tool key, and its extension: identityId is the id of every copy whose manifest has the tool's key (such a copy needs no binding, so boundId stays null), checkedInAt says whether one has checked in, inUse which builds. Then the addresses of its pages.`,
|
|
37
55
|
inputSchema: { type: 'object', properties: { tool }, required: ['tool'], additionalProperties: false },
|
|
56
|
+
annotations: READS,
|
|
38
57
|
run: (a, api) => api(`/api/cli/tools/${encodeURIComponent(a.tool)}`),
|
|
39
58
|
},
|
|
40
59
|
{
|
|
41
60
|
name: 'toolaby_create_tool',
|
|
42
61
|
title: 'Create a tool',
|
|
43
|
-
description: `A new tool on the ${side} Wall. Its Free plan holds everything until access is set. Then wire an extension to it: \`npx toolaby create <id>\` for a new one, \`npx toolaby wire <id>\` for an existing one.`,
|
|
62
|
+
description: `A new tool on the ${side} Wall. 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 toolaby create <id>\` for a new one, \`npx toolaby wire <id>\` for an existing one.`,
|
|
44
63
|
inputSchema: {
|
|
45
64
|
type: 'object',
|
|
46
65
|
properties: {
|
|
@@ -52,12 +71,13 @@ function toolsFor(side) {
|
|
|
52
71
|
required: ['name'],
|
|
53
72
|
additionalProperties: false,
|
|
54
73
|
},
|
|
74
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
55
75
|
run: (a, api) => api('/api/cli/tools', { method: 'POST', body: a }),
|
|
56
76
|
},
|
|
57
77
|
{
|
|
58
78
|
name: 'toolaby_set_access',
|
|
59
79
|
title: 'Set what the Free plan holds',
|
|
60
|
-
description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx toolaby upgrade <id>` then writes the new one.',
|
|
80
|
+
description: 'What a buyer gets before paying: "open" (the tool\'s use, unlimited), "free_uses" (a number of uses per device, counted by gate() without a feature), or "paid" (nothing before a plan). This changes access in the developer\'s own workspace, for every buyer of the tool. Whichever it is, a feature a plan sells (not marked free) is refused until bought. signInRequired asks for an account first. Takes effect on every device at its next check. The answer says when the tool key changed: `npx toolaby upgrade <id>` then writes the new one.',
|
|
61
81
|
inputSchema: {
|
|
62
82
|
type: 'object',
|
|
63
83
|
properties: {
|
|
@@ -69,12 +89,13 @@ function toolsFor(side) {
|
|
|
69
89
|
required: ['tool', 'access'],
|
|
70
90
|
additionalProperties: false,
|
|
71
91
|
},
|
|
92
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
72
93
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/access`, { method: 'POST', body: rest }),
|
|
73
94
|
},
|
|
74
95
|
{
|
|
75
96
|
name: 'toolaby_set_features',
|
|
76
97
|
title: 'Set features, and the plans that unlock them',
|
|
77
|
-
description: 'The tool\'s whole feature list (it replaces the current one: send the features it has too), and which plans unlock which. A feature\'s key is what the extension passes to gate({ feature }) and has(). A feature marked free is on the Free plan too; one that is not free and that no plan unlocks is refused to everyone. The plans must exist first (toolaby_add_plan); a plan not named in `plans` keeps what it unlocks.',
|
|
98
|
+
description: 'The tool\'s whole feature list (it replaces the current one: send the features it has too), and which plans unlock which. This changes billing in the developer\'s own workspace: what every plan of the tool sells. A feature\'s key is what the extension passes to gate({ feature }) and has(). A feature marked free is on the Free plan too; one that is not free and that no plan unlocks is refused to everyone. The plans must exist first (toolaby_add_plan); a plan not named in `plans` keeps what it unlocks.',
|
|
78
99
|
inputSchema: {
|
|
79
100
|
type: 'object',
|
|
80
101
|
properties: {
|
|
@@ -102,12 +123,13 @@ function toolsFor(side) {
|
|
|
102
123
|
required: ['tool', 'features'],
|
|
103
124
|
additionalProperties: false,
|
|
104
125
|
},
|
|
126
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
105
127
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/features`, { method: 'POST', body: rest }),
|
|
106
128
|
},
|
|
107
129
|
{
|
|
108
130
|
name: 'toolaby_add_plan',
|
|
109
131
|
title: 'Add a plan',
|
|
110
|
-
description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx toolaby upgrade <id>\` then writes the new one.`,
|
|
132
|
+
description: `A plan buyers can buy: a Price on the workspace's Stripe account (${side === 'Test' ? 'on Test, its test account' : 'on Live, the connected account'}). This changes billing in the developer's own workspace. "one_time" makes a lifetime licence; "subscription" bills monthly or yearly. Answers with the plan: its id (the tool's first plan is "default"), which openPaymentPage(id) takes, and its name as buyers see it, the name given and the kind ("Pro · Lifetime"). Then say what it unlocks with toolaby_set_features. The answer says when the tool key changed (the first subscription plan changes it): \`npx toolaby upgrade <id>\` then writes the new one.`,
|
|
111
133
|
inputSchema: {
|
|
112
134
|
type: 'object',
|
|
113
135
|
properties: {
|
|
@@ -124,18 +146,20 @@ function toolsFor(side) {
|
|
|
124
146
|
required: ['tool', 'billing', 'amount', 'currency'],
|
|
125
147
|
additionalProperties: false,
|
|
126
148
|
},
|
|
149
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
127
150
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/plans`, { method: 'POST', body: rest }),
|
|
128
151
|
},
|
|
129
152
|
{
|
|
130
153
|
name: 'toolaby_grant',
|
|
131
154
|
title: 'Grant access',
|
|
132
|
-
description: 'Access to the tool without paying: to test a paid feature on Test, or for a reviewer. An address with no account gets one; the grant applies once that person signs in to the extension with it (the link arrives by email), so testing a paid feature this way needs a person for a moment. For a number of days, or until revoked. On the Free plan a workspace holds 100 grants and other passes at once.',
|
|
155
|
+
description: 'Access to the tool without paying: to test a paid feature on Test, or for a reviewer. This changes access in the developer\'s own workspace: the person gets the paid features, and each call is another grant. An address with no account gets one; the grant applies once that person signs in to the extension with it (the link arrives by email), so testing a paid feature this way needs a person for a moment. For a number of days, or until revoked. On the Free plan a workspace holds 100 grants and other passes at once.',
|
|
133
156
|
inputSchema: {
|
|
134
157
|
type: 'object',
|
|
135
158
|
properties: { tool, email: { type: 'string' }, days: { type: 'integer', minimum: 1, maximum: 3650, description: 'Optional: how long. Default: until revoked.' } },
|
|
136
159
|
required: ['tool', 'email'],
|
|
137
160
|
additionalProperties: false,
|
|
138
161
|
},
|
|
162
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
|
139
163
|
run: ({ tool: t, ...rest }, api) => api(`/api/cli/tools/${encodeURIComponent(t)}/grants`, { method: 'POST', body: rest }),
|
|
140
164
|
},
|
|
141
165
|
{
|
|
@@ -143,6 +167,7 @@ function toolsFor(side) {
|
|
|
143
167
|
title: 'Read the docs',
|
|
144
168
|
description: 'The Wall\'s docs as Markdown. Without a page: the index, every page with what it covers. With a page (like "gating", "pricing", "reference/client"): that page.',
|
|
145
169
|
inputSchema: { type: 'object', properties: { page: { type: 'string' } }, additionalProperties: false },
|
|
170
|
+
annotations: READS,
|
|
146
171
|
run: (a, _api, docs) => docs(a.page),
|
|
147
172
|
},
|
|
148
173
|
];
|
|
@@ -168,13 +193,20 @@ export function runMcp({ version, side, wall, api, token }) {
|
|
|
168
193
|
const clean = typeof page === 'string' ? page.replace(/^\/+|\.md$|\/+$/g, '').replace(/^docs\//, '') : '';
|
|
169
194
|
if (clean && !/^[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(clean)) return { ok: false, status: 400, json: { message: 'A page is a path like "gating" or "reference/client".' } };
|
|
170
195
|
const url = clean ? `${wall}/docs/${clean}.md` : `${wall}/llms.txt`;
|
|
171
|
-
|
|
172
|
-
const
|
|
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
|
+
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 the Wall serves.' } };
|
|
199
|
+
const text = res.body ? res.body.toString('utf8') : '';
|
|
173
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}.` } };
|
|
174
201
|
};
|
|
175
202
|
const call = async (name, args) => {
|
|
176
203
|
const t = tools.find((x) => x.name === name);
|
|
177
204
|
if (!t) return { error: { code: -32602, message: `Unknown tool: ${name}` } };
|
|
205
|
+
// Arguments are an object, or none: anything else threw before the answer, and the request went unanswered.
|
|
206
|
+
if (args !== undefined && (args === null || typeof args !== 'object' || Array.isArray(args))) return { error: { code: -32602, message: 'Arguments are an object.' } };
|
|
207
|
+
if (args && 'tool' in args && (typeof args.tool !== 'string' || !TOOL_ID.test(args.tool))) {
|
|
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
|
+
}
|
|
178
210
|
if (name !== 'toolaby_docs' && !token()) {
|
|
179
211
|
return { result: { content: [{ type: 'text', text: `Not signed in to the ${side} Wall (${wall}). Run \`npx toolaby login${side === 'Live' ? ' --live' : ''}\` in a terminal, then call this again.` }], isError: true } };
|
|
180
212
|
}
|
package/lib/project.js
CHANGED
|
@@ -120,11 +120,13 @@ export function detectProject(o) {
|
|
|
120
120
|
if (crxjs && !has('manifest.json')) {
|
|
121
121
|
const configFile = first(has, ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'manifest.js', 'src/manifest.ts', 'src/manifest.js']);
|
|
122
122
|
const source = o.manifestSource ?? '';
|
|
123
|
-
|
|
123
|
+
// A name as one line of a path: read across a line break or a backslash, a project's config drew lines of its own
|
|
124
|
+
// into the snippets the command prints and the dashboard shows (25 Sep 2026).
|
|
125
|
+
const declared = source.match(/default_popup:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? null;
|
|
124
126
|
const appPopup = declared && declared !== 'toolaby-popup.html' ? declared : null;
|
|
125
|
-
const declaredPanel = source.match(/default_path:\s*['"]([^'"]+)['"]/)?.[1] ?? null;
|
|
127
|
+
const declaredPanel = source.match(/default_path:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? null;
|
|
126
128
|
const appSidePanel = declaredPanel && declaredPanel !== 'toolaby-sidepanel.html' ? declaredPanel : null;
|
|
127
|
-
const worker = source.match(/service_worker:\s*['"]([^'"]+)['"]/)?.[1] ?? first(has, ['src/background.ts', 'src/background.js', 'background.ts', 'background.js']);
|
|
129
|
+
const worker = source.match(/service_worker:\s*['"]([^'"\\\r\n]+)['"]/)?.[1] ?? first(has, ['src/background.ts', 'src/background.js', 'background.ts', 'background.js']);
|
|
128
130
|
const workerFile = worker ?? 'src/background.ts';
|
|
129
131
|
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
130
132
|
const code = [
|
package/lib/safe.js
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// What this command will not do, whoever asks: write outside the project it was pointed at, write a file of a new
|
|
2
|
+
// extension it does not know, open a sign-in page that is not the Wall's, put a private file in a store zip, print a
|
|
3
|
+
// line it did not write, ask whatever address a project names, or take a download without end.
|
|
4
|
+
//
|
|
5
|
+
// Each of these took someone else's word. A cloned repository's manifest named the file `wire` wrote — a service
|
|
6
|
+
// worker at "../../.zshrc" overwrote the shell's startup file — and a link in the folder took a write wherever it
|
|
7
|
+
// pointed. The Wall's list of a new extension's files was joined to the folder as it came, so a Wall at another
|
|
8
|
+
// address (--wall, TOOLABY_WALL) could write any file with any content. And on Windows the sign-in address went
|
|
9
|
+
// through cmd.exe, where an `&` in it ran a command. Hand-written, unlike manifest.js and project.js: this is the
|
|
10
|
+
// command's own boundary, not a wiring rule the browser shares.
|
|
11
|
+
import { lstatSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
+
import { isAbsolute, join, posix, relative, resolve, sep, win32 } from 'node:path';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The minimal extension's own files, as the Wall names them (SCAFFOLD_FILES in wall/src/packages/wiring/files.ts;
|
|
16
|
+
* a test keeps the two equal): the only names `create` accepts from the Wall's list, which says which of them to
|
|
17
|
+
* write and never where. A name the Wall adds later is refused by a command that predates it — nothing is written,
|
|
18
|
+
* and `npm install -g toolaby@latest` is the way on.
|
|
19
|
+
*/
|
|
20
|
+
export const SCAFFOLD_FILES = ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where `rel` lands inside `root` — `{ path }` — or why it may not — `{ why }`. Refused: an absolute path, a `..`,
|
|
24
|
+
* a backslash on POSIX (there it is part of a name, not a separator), anything that resolves outside the folder,
|
|
25
|
+
* and a symbolic link at any step below the root, dangling or not: a link in a cloned repository points wherever
|
|
26
|
+
* its author chose, and writing through it writes there. The root itself is the developer's to choose.
|
|
27
|
+
*/
|
|
28
|
+
export function inside(root, rel) {
|
|
29
|
+
if (typeof rel !== 'string' || !rel.trim() || rel.includes('\0')) return { why: 'it is not a file name' };
|
|
30
|
+
if (isAbsolute(rel) || posix.isAbsolute(rel) || win32.isAbsolute(rel)) return { why: 'it is an absolute path' };
|
|
31
|
+
if (sep === '/' && rel.includes('\\')) return { why: 'it has a backslash in it' };
|
|
32
|
+
if (rel.split(/[\\/]/).includes('..')) return { why: 'it has ".." in it' };
|
|
33
|
+
const base = resolve(root);
|
|
34
|
+
const path = resolve(base, rel);
|
|
35
|
+
if (!path.startsWith(base + sep)) return { why: 'it is outside the folder' };
|
|
36
|
+
let at = base;
|
|
37
|
+
for (const part of relative(base, path).split(sep)) {
|
|
38
|
+
at = join(at, part);
|
|
39
|
+
let st;
|
|
40
|
+
try { st = lstatSync(at); } catch { break; } // not there yet, and so nothing below it is
|
|
41
|
+
if (st.isSymbolicLink()) return { why: `${relative(base, at).split(sep).join('/')} is a symbolic link` };
|
|
42
|
+
}
|
|
43
|
+
return { path };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const LOOPBACK = /^(localhost|127(\.\d{1,3}){3}|\[::1\])$|\.localhost$/i;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A value from a Wall's answer or a project's file, as one line. Every line leaves without terminal controls (bin.mjs),
|
|
50
|
+
* but keeps its line breaks, which the command's own messages need — so a tool name with "\n✓ Ready to publish" in it
|
|
51
|
+
* printed a whole line of its own, under the command's real ones (25 Sep 2026). Each such value goes through this
|
|
52
|
+
* where it is put into a line: line breaks and tabs, and any run of space, are one space.
|
|
53
|
+
*/
|
|
54
|
+
export const oneLine = (value) => String(value).replace(/\s+/g, ' ').slice(0, 500);
|
|
55
|
+
|
|
56
|
+
/** An address a Wall answers on: https, or http on this machine, for a Wall on the developer's laptop. */
|
|
57
|
+
const wallScheme = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.test(url.hostname));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Wall a tool key names, when `check` may ask it anything: an origin, https or on this machine, and nothing else —
|
|
61
|
+
* no path, no credentials, no other scheme — or null. The key is read from the project's own toolaby.config.js, and a
|
|
62
|
+
* cloned project's key sent `check`'s requests wherever it named: a server inside the developer's network, any path on
|
|
63
|
+
* any host (25 Sep 2026).
|
|
64
|
+
*/
|
|
65
|
+
export function keySite(site) {
|
|
66
|
+
let url;
|
|
67
|
+
try { url = new URL(site); } catch { return null; }
|
|
68
|
+
return url.origin === site && wallScheme(url) ? site : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A GET that cannot run away with the command: the body read up to `limit` bytes and no further, the whole within `ms`,
|
|
73
|
+
* and a redirect followed — five at most — only to an address a Wall answers on. Answers `{ status, ok, body }`, the
|
|
74
|
+
* body null when it passed the limit; throws as fetch does, and with a TimeoutError when the time runs out. A Wall at
|
|
75
|
+
* another address (--wall, TOOLABY_WALL) could send `create` an icon without end, which the new folder took until the
|
|
76
|
+
* disk was full; and an https address that redirected took `check` anywhere (25 Sep 2026).
|
|
77
|
+
*/
|
|
78
|
+
export async function fetchBounded(url, { limit = 1024 * 1024, ms = 30_000 } = {}) {
|
|
79
|
+
const signal = AbortSignal.timeout(ms);
|
|
80
|
+
const origin = new URL(url).origin;
|
|
81
|
+
let res = await fetch(url, { redirect: 'manual', signal });
|
|
82
|
+
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
83
|
+
const next = new URL(res.headers.get('location'), url);
|
|
84
|
+
// Within the address asked, and no further: an https Wall that redirected sent `check` to a service on this
|
|
85
|
+
// machine, whose answer it printed as the tool's (25 Sep 2026).
|
|
86
|
+
if (next.origin !== origin || !wallScheme(next)) break;
|
|
87
|
+
await res.body?.cancel().catch(() => {});
|
|
88
|
+
url = next.href;
|
|
89
|
+
res = await fetch(url, { redirect: 'manual', signal });
|
|
90
|
+
}
|
|
91
|
+
const answered = { status: res.status, ok: res.ok };
|
|
92
|
+
if (Number(res.headers.get('content-length')) > limit) { await res.body?.cancel().catch(() => {}); return { ...answered, body: null }; }
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
const reader = res.body?.getReader();
|
|
96
|
+
for (;;) {
|
|
97
|
+
const { done, value } = reader ? await reader.read() : { done: true };
|
|
98
|
+
if (done) break;
|
|
99
|
+
size += value.byteLength;
|
|
100
|
+
if (size > limit) { await reader.cancel().catch(() => {}); return { ...answered, body: null }; }
|
|
101
|
+
chunks.push(value);
|
|
102
|
+
}
|
|
103
|
+
return { ...answered, body: Buffer.concat(chunks) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
|
|
108
|
+
* refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
|
|
109
|
+
* for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026).
|
|
110
|
+
*/
|
|
111
|
+
export async function fetchHere(url, init = {}) {
|
|
112
|
+
const origin = new URL(url).origin;
|
|
113
|
+
let res = await fetch(url, { ...init, redirect: 'manual' });
|
|
114
|
+
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
115
|
+
const next = new URL(res.headers.get('location'), url);
|
|
116
|
+
await res.body?.cancel().catch(() => {});
|
|
117
|
+
if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
|
|
118
|
+
url = next.href;
|
|
119
|
+
res = await fetch(url, { ...init, redirect: 'manual' });
|
|
120
|
+
}
|
|
121
|
+
return res;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The most of one project file the command reads as text: a manifest, a config, a source file — none comes near it. */
|
|
125
|
+
export const READ_LIMIT = 5 * 1024 * 1024;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A project file's text, where it is a file and no larger than READ_LIMIT; otherwise it throws, as a missing one does.
|
|
129
|
+
* A cloned repository can hold a link to /dev/zero, or a pipe, under any name the command reads: it waited, or filled
|
|
130
|
+
* its memory, for ever (25 Sep 2026).
|
|
131
|
+
*/
|
|
132
|
+
export function readText(path, limit = READ_LIMIT) {
|
|
133
|
+
const st = statSync(path);
|
|
134
|
+
if (!st.isFile()) throw new Error(`${path} is not a file`);
|
|
135
|
+
if (st.size > limit) throw new Error(`${path} is larger than any file this reads`);
|
|
136
|
+
return readFileSync(path, 'utf8');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Why a file's contents have no place in a store zip, whatever it is called, or null: a private key, a cloud service
|
|
141
|
+
* account's key, a source map written into the code. Names alone let `key.txt`, a Google Cloud key under the name
|
|
142
|
+
* its console gives it, and a bundle's inline map through (25 Sep 2026).
|
|
143
|
+
*/
|
|
144
|
+
export function privateContent(bytes) {
|
|
145
|
+
const text = bytes.subarray(0, 4 * 1024 * 1024).toString('latin1');
|
|
146
|
+
if (/"type"\s*:\s*"service_account"/.test(text) && /"private_key"\s*:/.test(text)) return "a service account's key inside";
|
|
147
|
+
if (/-----BEGIN [A-Z0-9 ]*PRIVATE KEY( BLOCK)?-----/.test(text)) return 'a private key inside';
|
|
148
|
+
if (/[#@]\s*sourceMappingURL\s*=\s*data:/.test(text)) return 'a source map inside';
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The page `login` opens: the one the Wall gave, when it is the Wall's own over https — or, for a Wall on this
|
|
154
|
+
* machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
|
|
155
|
+
* http, or a scheme such as file: that opens whatever it names.
|
|
156
|
+
*/
|
|
157
|
+
export function signInPage(link, wall) {
|
|
158
|
+
if (typeof link !== 'string') return null;
|
|
159
|
+
let home; let page;
|
|
160
|
+
try { home = new URL(wall); page = new URL(link, home); } catch { return null; }
|
|
161
|
+
if (page.username || page.password) return null;
|
|
162
|
+
if (page.protocol === 'https:' && page.origin === home.origin) return page.href;
|
|
163
|
+
const web = page.protocol === 'http:' || page.protocol === 'https:';
|
|
164
|
+
return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How a page is opened on each platform, the address one argument and no shell in between. On Windows,
|
|
169
|
+
* `cmd /c start "" <url>` read an `&` in the address as a second command; rundll32 hands it to the default browser
|
|
170
|
+
* as it is.
|
|
171
|
+
*/
|
|
172
|
+
export function opener(platform, url) {
|
|
173
|
+
if (platform === 'darwin') return ['open', [url]];
|
|
174
|
+
if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]];
|
|
175
|
+
return ['xdg-open', [url]];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Why a file has no place in a store zip, or null: a private key or a keystore (Chrome's own "Pack extension" leaves
|
|
180
|
+
* the extension's .pem beside the folder, and it ends up inside), a certificate, an environment file, a file of
|
|
181
|
+
* credentials by its name — a cloud service account's key, a package registry's or a machine's login — or a source
|
|
182
|
+
* map, which carries the source. A store zip is public: anyone can download an item and unzip it. `pack` leaves these
|
|
183
|
+
* out and names them; --allow-private packs them. Dotfiles, .env and .npmrc among them, are never packed, and are
|
|
184
|
+
* named here all the same. A certificate, a service account's key under the name its console downloads it with
|
|
185
|
+
* (firebase-adminsdk-…json, …-service-account-…json), an SSH key with a suffix and Windows' _netrc were packed until
|
|
186
|
+
* 25 Sep 2026.
|
|
187
|
+
*/
|
|
188
|
+
export function privateKind(name) {
|
|
189
|
+
const n = name.toLowerCase();
|
|
190
|
+
if (/\.(pem|key|p12|pfx|p8|jks|keystore|bks|ppk)$/.test(n) || /^id_(rsa|dsa|ecdsa|ed25519)(?!.*\.pub$)/.test(n)) return 'a private key';
|
|
191
|
+
if (/\.(crt|cer|der)$/.test(n)) return 'a certificate';
|
|
192
|
+
if (/\.env(\.[^.]+)*$/.test(n)) return 'an environment file';
|
|
193
|
+
if (/^(credentials|secrets?)(\.(json|ya?ml|toml))?$/.test(n) || /(service[-_]?account|firebase-adminsdk|^client_secret)[^/]*\.json$/.test(n)) return 'credentials';
|
|
194
|
+
if (/\.tfstate(\.backup)?$/.test(n)) return "a deployment's state";
|
|
195
|
+
if (/^[._]?(npmrc|yarnrc\.yml|netrc|pypirc|git-credentials|htpasswd|pgpass)$/.test(n)) return 'credentials';
|
|
196
|
+
if (n.endsWith('.map')) return 'a source map';
|
|
197
|
+
return null;
|
|
198
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolaby",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.2",
|
|
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": {
|