toolaby 1.6.1 → 1.6.3
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 +187 -90
- package/lib/check.js +36 -23
- package/lib/mcp.js +44 -12
- package/lib/project.js +5 -3
- package/lib/safe.js +340 -8
- package/package.json +1 -1
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,13 +24,14 @@
|
|
|
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, lstatSync, realpathSync, chmodSync, unlinkSync } from 'node:fs';
|
|
34
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, lstatSync, realpathSync, chmodSync, unlinkSync, renameSync } from 'node:fs';
|
|
33
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';
|
|
@@ -39,9 +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';
|
|
42
|
-
import { inside, signInPage, opener, privateKind, SCAFFOLD_FILES } from './lib/safe.js';
|
|
44
|
+
import { inside, signInPage, opener, privateKind, privateContent, privateCopies, projectRoot, gitDirectory, notAnImage, publicLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
|
|
43
45
|
|
|
44
|
-
const VERSION = '1.6.
|
|
46
|
+
const VERSION = '1.6.3';
|
|
45
47
|
const CLIENT_ID = 'toolaby-cli';
|
|
46
48
|
const argv = process.argv.slice(2);
|
|
47
49
|
// `--version` and `--help` are commands of their own; every other command is the first bare word.
|
|
@@ -53,11 +55,18 @@ const opt = (k, d = null) => { const i = rest.indexOf(k); return i >= 0 && rest[
|
|
|
53
55
|
const flag = (k) => rest.includes(k) || (k === '--force' && command === 'upgrade');
|
|
54
56
|
const WALLS = { test: 'https://test.toolaby.app', live: 'https://toolaby.app' };
|
|
55
57
|
const live = flag('--live');
|
|
56
|
-
|
|
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));
|
|
57
62
|
/** The side, for every message: what a developer must never be unsure of. */
|
|
58
63
|
const side = wall === WALLS.live ? 'Live' : wall === WALLS.test ? 'Test' : wall;
|
|
59
|
-
|
|
60
|
-
|
|
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); };
|
|
61
70
|
|
|
62
71
|
// ── The session: one per Wall, in the user's config, readable by them alone ─────────────────────────────────────────
|
|
63
72
|
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'toolaby');
|
|
@@ -68,16 +77,39 @@ function writeCredentials(all) {
|
|
|
68
77
|
writeFileSync(CREDENTIALS, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
69
78
|
try { chmodSync(CREDENTIALS, 0o600); } catch { /* not every filesystem has modes */ }
|
|
70
79
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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.` : '');
|
|
90
|
+
|
|
91
|
+
/** The most of one answer the command reads from a Wall: none of the Wall's comes near it (the largest, a wiring, is under 1 MB). */
|
|
92
|
+
const ANSWER_LIMIT = 4 * 1024 * 1024;
|
|
93
|
+
|
|
94
|
+
async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
|
|
95
|
+
// Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
|
|
96
|
+
const res = await fetchHere(`${wall}${path}`, {
|
|
75
97
|
method,
|
|
76
98
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
77
99
|
body: body ? JSON.stringify(body) : undefined,
|
|
78
100
|
}).catch((e) => { throw new Error(`Could not reach ${wall}: ${e.message}`); });
|
|
79
|
-
if (
|
|
80
|
-
|
|
101
|
+
if (res.status === 401 && token && token === envToken()) {
|
|
102
|
+
envRefused = true;
|
|
103
|
+
const stored = storedToken();
|
|
104
|
+
if (stored && stored !== token) { await res.body?.cancel().catch(() => {}); return api(path, { method, body, token: stored }); }
|
|
105
|
+
}
|
|
106
|
+
// Read up to a few megabytes and no further: a Wall at another address (--wall, TOOLABY_WALL) that answered without
|
|
107
|
+
// end filled the command's memory, and the MCP server's, which hands what it reads to an agent (25 Sep 2026).
|
|
108
|
+
// An answer cut short reads as no JSON, as it always has; only one past the limit stops the command.
|
|
109
|
+
const bytes = await readBounded(res, ANSWER_LIMIT).catch(() => Buffer.alloc(0));
|
|
110
|
+
if (!bytes) throw new Error(`The Wall at ${wall} answered ${path.split('?')[0]} with more than ${ANSWER_LIMIT / 1024 / 1024} MB, which no answer of a Wall's is. None of it was used.`);
|
|
111
|
+
let json = {};
|
|
112
|
+
try { json = JSON.parse(bytes.toString('utf8')); } catch { /* not JSON: the status says what happened */ }
|
|
81
113
|
return { status: res.status, ok: res.ok, json };
|
|
82
114
|
}
|
|
83
115
|
|
|
@@ -98,7 +130,7 @@ async function login() {
|
|
|
98
130
|
// Wall hands over is opened on this computer, and a file: or another site's page opens whatever it names.
|
|
99
131
|
const page = signInPage(link, wall);
|
|
100
132
|
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.`);
|
|
101
|
-
out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${user_code} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
|
|
133
|
+
out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${oneLine(user_code)} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
|
|
102
134
|
openBrowser(page);
|
|
103
135
|
let every = Math.max(1, Number(interval) || 5) * 1000;
|
|
104
136
|
const until = Date.now() + Number(expires_in) * 1000;
|
|
@@ -114,44 +146,46 @@ async function login() {
|
|
|
114
146
|
const all = readCredentials();
|
|
115
147
|
all[wall] = { token, email, at: new Date().toISOString() };
|
|
116
148
|
writeCredentials(all);
|
|
117
|
-
out(`\n\n Signed in${email ? ` as ${email}` : ''} · ${side}.\n`);
|
|
149
|
+
out(`\n\n Signed in${email ? ` as ${oneLine(email)}` : ''} · ${side}.\n`);
|
|
118
150
|
return token;
|
|
119
151
|
}
|
|
120
152
|
const why = poll.json.error;
|
|
121
153
|
if (why === 'slow_down') every += 5000;
|
|
122
154
|
else if (why === 'expired_token') fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
123
155
|
else if (why === 'access_denied') fail('The sign-in was denied in the browser. Nothing was signed in.');
|
|
124
|
-
else if (why && why !== 'authorization_pending') fail(`The Wall answered "${why}"${poll.json.error_description ? `: ${poll.json.error_description}` : ''}.`);
|
|
156
|
+
else if (why && why !== 'authorization_pending') fail(`The Wall answered "${oneLine(why)}"${poll.json.error_description ? `: ${oneLine(poll.json.error_description)}` : ''}.`);
|
|
125
157
|
}
|
|
126
158
|
fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
127
159
|
}
|
|
128
160
|
|
|
129
161
|
/** A token to act with: the stored one, else a sign-in right here — `wire` in a fresh terminal is one command, not two. */
|
|
130
162
|
async function tokenOrLogin() {
|
|
131
|
-
|
|
163
|
+
const token = storedToken();
|
|
164
|
+
if (!token && envElsewhere()) out(envElsewhere());
|
|
165
|
+
return token ?? (await login());
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
async function whoami() {
|
|
135
169
|
const token = storedToken();
|
|
136
|
-
if (!token) fail(`Not signed in to ${side} (${wall}). Run \`toolaby login${live ? ' --live' : ''}
|
|
170
|
+
if (!token) fail(`Not signed in to ${side} (${wall}). Run \`toolaby login${live ? ' --live' : ''}\`.${envElsewhere()}`);
|
|
137
171
|
const me = await api('/api/cli/me', { token });
|
|
138
172
|
if (me.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
|
|
139
173
|
if (!me.ok) fail(`The Wall answered HTTP ${me.status}.`);
|
|
140
|
-
out(`\n ${me.json.email} · ${side} · ${wall}\n`);
|
|
141
|
-
for (const w of me.json.workspaces) out(` ${w.name} (${w.slug}, ${w.role}) ${w.site}`);
|
|
174
|
+
out(`\n ${oneLine(me.json.email)} · ${side} · ${wall}\n`);
|
|
175
|
+
for (const w of me.json.workspaces) out(` ${oneLine(w.name)} (${oneLine(w.slug)}, ${oneLine(w.role)}) ${oneLine(w.site)}`);
|
|
142
176
|
out();
|
|
143
177
|
}
|
|
144
178
|
|
|
145
179
|
async function logout() {
|
|
146
180
|
const all = readCredentials();
|
|
147
181
|
const entry = all[wall];
|
|
148
|
-
if (!entry && !
|
|
182
|
+
if (!entry && !envToken()) { out(`\n Not signed in to ${side} (${wall}).\n`); return; }
|
|
149
183
|
// Revoked on the server first; forgotten here whatever the server said — but the difference is reported.
|
|
150
|
-
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ??
|
|
184
|
+
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ?? envToken() }).catch(() => ({ ok: false, status: 0 }));
|
|
151
185
|
delete all[wall];
|
|
152
186
|
writeCredentials(all);
|
|
153
187
|
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`);
|
|
154
|
-
if (
|
|
188
|
+
if (envToken()) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
|
|
155
189
|
}
|
|
156
190
|
|
|
157
191
|
async function tools() {
|
|
@@ -161,7 +195,7 @@ async function tools() {
|
|
|
161
195
|
if (!res.ok) fail(`The Wall answered HTTP ${res.status}.`);
|
|
162
196
|
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; }
|
|
163
197
|
out(`\n ${side}\n`);
|
|
164
|
-
for (const t of res.json.tools) out(` ${t.slug.padEnd(40)} ${t.name}${t.bundle ? ' (bundle)' : ''} ${t.site}`);
|
|
198
|
+
for (const t of res.json.tools) out(` ${oneLine(t.slug).padEnd(40)} ${oneLine(t.name)}${t.bundle ? ' (bundle)' : ''} ${oneLine(t.site)}`);
|
|
165
199
|
out();
|
|
166
200
|
}
|
|
167
201
|
|
|
@@ -177,9 +211,11 @@ function listTree(dir, depth, prefix = '', acc = new Set()) {
|
|
|
177
211
|
}
|
|
178
212
|
return acc;
|
|
179
213
|
}
|
|
180
|
-
const readJson = (path) => { try { return JSON.parse(
|
|
214
|
+
const readJson = (path) => { try { return JSON.parse(readText(path)); } catch { return null; } };
|
|
215
|
+
/** A project file's text, or `none` where it is no file to read (lib/safe.js readText): a link to a device reads as nothing. */
|
|
216
|
+
const readOr = (path, none = null) => { try { return readText(path); } catch { return none; } };
|
|
181
217
|
/** Whether a background already starts the platform, outside its comments: then it needs no lines added. */
|
|
182
|
-
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(
|
|
218
|
+
const startsPlatform = (path) => { try { return /\bstartBackground\s*\(/.test(readText(path).replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
|
|
183
219
|
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
184
220
|
/** Whether anything is at the path, a link to nowhere included (existsSync follows a link, and says no for a dangling one). */
|
|
185
221
|
const there = (path) => { try { lstatSync(path); return true; } catch { return false; } };
|
|
@@ -193,7 +229,7 @@ function placesIn(root, paths, named = {}) {
|
|
|
193
229
|
const at = {}; const refused = [];
|
|
194
230
|
for (const rel of new Set(paths)) {
|
|
195
231
|
const r = inside(root, rel);
|
|
196
|
-
if (r.why) refused.push(`${JSON.stringify(rel)}${named[rel] ? ` (${named[rel]})` : ''}: ${r.why}`);
|
|
232
|
+
if (r.why) refused.push(`${JSON.stringify(rel)}${named[rel] ? ` (${named[rel]})` : ''}: ${oneLine(r.why)}`);
|
|
197
233
|
else at[rel] = r.path;
|
|
198
234
|
}
|
|
199
235
|
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.`);
|
|
@@ -204,21 +240,27 @@ function placesIn(root, paths, named = {}) {
|
|
|
204
240
|
async function filesFor(slug, scaffold, manifestKey = null, surfaces = null) {
|
|
205
241
|
const token = await tokenOrLogin();
|
|
206
242
|
const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey); if (surfaces) q.set('surfaces', surfaces);
|
|
243
|
+
// The folder's manifest names another extension than the tool's: switched only when asked (--adopt-key).
|
|
244
|
+
if (manifestKey && flag('--adopt-key')) q.set('adoptKey', '1');
|
|
207
245
|
const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
|
|
208
246
|
if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey, surfaces); }
|
|
247
|
+
if (res.status === 409 && res.json?.error === 'identity_conflict') {
|
|
248
|
+
const offered = oneLine(res.json.offered); const current = oneLine(res.json.current);
|
|
249
|
+
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.`);
|
|
250
|
+
}
|
|
209
251
|
if (res.status === 404) {
|
|
210
252
|
// Say who the command is: the tool may well exist — in a workspace of another account of theirs.
|
|
211
253
|
const me = await api('/api/cli/me', { token }).catch(() => null);
|
|
212
|
-
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.` : '';
|
|
213
|
-
fail(`${res.json.message ?? `No tool "${slug}" in your workspaces.`}${who}`);
|
|
254
|
+
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.` : '';
|
|
255
|
+
fail(`${oneLine(res.json.message ?? `No tool "${slug}" in your workspaces.`)}${who}`);
|
|
214
256
|
}
|
|
215
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
257
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
216
258
|
return res.json;
|
|
217
259
|
}
|
|
218
260
|
|
|
219
261
|
async function wire() {
|
|
220
262
|
const slug = positional[0];
|
|
221
|
-
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');
|
|
263
|
+
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');
|
|
222
264
|
const dir = resolve(opt('--into') ?? '.');
|
|
223
265
|
if (!existsSync(dir)) fail(`${dir} does not exist`);
|
|
224
266
|
const tree = listTree(dir, 3);
|
|
@@ -230,13 +272,13 @@ async function wire() {
|
|
|
230
272
|
const site = plan.site;
|
|
231
273
|
const manifestKey = plan.identity?.manifestKey ?? null;
|
|
232
274
|
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
233
|
-
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ?
|
|
275
|
+
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 });
|
|
234
276
|
// 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.
|
|
235
|
-
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.`);
|
|
277
|
+
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.`);
|
|
236
278
|
|
|
237
279
|
// The extension's own popup and side panel: the manifest's, or — wired before — what the existing configuration remembers.
|
|
238
280
|
const configPath = join(dir, `${project.codeDir}toolaby.config.js`);
|
|
239
|
-
const oldConfig = existsSync(configPath) ?
|
|
281
|
+
const oldConfig = existsSync(configPath) ? readOr(configPath, '') : '';
|
|
240
282
|
const remembered = oldConfig.match(/appPopup:\s*'([^']*)'/)?.[1] || null;
|
|
241
283
|
const rememberedSidePanel = oldConfig.match(/appSidePanel:\s*'([^']*)'/)?.[1] || null;
|
|
242
284
|
// An extension whose own pages draw the panel — what `create` makes, or "your popup first" — keeps them in front.
|
|
@@ -266,7 +308,7 @@ async function wire() {
|
|
|
266
308
|
const wrote = targets.filter((path) => typeof plan.files[path.split('/').pop()] === 'string');
|
|
267
309
|
for (const path of wrote) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
|
|
268
310
|
for (const f of entries) { write(f, plan.files[f]); wrote.push(f); }
|
|
269
|
-
const ownAgents = has('AGENTS.md') && !
|
|
311
|
+
const ownAgents = has('AGENTS.md') && !readOr(join(dir, 'AGENTS.md'), '').includes('TOOLABY.md');
|
|
270
312
|
for (const p of stale) unlinkSync(at[p]);
|
|
271
313
|
const did = [];
|
|
272
314
|
let ownWorker = null;
|
|
@@ -283,12 +325,13 @@ async function wire() {
|
|
|
283
325
|
ownWorker = { file: project.worker, classic: false };
|
|
284
326
|
}
|
|
285
327
|
|
|
286
|
-
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
287
|
-
|
|
328
|
+
out(`\n ${oneLine(plan.name)} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
|
|
329
|
+
// Each thing done is a line, and names what the manifest and the Wall named: the popup, the background, the address.
|
|
330
|
+
out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.map(oneLine).join(',\n ')}` : ''}.\n`);
|
|
288
331
|
if (ownAgents) out(' Your AGENTS.md is your own: add a line so coding agents find the Wall\'s guide — "Before changing anything paid, read TOOLABY.md."\n');
|
|
289
332
|
if (stale.length) out(` Removed: ${stale.join(', ')} — earlier files, now inside toolaby.js.\n`);
|
|
290
333
|
for (const sn of project.snippets) out(` Add to ${sn.file} — yours to paste, the manifest being generated:\n\n${sn.code.split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
291
|
-
if (ownWorker) out(` Your background, ${ownWorker.file}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
334
|
+
if (ownWorker) out(` Your background, ${oneLine(ownWorker.file)}: add${ownWorker.classic ? ' — after giving "background" a "type": "module" in manifest.json (importScripts() is not available in a module worker)' : ''}:\n\n${project.workerLines.trimEnd().split('\n').map((l) => ` ${l}`).join('\n')}\n`);
|
|
292
335
|
if (project.built) out(` ${BUILD_COPY_NOTE}\n`);
|
|
293
336
|
out(` Next: ${project.kind === 'plain' && !project.built ? '' : 'build or start the dev server, then '}chrome://extensions → reload it. The tool's Set up page says "checked in" the moment it starts.`);
|
|
294
337
|
if (project.note) out(` ${project.note}\n`);
|
|
@@ -323,21 +366,27 @@ async function create() {
|
|
|
323
366
|
const entries = AGENT_ENTRY_FILES.filter((f) => typeof plan.files[f] === 'string');
|
|
324
367
|
const icons = [16, 48, 128].map((s) => `icons/icon${s}.png`);
|
|
325
368
|
const at = placesIn(dir, [...wanted, 'manifest.json', ...icons, ...agentFiles.map((f) => f.path), ...entries]);
|
|
369
|
+
// The icons first, each within a megabyte and half a minute, and nothing written until they are in: a Wall at another
|
|
370
|
+
// address (--wall, TOOLABY_WALL) could send one without end, and the new folder took it until the disk was full
|
|
371
|
+
// (25 Sep 2026).
|
|
372
|
+
const iconBytes = {};
|
|
373
|
+
for (const icon of icons) {
|
|
374
|
+
const got = await fetchBounded(`${wall}/boilerplate/${icon}`).catch((e) => ({ why: e.name === 'TimeoutError' ? 'it took longer than 30 seconds' : oneLine(e.message) }));
|
|
375
|
+
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.`);
|
|
376
|
+
iconBytes[icon] = got.body;
|
|
377
|
+
}
|
|
326
378
|
const write = (rel, content) => { mkdirSync(dirname(at[rel]), { recursive: true }); writeFileSync(at[rel], content); };
|
|
327
379
|
// Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
|
|
328
380
|
for (const f of wanted) write(f, fillPages(plan.files[f]));
|
|
329
381
|
// The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
|
|
330
382
|
// missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
|
|
331
383
|
// The extension's identity too: every copy gets the id the tool key is bound to, wherever its folder is.
|
|
332
|
-
write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(
|
|
333
|
-
for (const icon of icons)
|
|
334
|
-
const res = await fetch(`${wall}/boilerplate/${icon}`);
|
|
335
|
-
write(icon, Buffer.from(await res.arrayBuffer()));
|
|
336
|
-
}
|
|
384
|
+
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');
|
|
385
|
+
for (const icon of icons) write(icon, iconBytes[icon]);
|
|
337
386
|
for (const f of agentFiles) write(f.path, plan.files[f.name]);
|
|
338
387
|
for (const f of entries) write(f, plan.files[f]);
|
|
339
388
|
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];
|
|
340
|
-
out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.buyer ?? `${plan.site}/tools/${slug}`}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${wall}/docs/surfaces\n`);
|
|
389
|
+
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`);
|
|
341
390
|
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`);
|
|
342
391
|
}
|
|
343
392
|
|
|
@@ -350,7 +399,18 @@ async function create() {
|
|
|
350
399
|
// (or one it prints) and POSTs it to the URL — a server on localhost, no
|
|
351
400
|
// tunnel, no deploy — so a handler can be written and run before anything
|
|
352
401
|
// is registered.
|
|
353
|
-
|
|
402
|
+
// This machine's development secret: made once, kept beside the credentials (mode 600), never the same as anyone
|
|
403
|
+
// else's. It was one constant, shipped in the package and printed in the docs, and a server deployed with it took
|
|
404
|
+
// forged events from anyone (25 Sep 2026).
|
|
405
|
+
const DEV_SECRET_FILE = join(CONFIG_DIR, 'dev-webhook-secret');
|
|
406
|
+
function devSecret() {
|
|
407
|
+
try { const kept = readFileSync(DEV_SECRET_FILE, 'utf8').trim(); if (/^whsec_[A-Za-z0-9+/=]{20,}$/.test(kept)) return kept; } catch { /* made below */ }
|
|
408
|
+
const made = `whsec_${randomBytes(24).toString('base64')}`;
|
|
409
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
410
|
+
writeFileSync(DEV_SECRET_FILE, made + '\n', { mode: 0o600 });
|
|
411
|
+
try { chmodSync(DEV_SECRET_FILE, 0o600); } catch { /* not every filesystem has modes */ }
|
|
412
|
+
return made;
|
|
413
|
+
}
|
|
354
414
|
async function catalogue() {
|
|
355
415
|
const res = await api('/api/cli/webhooks/events', { token: await tokenOrLogin() });
|
|
356
416
|
if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`toolaby login${live ? ' --live' : ''}\`.`);
|
|
@@ -362,8 +422,13 @@ async function webhooks() {
|
|
|
362
422
|
if (sub === 'events') {
|
|
363
423
|
const events = await catalogue();
|
|
364
424
|
out(`\n ${side}\n`);
|
|
365
|
-
for (const e of events) out(` ${e.type.padEnd(26)} ${e.description}`);
|
|
366
|
-
out(`\n toolaby webhooks trigger <event> → your registered endpoints\n toolaby webhooks trigger <event> --to <url> → a local server, signed with
|
|
425
|
+
for (const e of events) out(` ${oneLine(e.type).padEnd(26)} ${oneLine(e.description)}`);
|
|
426
|
+
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`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (sub === 'secret') {
|
|
430
|
+
// For the server on this machine: `WALL_WEBHOOK_SECRET=$(npx -y toolaby@latest webhooks secret) node server.mjs`.
|
|
431
|
+
process.stdout.write(devSecret() + '\n');
|
|
367
432
|
return;
|
|
368
433
|
}
|
|
369
434
|
if (sub === 'trigger') {
|
|
@@ -372,14 +437,14 @@ async function webhooks() {
|
|
|
372
437
|
const to = opt('--to');
|
|
373
438
|
if (!to) {
|
|
374
439
|
const res = await api('/api/cli/webhooks/trigger', { method: 'POST', body: { type, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
375
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
376
|
-
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`);
|
|
440
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
441
|
+
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`);
|
|
377
442
|
return;
|
|
378
443
|
}
|
|
379
444
|
const events = await catalogue();
|
|
380
445
|
const event = events.find((e) => e.type === type);
|
|
381
446
|
if (!event) fail(`No event "${type}". \`toolaby webhooks events\` lists them.`);
|
|
382
|
-
const secret = opt('--secret') ??
|
|
447
|
+
const secret = opt('--secret') ?? devSecret();
|
|
383
448
|
if (!/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) fail('--secret must be a Standard Webhooks secret: whsec_ followed by base64.');
|
|
384
449
|
const id = `msg_${randomBytes(12).toString('base64url')}`;
|
|
385
450
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
@@ -390,18 +455,18 @@ async function webhooks() {
|
|
|
390
455
|
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 });
|
|
391
456
|
} catch (e) { fail(`Could not reach ${to}: ${e.message}`); }
|
|
392
457
|
const text = (await res.text()).slice(0, 200);
|
|
393
|
-
out(`\n POST ${to} → ${res.status}${text ? ` ${text
|
|
394
|
-
out(` ${type}, signed with ${opt('--secret') ? 'your secret' :
|
|
458
|
+
out(`\n POST ${to} → ${res.status}${text ? ` ${oneLine(text)}` : ''}`);
|
|
459
|
+
out(` ${type}, signed with ${opt('--secret') ? 'your secret' : "this machine's development secret — `npx -y toolaby@latest webhooks secret` prints it for WALL_WEBHOOK_SECRET"}.\n`);
|
|
395
460
|
if (res.status >= 300) out(' A real delivery would be retried until your server answers 2xx.\n');
|
|
396
461
|
return;
|
|
397
462
|
}
|
|
398
463
|
if (sub === 'endpoints') {
|
|
399
464
|
const ws = opt('--workspace');
|
|
400
465
|
const res = await api(`/api/cli/webhooks/endpoints${ws ? `?workspace=${encodeURIComponent(ws)}` : ''}`, { token: await tokenOrLogin() });
|
|
401
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
402
|
-
out(`\n ${res.json.workspace} · ${side}\n`);
|
|
466
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
467
|
+
out(`\n ${oneLine(res.json.workspace)} · ${side}\n`);
|
|
403
468
|
if (res.json.endpoints.length === 0) out(' None yet. `toolaby webhooks add <url>` registers one.');
|
|
404
|
-
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(', ')})` : ''}`);
|
|
469
|
+
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(', '))})` : ''}`);
|
|
405
470
|
out();
|
|
406
471
|
return;
|
|
407
472
|
}
|
|
@@ -410,16 +475,16 @@ async function webhooks() {
|
|
|
410
475
|
if (!url) fail('usage: toolaby webhooks add <url> [--description <text>] [--events a.b,c.d] [--workspace <slug>]');
|
|
411
476
|
const events = (opt('--events') ?? '').split(',').map((e) => e.trim()).filter(Boolean);
|
|
412
477
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'POST', body: { url, description: opt('--description') ?? '', events, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
413
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
414
|
-
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`);
|
|
478
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
479
|
+
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`);
|
|
415
480
|
return;
|
|
416
481
|
}
|
|
417
482
|
if (sub === 'remove') {
|
|
418
483
|
const id = positional[1];
|
|
419
484
|
if (!id) fail('usage: toolaby webhooks remove <ep_…> [--workspace <slug>] — `toolaby webhooks endpoints` lists them');
|
|
420
485
|
const res = await api('/api/cli/webhooks/endpoints', { method: 'DELETE', body: { id, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
|
|
421
|
-
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
422
|
-
out(`\n Removed ${res.json.removed} · ${res.json.workspace} · ${side}. Deliveries to it stop at once.\n`);
|
|
486
|
+
if (!res.ok) fail(oneLine(res.json.message ?? `The Wall answered HTTP ${res.status}.`));
|
|
487
|
+
out(`\n Removed ${oneLine(res.json.removed)} · ${oneLine(res.json.workspace)} · ${side}. Deliveries to it stop at once.\n`);
|
|
423
488
|
return;
|
|
424
489
|
}
|
|
425
490
|
fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
|
|
@@ -469,41 +534,62 @@ function zipOf(entries) {
|
|
|
469
534
|
|
|
470
535
|
/**
|
|
471
536
|
* What goes in the zip: every file in the folder but dotfiles, node_modules and zips — and, left out with the reason,
|
|
472
|
-
* a file that looks private (lib/safe.js), which --allow-private packs anyway, and a
|
|
473
|
-
*
|
|
474
|
-
*
|
|
537
|
+
* a file that looks private by its name or its contents (lib/safe.js), which --allow-private packs anyway, and never a
|
|
538
|
+
* 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
|
|
539
|
+
* .env or .git, under an innocent name (25 Sep 2026). Nor a name no zip should hold — a backslash, which unzips as a
|
|
540
|
+
* folder, or a control character — nor anything but a file. A zip is public once the store serves it.
|
|
541
|
+
*
|
|
542
|
+
* A build has already followed the links of the project it came from: what they pointed at is an ordinary file here.
|
|
543
|
+
* So a file is also private when it is a copy of one of the project's (`copyOf`), a folder when it is a git
|
|
544
|
+
* repository's own, and a file named as an image when it holds none (round 7, 25 Sep 2026).
|
|
475
545
|
*/
|
|
476
|
-
function filesIn(root) {
|
|
477
|
-
const
|
|
478
|
-
const
|
|
546
|
+
function filesIn(root, copyOf = () => null) {
|
|
547
|
+
const found = []; const left = []; const privately = [];
|
|
548
|
+
const allowed = flag('--allow-private');
|
|
479
549
|
const walk = (dir) => {
|
|
480
550
|
for (const name of readdirSync(dir).sort()) {
|
|
481
551
|
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
482
552
|
const p = join(dir, name);
|
|
483
|
-
const rel = relative(root, p).split(
|
|
484
|
-
|
|
485
|
-
if (
|
|
486
|
-
if (
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
553
|
+
const rel = relative(root, p).split(sep).join('/');
|
|
554
|
+
const st = lstatSync(p);
|
|
555
|
+
if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
|
|
556
|
+
if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
|
|
557
|
+
if (st.isDirectory()) {
|
|
558
|
+
// Left out whole, unwalked: its config and its history are the repository's, whatever the folder is called.
|
|
559
|
+
if (gitDirectory(p)) { if (!allowed) { left.push([rel, 'a git directory']); continue; } privately.push([rel, 'a git directory']); }
|
|
560
|
+
walk(p);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (!st.isFile()) { left.push([rel, 'not a file']); continue; }
|
|
564
|
+
const bytes = readFileSync(p);
|
|
565
|
+
const why = privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? notAnImage(name, bytes);
|
|
566
|
+
if (why && !allowed) left.push([rel, why]);
|
|
567
|
+
else { found.push(p); if (why) privately.push([rel, why]); }
|
|
490
568
|
}
|
|
491
569
|
};
|
|
492
570
|
walk(root);
|
|
493
|
-
return { found, left };
|
|
571
|
+
return { found, left, privately };
|
|
494
572
|
}
|
|
495
573
|
|
|
574
|
+
/** A path as the developer knows it: under their home folder, from ~. */
|
|
575
|
+
const fromHome = (path) => (path.startsWith(homedir() + sep) ? `~${path.slice(homedir().length)}` : path);
|
|
576
|
+
|
|
496
577
|
function pack() {
|
|
497
578
|
const given = positional[0];
|
|
498
579
|
const folder = given ? resolve(given) : BUILD_DIRS.map((d) => resolve(d)).find((d) => existsSync(join(d, 'manifest.json')));
|
|
499
580
|
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>.`);
|
|
500
581
|
let manifest;
|
|
501
|
-
|
|
582
|
+
// The parser's message quotes the file it could not read, line breaks and all.
|
|
583
|
+
try { manifest = JSON.parse(readText(join(folder, 'manifest.json'))); } catch (e) { fail(`manifest.json does not parse: ${oneLine(e.message)}`); }
|
|
502
584
|
if (!manifest.version) fail('manifest.json has no "version".');
|
|
503
585
|
// One to four numbers, as Chrome and the store take it — and a file name here: the zip is named with it, and a
|
|
504
586
|
// version with "../" in it put the zip outside the folder.
|
|
505
587
|
if (typeof manifest.version !== 'string' || !/^\d{1,5}(\.\d{1,5}){0,3}$/.test(manifest.version)) fail(`manifest.json's "version" is ${JSON.stringify(manifest.version)}: Chrome takes one to four numbers separated by dots, like 1.2.0.`);
|
|
506
|
-
|
|
588
|
+
// The project the build came from — or, with none above it, the folder alone — and the command's own secrets: what a
|
|
589
|
+
// build could have copied in under another name, by following a link (lib/safe.js).
|
|
590
|
+
const project = projectRoot(folder);
|
|
591
|
+
const copyOf = privateCopies(project ?? folder, [[CREDENTIALS, `${fromHome(CREDENTIALS)}, this command's session`], [DEV_SECRET_FILE, `${fromHome(DEV_SECRET_FILE)}, this machine's webhook secret`]]);
|
|
592
|
+
const { found: files, left, privately } = filesIn(folder, copyOf);
|
|
507
593
|
const problems = [];
|
|
508
594
|
|
|
509
595
|
// Which side the build sells on: the tool keys inside it, wherever the bundler put them.
|
|
@@ -515,40 +601,46 @@ function pack() {
|
|
|
515
601
|
try { const facts = JSON.parse(Buffer.from(k.split('_')[2], 'base64url').toString('utf8')); if (typeof facts.s === 'string') sites.add(facts.s.replace(/\/$/, '')); } catch { /* not a key after all */ }
|
|
516
602
|
}
|
|
517
603
|
}
|
|
518
|
-
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development Wall'}. Run \`npx toolaby wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
|
|
604
|
+
if ((modes.has('test') || modes.has('dev')) && !flag('--allow-test')) problems.push(`It carries a ${[...modes].filter((m) => m !== 'live').map((m) => `tk_${m}_`).join(' and ')} key: it would sell on ${modes.has('test') ? 'Test, with test cards' : 'a development Wall'}. Run \`npx -y toolaby@latest wire <tool-id> --live --force\`, build again, then pack. (--allow-test packs it anyway, for a build you will not publish.)`);
|
|
519
605
|
if (modes.size === 0) out('\n No tool key found in the build: it is not wired to the Wall, or the key is somewhere this cannot read. Packing anyway.');
|
|
520
606
|
|
|
521
607
|
// Addresses of this machine have no place in a store build.
|
|
522
608
|
const local = [...(manifest.host_permissions ?? []), ...(manifest.externally_connectable?.matches ?? [])].filter((m) => typeof m === 'string' && LOCAL.test(m));
|
|
523
|
-
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.)`);
|
|
609
|
+
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.)`);
|
|
524
610
|
|
|
525
611
|
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
526
612
|
for (const site of sites) {
|
|
527
613
|
const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
|
|
528
|
-
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.`);
|
|
614
|
+
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 -y toolaby@latest wire <tool-id> --live --force\` adds them.`);
|
|
529
615
|
}
|
|
530
616
|
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
531
617
|
|
|
532
618
|
const hadKey = typeof manifest.key === 'string';
|
|
533
619
|
const { key: _key, ...forStore } = manifest;
|
|
534
620
|
const entries = files.map((f) => {
|
|
535
|
-
const name = relative(folder, f).split(
|
|
621
|
+
const name = relative(folder, f).split(sep).join('/');
|
|
536
622
|
return [name, name === 'manifest.json' ? Buffer.from(JSON.stringify(forStore, null, 2) + '\n') : readFileSync(f)];
|
|
537
623
|
});
|
|
538
624
|
// Named after the extension, or after its folder when the name is a __MSG_…__ placeholder.
|
|
539
625
|
const named = typeof manifest.name === 'string' && !manifest.name.startsWith('__MSG_') ? manifest.name : basename(folder);
|
|
540
626
|
const safe = named.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'extension';
|
|
541
627
|
const target = resolve(opt('--out') ?? `${safe}-${manifest.version}.zip`);
|
|
542
|
-
|
|
628
|
+
// Written beside it under a name nobody could have made, then moved over it: a link a cloned project left under the
|
|
629
|
+
// zip's own name took the write wherever it pointed (25 Sep 2026). A move replaces the link itself.
|
|
630
|
+
const fresh = `${target}.${process.pid}.${randomBytes(6).toString('hex')}.part`;
|
|
631
|
+
writeFileSync(fresh, zipOf(entries), { flag: 'wx' });
|
|
632
|
+
renameSync(fresh, target);
|
|
543
633
|
const kb = (statSync(target).size / 1024).toFixed(0);
|
|
544
634
|
out(`\n ${relative(process.cwd(), target) || target} · ${entries.length} files, ${kb} KB · version ${manifest.version}${modes.has('live') ? ' · Live key' : ''}`);
|
|
545
635
|
// What was left out, and why — or, asked for, what private file went in: a store zip is public.
|
|
546
|
-
const links = left.filter(([, why]) => why
|
|
547
|
-
const kept = left.filter((
|
|
548
|
-
if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${f} (${why})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
|
|
549
|
-
if (links.length) out(` Left out: ${links.map(([f, why]) => `${f} (${why})`).join(', ')}. The zip holds the folder's own files: copy in what belongs in it.`);
|
|
550
|
-
|
|
551
|
-
|
|
636
|
+
const links = left.filter(([, why]) => why === 'a link' || why === 'not a file' || why === 'a name no zip should hold');
|
|
637
|
+
const kept = left.filter((entry) => !links.includes(entry));
|
|
638
|
+
if (kept.length) out(` Left out, as private: ${kept.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone can download a store item and unzip it; --allow-private packs ${kept.length > 1 ? 'them' : 'it'} anyway.`);
|
|
639
|
+
if (links.length) out(` Left out: ${links.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. The zip holds the folder's own files: copy in what belongs in it.`);
|
|
640
|
+
if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${oneLine(f)} (${why})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
|
|
641
|
+
// Said, not refused: the links are the project's, not the build's, and the build may hold nothing of theirs yet.
|
|
642
|
+
const linked = project ? publicLinks(project, folder) : [];
|
|
643
|
+
if (linked.length) out(` Links in the folders a build copies as they are: ${linked.slice(0, 8).map(oneLine).join(', ')}${linked.length > 8 ? ` and ${linked.length - 8} more` : ''}. The build copies what each points at, a private file too: make each the file itself.`);
|
|
552
644
|
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.');
|
|
553
645
|
out(' Upload it at https://chrome.google.com/webstore/devconsole — a new item, or Package → Upload new package.\n');
|
|
554
646
|
}
|
|
@@ -578,7 +670,8 @@ function help() {
|
|
|
578
670
|
toolaby tools your tools, with their ids
|
|
579
671
|
toolaby pack [folder] the zip for the Chrome Web Store: the built extension, its manifest's "key" left out;
|
|
580
672
|
refuses a Test key or localhost inside --out <file.zip>
|
|
581
|
-
leaves out keys, .env files
|
|
673
|
+
leaves out keys, certificates, .env and credential files, source maps, live secrets,
|
|
674
|
+
a build's copy of a private file, a git folder, an image that is none --allow-private packs them
|
|
582
675
|
toolaby check [folder] whether the extension here is wired right: files, key, manifest, client, features;
|
|
583
676
|
--browser loads it in Chromium (needs Playwright in the project)
|
|
584
677
|
toolaby mcp the Wall for coding agents, an MCP server on stdio:
|
|
@@ -589,11 +682,15 @@ function help() {
|
|
|
589
682
|
toolaby login · whoami · logout the session, per side; a command that needs one signs you in itself
|
|
590
683
|
|
|
591
684
|
Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
|
|
592
|
-
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI
|
|
685
|
+
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI —
|
|
686
|
+
sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://test.toolaby.app for Test).
|
|
687
|
+
|
|
688
|
+
Not installed: npx -y toolaby@latest <command>. In a repository you did not write, install it (npm i -g toolaby)
|
|
689
|
+
and type toolaby: npx can run a toolaby the project carries, or fetch one from a registry its .npmrc names.
|
|
593
690
|
`);
|
|
594
691
|
}
|
|
595
692
|
|
|
596
693
|
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) };
|
|
597
694
|
const run = commands[command];
|
|
598
695
|
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
599
|
-
Promise.resolve().then(run).catch((e) => fail(e.message));
|
|
696
|
+
Promise.resolve().then(run).catch((e) => fail(oneLine(e.message)));
|