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 +1 -1
- package/bin.mjs +17 -9
- package/lib/safe.js +24 -5
- package/package.json +1 -1
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://
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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": {
|