toolaby 1.8.0 → 1.8.1

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 CHANGED
@@ -38,7 +38,7 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
38
38
  ## Where things live
39
39
 
40
40
  - Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
41
- - The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI), and is sent to one Wall only: Live (`toolaby.app`), or the one `TOOLABY_TOKEN_WALL` names — `TOOLABY_TOKEN_WALL=https://test.toolaby.app` for a Test session. If that Wall refuses it, the session `toolaby login` stored for that Wall is used instead. `TOOLABY_WALL` or `--wall <url>` names any other Wall.
41
+ - The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI), and is sent to one Wall only: Live (`toolaby.app`), or the one `TOOLABY_TOKEN_WALL` names — `TOOLABY_TOKEN_WALL=https://toolaby.app/test` for a Test session. If that Wall refuses it, the session `toolaby login` stored for that Wall is used instead. `TOOLABY_WALL` or `--wall <url>` names any other Wall.
42
42
 
43
43
  `wire` and `create` write only inside the folder they work on, and never through a symbolic link. A manifest, a framework's config or a Wall that names a place outside it (`../`, an absolute path, a link) is refused before anything is written. `create` writes only the files it knows a new extension has.
44
44
 
package/bin.mjs CHANGED
@@ -44,7 +44,7 @@ import { runMcp } from './lib/mcp.js';
44
44
  import { runCheck } from './lib/check.js';
45
45
  import { inside, signInPage, devicePolling, opener, privateKind, privateContent, privateCopies, googleKey, projectRoot, gitDirectory, gitObject, gitObjects, gitFile, notAnImage, unpacked, COMPRESSED, publicLinks, privateLinks, oneLine, fetchBounded, fetchHere, readBounded, readText, SCAFFOLD_FILES } from './lib/safe.js';
46
46
 
47
- const VERSION = '1.8.0';
47
+ const VERSION = '1.8.1';
48
48
  const CLIENT_ID = 'toolaby-cli';
49
49
  const argv = process.argv.slice(2);
50
50
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -105,9 +105,10 @@ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
105
105
  // The time ran out (fetchHere's deadline; AbortError where an older Node says so): one line, whichever part was late.
106
106
  // A Wall that answered a byte at a time held `login` and `wire` for ever (round 8, 25 Sep 2026).
107
107
  const late = (e) => (e?.name === 'TimeoutError' || e?.name === 'AbortError' ? new Error(`The Wall at ${wall} did not answer ${path.split('?')[0]} within ${ANSWER_TIME / 1000} seconds.`) : null);
108
- // Only the Wall answers for the Wall: a redirect elsewhere is refused (lib/safe.js).
108
+ // Only the Wall answers for the Wall: a redirect elsewhere — Live's /test is Test's — is refused (lib/safe.js).
109
109
  const res = await fetchHere(`${wall}${path}`, {
110
110
  ms: ANSWER_TIME,
111
+ wall,
111
112
  method,
112
113
  headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
113
114
  body: body ? JSON.stringify(body) : undefined,
@@ -534,6 +535,8 @@ const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
534
535
  const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
535
536
  /** Why a file is left out as no file of the folder's own, not as a private one: never packed, --allow-private or not. */
536
537
  const NOT_OWN = ['a link', 'not a file', 'a name no zip should hold'];
538
+ /** What pack never takes and never says it left out: a folder's dot-names, its node_modules, a zip beside the build. */
539
+ const NEVER_PACKED = ['a name starting with a dot, never packed', 'node_modules, never packed', 'a zip, never packed'];
537
540
 
538
541
  /**
539
542
  * The files a manifest names, each with what it is to the extension, as the zip spells them: no leading ./ or /, no
@@ -622,15 +625,17 @@ function zipOf(entries) {
622
625
  * And git's other files are git's, whatever their names — its index, its refs, its logs, a config (round 9, 25 Sep 2026).
623
626
  */
624
627
  function filesIn(root, copyOf = () => null, fromLink = () => null) {
625
- const found = []; const left = []; const privately = [];
628
+ const found = []; const left = []; const privately = []; const skipped = [];
626
629
  const allowed = flag('--allow-private');
627
630
  const judge = (name, bytes) => privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? gitObject(bytes) ?? gitFile(bytes) ?? notAnImage(name, bytes);
628
631
  const judged = new Map(); // what each file found private is, by its path: a compressed one beside it is the same
629
632
  const walk = (dir) => {
630
633
  for (const name of readdirSync(dir).sort()) {
631
- if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
632
634
  const p = join(dir, name);
633
635
  const rel = relative(root, p).split(sep).join('/');
636
+ // Never packed, and not said: kept apart all the same, so a manifest that names one is still refused below — a
637
+ // content script under node_modules or a dot-folder made a zip Chrome did not load (29 Sep 2026).
638
+ if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) { skipped.push([rel, NEVER_PACKED[name.startsWith('.') ? 0 : name === 'node_modules' ? 1 : 2]]); continue; }
634
639
  const st = lstatSync(p);
635
640
  if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
636
641
  if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
@@ -660,7 +665,7 @@ function filesIn(root, copyOf = () => null, fromLink = () => null) {
660
665
  }
661
666
  };
662
667
  walk(root);
663
- return { found, left, privately };
668
+ return { found, left, privately, skipped };
664
669
  }
665
670
 
666
671
  /** A path as the developer knows it: under their home folder, from ~. */
@@ -692,7 +697,7 @@ function pack() {
692
697
  if (whole.length && !allowed) fail(`Not packed: ${whole.map((l) => `${oneLine(l.link)} is ${l.why}`).join(', ')}. A build copies what a public folder links to, whole, to the top of its output.\n Make ${whole.length > 1 ? 'each' : 'it'} a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
693
698
  if (gitDirectory(folder) && !allowed) fail(`Not packed: ${oneLine(relative(process.cwd(), folder) || '.')} is a git repository's own folder (HEAD, config and its history), as a build leaves when its public folder is a link to .git.\n Make the public folder a folder of its own, build again, and pack. (--allow-private packs it anyway.)`);
694
699
  const fromLink = (rel) => { const hit = leaking.find((l) => l.within && (rel === l.within || rel.startsWith(`${l.within}/`))); return hit ? `from ${hit.link}, ${hit.why}` : null; };
695
- const { found: files, left, privately } = filesIn(folder, copyOf, fromLink);
700
+ const { found: files, left, privately, skipped } = filesIn(folder, copyOf, fromLink);
696
701
  // What was left out as no file of the folder's own, and what as private.
697
702
  const links = left.filter(([, why]) => NOT_OWN.includes(why));
698
703
  const kept = left.filter((entry) => !links.includes(entry));
@@ -701,14 +706,17 @@ function pack() {
701
706
  // an icon: refused, and nothing written (round 9, 25 Sep 2026). --allow-private packs a private one; a link, never.
702
707
  const broken = [];
703
708
  for (const [path, what] of namedFiles(manifest)) {
704
- const gone = left.find(([rel]) => path.toLowerCase() === rel.toLowerCase() || path.toLowerCase().startsWith(`${rel.toLowerCase()}/`));
709
+ const named = ([rel]) => path.toLowerCase() === rel.toLowerCase() || path.toLowerCase().startsWith(`${rel.toLowerCase()}/`);
710
+ const gone = left.find(named) ?? skipped.find(named);
705
711
  if (gone && !broken.some(([p]) => p === path)) broken.push([path, what, gone[1]]);
706
712
  }
707
713
  if (broken.length) {
708
- const secret = broken.filter(([, , why]) => !NOT_OWN.includes(why)).length;
714
+ const secret = broken.filter(([, , why]) => !NOT_OWN.includes(why) && !NEVER_PACKED.includes(why)).length;
715
+ const never = broken.filter(([, , why]) => NEVER_PACKED.includes(why)).length;
709
716
  const advice = [
710
717
  secret && `Take out what is private and build again, or pack ${secret > 1 ? 'them' : 'it'} anyway with --allow-private, for anyone who downloads the item to read.`,
711
- secret < broken.length && 'The zip holds the folder\'s own files, and never a link: make each the file itself.',
718
+ broken.some(([, , why]) => NOT_OWN.includes(why)) && 'The zip holds the folder\'s own files, and never a link: make each the file itself.',
719
+ never && `A zip never holds a dot-name, node_modules or another zip: put ${never > 1 ? 'them' : 'it'} among the extension's own files, as a bundler does.`,
712
720
  ].filter(Boolean);
713
721
  fail(`Not packed: the manifest names ${broken.length > 1 ? 'files' : 'a file'} this leaves out, and the zip would not load:\n\n${broken.map(([path, what, why]) => ` · ${oneLine(path)}, ${what} (${oneLine(why)})`).join('\n')}\n\n ${advice.join('\n ')}`);
714
722
  }
package/lib/safe.js CHANGED
@@ -117,23 +117,41 @@ export async function readBounded(res, limit) {
117
117
  return Buffer.concat(chunks);
118
118
  }
119
119
 
120
+ /** Where Test sits under Live's own addresses: `toolaby.app/test` (the Wall's packages/zone). */
121
+ const TEST_PATH = '/test';
122
+ const under = (path, base) => path === base || path.startsWith(`${base}/`);
123
+
124
+ /**
125
+ * Whether an address is the Wall's: its origin, and for a Wall under a path, that path. Test sits under Live's origin
126
+ * at /test, so an origin no longer tells the two apart: a Wall with no path of its own takes nothing under /test for
127
+ * itself, and one under /test nothing outside it — each side's session goes to its own side alone (29 Sep 2026).
128
+ */
129
+ export function withinWall(address, wall) {
130
+ let page; let home;
131
+ try { page = new URL(address); home = new URL(wall); } catch { return false; }
132
+ if (page.origin !== home.origin) return false;
133
+ const base = home.pathname.replace(/\/+$/, '');
134
+ return base ? under(page.pathname, base) : !under(page.pathname, TEST_PATH);
135
+ }
136
+
120
137
  /**
121
138
  * A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
122
139
  * refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
123
- * for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026).
140
+ * for the Wall's word, and the MCP server handed an agent a page from any host (25 Sep 2026). With `wall`, only within
141
+ * that Wall (withinWall): the same headers go on each hop, the session among them, and Test and Live share an origin.
124
142
  *
125
143
  * And one that gives up at its time: the answer, redirects and body alike — read or not yet — within `ms` of asking, or
126
144
  * a TimeoutError, as fetchBounded's. Bounded in size alone, a Wall that answered a byte at a time held `login`, `wire`
127
145
  * and the MCP server for ever (round 8, 25 Sep 2026).
128
146
  */
129
- export async function fetchHere(url, { ms = 30_000, ...init } = {}) {
147
+ export async function fetchHere(url, { ms = 30_000, wall = null, ...init } = {}) {
130
148
  const signal = AbortSignal.timeout(ms);
131
- const origin = new URL(url).origin;
149
+ const home = wall ?? new URL(url).origin;
132
150
  let res = await fetch(url, { ...init, redirect: 'manual', signal });
133
151
  for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
134
152
  const next = new URL(res.headers.get('location'), url);
135
153
  await res.body?.cancel().catch(() => {});
136
- if (next.origin !== origin) throw new Error(`it answered with a redirect to ${next.origin}, which is not followed`);
154
+ if (!withinWall(next.href, home)) throw new Error(`it answered with a redirect to ${next.origin}${next.origin === new URL(home).origin ? next.pathname : ''}, which is not followed`);
137
155
  url = next.href;
138
156
  res = await fetch(url, { ...init, redirect: 'manual', signal });
139
157
  }
@@ -685,7 +703,8 @@ export function signInPage(link, wall) {
685
703
  try { home = new URL(wall); page = new URL(link, home); } catch { return null; }
686
704
  if (page.username || page.password) return null;
687
705
  const base = home.pathname.replace(/\/+$/, '');
688
- if (base && page.pathname !== base && !page.pathname.startsWith(`${base}/`)) return null;
706
+ // Under the Wall's own path; and for a Wall with none, nothing of Test's under /test (withinWall).
707
+ if (base ? !under(page.pathname, base) : under(page.pathname, TEST_PATH)) return null;
689
708
  if (page.protocol === 'https:' && page.origin === home.origin) return page.href;
690
709
  const web = page.protocol === 'http:' || page.protocol === 'https:';
691
710
  return web && LOOPBACK.test(home.hostname) && LOOPBACK.test(page.hostname) ? page.href : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "description": "The Toolaby command line: sign in once, then wire any Chrome extension to your tool on the Wall — sign-in, the Free plan, licences and subscriptions — or start a new one; check it, and give coding agents the Wall as an MCP server.",
5
5
  "type": "module",
6
6
  "bin": {