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/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); TOOLABY_WALL or
27
- // `--wall <url>` names any other Wall (a laptop's, a self-hosted one).
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.1';
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
- const wall = (opt('--wall') ?? process.env.TOOLABY_WALL ?? (live ? WALLS.live : WALLS.test)).replace(/\/$/, '');
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
- const out = (s = '') => console.log(s);
60
- const fail = (s) => { console.error(`\n ${s}\n`); process.exit(1); };
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
- const storedToken = () => process.env.TOOLABY_TOKEN || readCredentials()[wall]?.token || null;
72
-
73
- async function api(path, { method = 'GET', body, token = storedToken(), raw = false } = {}) {
74
- const res = await fetch(`${wall}${path}`, {
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 (raw) return res;
80
- const json = await res.json().catch(() => ({}));
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
- return storedToken() ?? (await login());
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 && !process.env.TOOLABY_TOKEN) { out(`\n Not signed in to ${side} (${wall}).\n`); return; }
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 ?? process.env.TOOLABY_TOKEN }).catch(() => ({ ok: false, status: 0 }));
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 (process.env.TOOLABY_TOKEN) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
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(readFileSync(path, 'utf8')); } catch { return null; } };
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(readFileSync(path, 'utf8').replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, '')); } catch { return false; } };
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 ? readFileSync(join(dir, configSource), 'utf8') : null, site, tree, manifestKey });
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) ? readFileSync(configPath, 'utf8') : '';
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') && !readFileSync(join(dir, 'AGENTS.md'), 'utf8').includes('TOOLABY.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
- out(` Written: ${wrote.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
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(readFileSync(at['manifest.json'], 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces), key: plan.identity?.manifestKey ?? null }), null, 2) + '\n');
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
- const DEV_SECRET = 'whsec_dG9vbGFieS1kZXYtd2ViaG9vay1zZWNyZXQtMDAwMQ==';
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 a secret you name\n`);
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') ?? DEV_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.replace(/\s+/g, ' ')}` : ''}`);
394
- out(` ${type}, signed with ${opt('--secret') ? 'your secret' : `${DEV_SECRET} — set WALL_WEBHOOK_SECRET to it to verify`}.\n`);
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 link that leads out of the folder
473
- * or to nothing, which is never packed: a link in a cloned repository can put ~/.ssh/id_rsa in a store zip as logo.png.
474
- * A zip is public once the store serves it. A folder reached twice through links is walked once.
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 top = realpathSync(root);
478
- const found = []; const left = []; const walked = new Set([top]);
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('\\').join('/');
484
- let real = null; try { real = realpathSync(p); } catch { /* a link to nothing */ }
485
- if (!real || (real !== top && !real.startsWith(top + sep))) { left.push([rel, real ? 'a link out of the folder' : 'a link to nothing']); continue; }
486
- if (statSync(p).isDirectory()) { if (!walked.has(real)) { walked.add(real); walk(p); } continue; }
487
- const why = privateKind(name);
488
- if (why && !flag('--allow-private')) left.push([rel, why]);
489
- else found.push(p);
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
- try { manifest = JSON.parse(readFileSync(join(folder, 'manifest.json'), 'utf8')); } catch (e) { fail(`manifest.json does not parse: ${e.message}`); }
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
- const { found: files, left } = filesIn(folder);
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('\\').join('/');
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
- writeFileSync(target, zipOf(entries));
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.startsWith('a link'));
547
- const kept = left.filter(([, why]) => !why.startsWith('a link'));
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
- const privately = flag('--allow-private') ? files.map((f) => [relative(folder, f).split('\\').join('/'), privateKind(basename(f))]).filter(([, why]) => why) : [];
551
- if (privately.length) out(` Packed as asked (--allow-private): ${privately.map(([f, why]) => `${f} (${why})`).join(', ')}. Anyone who downloads the item can read ${privately.length > 1 ? 'them' : 'it'}.`);
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 and source maps --allow-private packs them
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)));