toolaby 1.7.2 → 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 +2 -2
- package/bin.mjs +24 -15
- package/lib/check.js +2 -2
- package/lib/manifest.js +20 -6
- package/lib/project.js +18 -1
- package/lib/safe.js +33 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ The command is `toolaby-cli`: on Windows, the Command Prompt runs a `toolaby.js`
|
|
|
12
12
|
|
|
13
13
|
In a repository you did not write, use the installed command: `npx` can run a `toolaby` the project carries, or fetch one from a registry the project's `.npmrc` names.
|
|
14
14
|
|
|
15
|
-
Every command acts on the test Wall (`
|
|
15
|
+
Every command acts on the test Wall (`toolaby.app/test`) unless told `--live` (`toolaby.app`), as Stripe's command line does. `toolaby tools` lists your ids on either side.
|
|
16
16
|
|
|
17
17
|
## What it does
|
|
18
18
|
|
|
@@ -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
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// toolaby whoami · logout · help
|
|
18
18
|
//
|
|
19
19
|
// Test and live, as Stripe's command line has them: every command acts on
|
|
20
|
-
// the TEST Wall (
|
|
20
|
+
// the TEST Wall (toolaby.app/test) unless told `--live`, so the everyday
|
|
21
21
|
// command is the safe one and going live is a word you type on purpose.
|
|
22
22
|
// Signed in, the command acts as you and only on your tools, as Clerk's and
|
|
23
23
|
// Stripe's do: nothing is fetched for a tool your workspaces do not own. The
|
|
@@ -37,14 +37,14 @@ import { deflateRawSync } from 'node:zlib';
|
|
|
37
37
|
import { homedir } from 'node:os';
|
|
38
38
|
import { spawn } from 'node:child_process';
|
|
39
39
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
40
|
-
import { wireManifest, drawsItself, WORKER_LINES, wallWorkerSource, configValues, wrappedBy, namedWorker as workerIn, startsPlatform as startsWall, CLASSIC_CLIENT } from './lib/manifest.js';
|
|
40
|
+
import { wireManifest, sitePattern, drawsItself, WORKER_LINES, wallWorkerSource, configValues, wrappedBy, namedWorker as workerIn, startsPlatform as startsWall, CLASSIC_CLIENT } from './lib/manifest.js';
|
|
41
41
|
import { wiringSummary, popupSwapIn, gatesIn, ownCode } from './lib/summary.js';
|
|
42
42
|
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';
|
|
43
43
|
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.
|
|
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.
|
|
@@ -54,7 +54,8 @@ const positional = rest.filter((a) => !a.startsWith('--'));
|
|
|
54
54
|
const opt = (k, d = null) => { const i = rest.indexOf(k); return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : d; };
|
|
55
55
|
// `upgrade` is `wire --force`: the platform's files replaced with the current ones, the extension's own untouched.
|
|
56
56
|
const flag = (k) => rest.includes(k) || (k === '--force' && command === 'upgrade');
|
|
57
|
-
|
|
57
|
+
// Test shares Live's addresses under /test, as Stripe keeps both modes on its own hosts.
|
|
58
|
+
const WALLS = { test: 'https://toolaby.app/test', live: 'https://toolaby.app' };
|
|
58
59
|
const live = flag('--live');
|
|
59
60
|
// An address as fetch() reads it: a URL loses its tabs and line breaks when it is parsed, and kept in the name every
|
|
60
61
|
// message prints, a --wall or a TOOLABY_WALL with one in it drew lines of its own (25 Sep 2026).
|
|
@@ -104,9 +105,10 @@ async function api(path, { method = 'GET', body, token = storedToken() } = {}) {
|
|
|
104
105
|
// The time ran out (fetchHere's deadline; AbortError where an older Node says so): one line, whichever part was late.
|
|
105
106
|
// A Wall that answered a byte at a time held `login` and `wire` for ever (round 8, 25 Sep 2026).
|
|
106
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);
|
|
107
|
-
// 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).
|
|
108
109
|
const res = await fetchHere(`${wall}${path}`, {
|
|
109
110
|
ms: ANSWER_TIME,
|
|
111
|
+
wall,
|
|
110
112
|
method,
|
|
111
113
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
112
114
|
body: body ? JSON.stringify(body) : undefined,
|
|
@@ -533,6 +535,8 @@ const TOOL_KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/g;
|
|
|
533
535
|
const LOCAL = /^(https?|wss?):\/\/(localhost|127\.0\.0\.1|\[::1\]|[^/]*\.localhost)(:\d+)?\//i;
|
|
534
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. */
|
|
535
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'];
|
|
536
540
|
|
|
537
541
|
/**
|
|
538
542
|
* The files a manifest names, each with what it is to the extension, as the zip spells them: no leading ./ or /, no
|
|
@@ -621,15 +625,17 @@ function zipOf(entries) {
|
|
|
621
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).
|
|
622
626
|
*/
|
|
623
627
|
function filesIn(root, copyOf = () => null, fromLink = () => null) {
|
|
624
|
-
const found = []; const left = []; const privately = [];
|
|
628
|
+
const found = []; const left = []; const privately = []; const skipped = [];
|
|
625
629
|
const allowed = flag('--allow-private');
|
|
626
630
|
const judge = (name, bytes) => privateKind(name) ?? copyOf(bytes) ?? privateContent(bytes) ?? gitObject(bytes) ?? gitFile(bytes) ?? notAnImage(name, bytes);
|
|
627
631
|
const judged = new Map(); // what each file found private is, by its path: a compressed one beside it is the same
|
|
628
632
|
const walk = (dir) => {
|
|
629
633
|
for (const name of readdirSync(dir).sort()) {
|
|
630
|
-
if (name.startsWith('.') || name === 'node_modules' || name.endsWith('.zip')) continue;
|
|
631
634
|
const p = join(dir, name);
|
|
632
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; }
|
|
633
639
|
const st = lstatSync(p);
|
|
634
640
|
if (st.isSymbolicLink()) { left.push([rel, 'a link']); continue; }
|
|
635
641
|
if (/[\\\u0000-\u001f\u007f]/.test(name)) { left.push([rel, 'a name no zip should hold']); continue; }
|
|
@@ -659,7 +665,7 @@ function filesIn(root, copyOf = () => null, fromLink = () => null) {
|
|
|
659
665
|
}
|
|
660
666
|
};
|
|
661
667
|
walk(root);
|
|
662
|
-
return { found, left, privately };
|
|
668
|
+
return { found, left, privately, skipped };
|
|
663
669
|
}
|
|
664
670
|
|
|
665
671
|
/** A path as the developer knows it: under their home folder, from ~. */
|
|
@@ -691,7 +697,7 @@ function pack() {
|
|
|
691
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.)`);
|
|
692
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.)`);
|
|
693
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; };
|
|
694
|
-
const { found: files, left, privately } = filesIn(folder, copyOf, fromLink);
|
|
700
|
+
const { found: files, left, privately, skipped } = filesIn(folder, copyOf, fromLink);
|
|
695
701
|
// What was left out as no file of the folder's own, and what as private.
|
|
696
702
|
const links = left.filter(([, why]) => NOT_OWN.includes(why));
|
|
697
703
|
const kept = left.filter((entry) => !links.includes(entry));
|
|
@@ -700,14 +706,17 @@ function pack() {
|
|
|
700
706
|
// an icon: refused, and nothing written (round 9, 25 Sep 2026). --allow-private packs a private one; a link, never.
|
|
701
707
|
const broken = [];
|
|
702
708
|
for (const [path, what] of namedFiles(manifest)) {
|
|
703
|
-
const
|
|
709
|
+
const named = ([rel]) => path.toLowerCase() === rel.toLowerCase() || path.toLowerCase().startsWith(`${rel.toLowerCase()}/`);
|
|
710
|
+
const gone = left.find(named) ?? skipped.find(named);
|
|
704
711
|
if (gone && !broken.some(([p]) => p === path)) broken.push([path, what, gone[1]]);
|
|
705
712
|
}
|
|
706
713
|
if (broken.length) {
|
|
707
|
-
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;
|
|
708
716
|
const advice = [
|
|
709
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.`,
|
|
710
|
-
|
|
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.`,
|
|
711
720
|
].filter(Boolean);
|
|
712
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 ')}`);
|
|
713
722
|
}
|
|
@@ -732,7 +741,7 @@ function pack() {
|
|
|
732
741
|
// The Wall the key names must be reachable from the extension, or sign-in and the purchase handoff fail.
|
|
733
742
|
for (const site of sites) {
|
|
734
743
|
const hosts = manifest.host_permissions ?? []; const ec = manifest.externally_connectable?.matches ?? [];
|
|
735
|
-
if (!hosts.includes(
|
|
744
|
+
if (!hosts.includes(sitePattern(site)) || !ec.includes(sitePattern(site))) problems.push(`The manifest does not name ${oneLine(sitePattern(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.`);
|
|
736
745
|
}
|
|
737
746
|
if (problems.length) fail(`Not packed:\n\n${problems.map((p) => ` · ${p}`).join('\n\n')}`);
|
|
738
747
|
|
|
@@ -809,9 +818,9 @@ function help() {
|
|
|
809
818
|
webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
|
|
810
819
|
login · whoami · logout the session, per side; a command that needs one signs you in itself
|
|
811
820
|
|
|
812
|
-
Every command acts on Test (
|
|
821
|
+
Every command acts on Test (toolaby.app/test) unless told --live (toolaby.app).
|
|
813
822
|
--wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI —
|
|
814
|
-
sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://
|
|
823
|
+
sent to Live alone, or to the one Wall TOOLABY_TOKEN_WALL names (https://toolaby.app/test for Test).
|
|
815
824
|
|
|
816
825
|
In a repository you did not write, install it (npm i -g toolaby) and type toolaby-cli: npx can run a toolaby
|
|
817
826
|
the project carries, or fetch one from a registry its .npmrc names.
|
package/lib/check.js
CHANGED
|
@@ -8,7 +8,7 @@ import { join, relative, resolve } from 'node:path';
|
|
|
8
8
|
import { createHash } from 'node:crypto';
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import { oneLine, keySite, fetchBounded, readText } from './safe.js';
|
|
11
|
-
import { wrappedBy, namedWorker, startsPlatform as startsWall } from './manifest.js';
|
|
11
|
+
import { wrappedBy, namedWorker, startsPlatform as startsWall, sitePattern } from './manifest.js';
|
|
12
12
|
import { popupSwapIn, ownCode } from './summary.js';
|
|
13
13
|
|
|
14
14
|
const KEY = /tk_(test|live|dev)_[A-Za-z0-9_-]{20,}/;
|
|
@@ -118,7 +118,7 @@ export async function runCheck({ dir: given, out, api, token, detectProject, wir
|
|
|
118
118
|
else ok('The manifest has the permissions and host permissions the Wall needs.');
|
|
119
119
|
// The identity: without the `key`, each copy's id follows its folder, and once the extension is named the tool key refuses every copy not chosen.
|
|
120
120
|
if (typeof manifest.key !== 'string' && /manifestKey:\s*'[A-Za-z0-9+/]{100,}={0,2}'/.test(configText)) warn(`manifest.json has no "key": each copy's id then depends on its folder, and once your extension is named the tool key refuses any copy you did not choose on the Keys page. \`npx -y toolaby@latest upgrade ${key.slug}\` adds it.`);
|
|
121
|
-
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${key.site}
|
|
121
|
+
} else warn(`The manifest is generated by ${project.label}: build, then check the built manifest has ${sitePattern(key.site)} in host_permissions and externally_connectable.`);
|
|
122
122
|
|
|
123
123
|
// 4b. The background starts the Wall: the popup and every page ask it everything, so without it nothing answers.
|
|
124
124
|
const upgrade = `\`npx -y toolaby@latest upgrade ${key.slug}\``;
|
package/lib/manifest.js
CHANGED
|
@@ -69,6 +69,19 @@ export const loadableName = (file) => {
|
|
|
69
69
|
};
|
|
70
70
|
/** The background the manifest names, as the folder spells it: "./background.js" is background.js. */
|
|
71
71
|
export const namedWorker = (manifest) => typeof manifest?.background?.service_worker === 'string' ? manifest.background.service_worker.trim().replace(/^(\.\/)+/, '') || null : null;
|
|
72
|
+
/**
|
|
73
|
+
* The match pattern a manifest names the Wall by: the origin of the key's site, whatever path follows it. Test shares
|
|
74
|
+
* Live's hosts under /test (`https://northwind.toolaby.app/test`), so one pattern — `https://northwind.toolaby.app/*` —
|
|
75
|
+
* lets the same build reach either side, and a Live wiring's manifest is the one it always was.
|
|
76
|
+
*/
|
|
77
|
+
export function sitePattern(site) {
|
|
78
|
+
try {
|
|
79
|
+
return `${new URL(site).origin}/*`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return `${site.replace(/\/+$/, '')}/*`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
72
85
|
/**
|
|
73
86
|
* Whether an earlier wiring left the extension's own pages drawing the panel: a configuration that says '' for both
|
|
74
87
|
* pages while the manifest names none of the Wall's. That is what `create` makes, and the "your popup first" shape
|
|
@@ -110,16 +123,17 @@ export function wireManifest(input, o) {
|
|
|
110
123
|
did.push('the "storage" permission');
|
|
111
124
|
added.push('storage');
|
|
112
125
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
126
|
+
const pattern = sitePattern(o.site);
|
|
127
|
+
if (!(Array.isArray(manifest.host_permissions) && manifest.host_permissions.includes(pattern))) {
|
|
128
|
+
manifest.host_permissions = add(manifest.host_permissions, pattern);
|
|
129
|
+
did.push(`a host permission for ${host}`);
|
|
116
130
|
if (!added.includes(host))
|
|
117
131
|
added.push(host);
|
|
118
132
|
}
|
|
119
133
|
const ec = manifest.externally_connectable ?? {};
|
|
120
|
-
if (!(Array.isArray(ec.matches) && ec.matches.includes(
|
|
121
|
-
manifest.externally_connectable = { ...ec, matches: add(ec.matches,
|
|
122
|
-
did.push(`externally_connectable for ${
|
|
134
|
+
if (!(Array.isArray(ec.matches) && ec.matches.includes(pattern))) {
|
|
135
|
+
manifest.externally_connectable = { ...ec, matches: add(ec.matches, pattern) };
|
|
136
|
+
did.push(`externally_connectable for ${host}`);
|
|
123
137
|
if (!added.includes(host))
|
|
124
138
|
added.push(host);
|
|
125
139
|
}
|
package/lib/project.js
CHANGED
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
// Generated from wall/src/packages/wiring/project.ts by wall/scripts/pack-create.mjs — edit there.
|
|
2
|
+
/**
|
|
3
|
+
* What kind of extension project a folder is, and where the platform's
|
|
4
|
+
* files go in it — the same files for every kind. A plain extension has
|
|
5
|
+
* its manifest.json at the root and everything beside it. A framework
|
|
6
|
+
* (WXT, Plasmo, CRXJS) generates the manifest at build time, bundles the
|
|
7
|
+
* background, and copies a folder of files as they are: the client goes
|
|
8
|
+
* where the build can import it, the Wall's popup — and its side panel,
|
|
9
|
+
* where the extension has one — where the build copies verbatim. What the manifest and the background need is the framework's
|
|
10
|
+
* own extension point where it has one — WXT loads a module from modules/
|
|
11
|
+
* by itself, so the wiring writes one and nothing is pasted — and, where it
|
|
12
|
+
* has none (Plasmo, CRXJS), a snippet for its config: shown, never written
|
|
13
|
+
* into someone's TypeScript. Pure:
|
|
14
|
+
* given what is in the folder, it says what to write and what to say.
|
|
15
|
+
* Shared by the browser (rb/WireExtension) and the terminal
|
|
16
|
+
* (`toolaby wire`, which carries a transpiled copy).
|
|
17
|
+
*/
|
|
18
|
+
import { sitePattern } from './manifest.js';
|
|
2
19
|
/** What to tell a developer whose build ships the extension — the Wire button, the command and the Set up page all say it in these words. */
|
|
3
20
|
export const BUILD_COPY_NOTE = 'A build ships this extension: make sure it copies every toolaby* file to its output — toolaby.js, toolaby.config.js, toolaby-popup.html and the rest — the way it copies your popup.html.';
|
|
4
21
|
/** Always written, where the build imports from: the platform as one file (the client and the popup UI), its config, its types. */
|
|
@@ -109,7 +126,7 @@ export function detectProject(o) {
|
|
|
109
126
|
const worker = first(has, [`${src}background.ts`, `${src}background.js`, `${src}background/index.ts`, `${src}background/index.js`]);
|
|
110
127
|
const workerFile = worker ?? `${src}background.ts`;
|
|
111
128
|
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
112
|
-
const code = JSON.stringify({ manifest: { ...(o.manifestKey ? { key: o.manifestKey } : {}), permissions: ['storage'], host_permissions: [
|
|
129
|
+
const code = JSON.stringify({ manifest: { ...(o.manifestKey ? { key: o.manifestKey } : {}), permissions: ['storage'], host_permissions: [sitePattern(site)], externally_connectable: { matches: [sitePattern(site)] } } }, null, 2).replace(/^\{\n|\n\}$/g, '');
|
|
113
130
|
return {
|
|
114
131
|
kind: 'plasmo', label: 'Plasmo', codeDir: '', popupDir: '', manifestFile: null, wrapper: false, appPopup: null, appSidePanel: null, worker, built: false, foundManifest: null,
|
|
115
132
|
createWorker: worker ? null : { file: workerFile, content: workerLines },
|
package/lib/safe.js
CHANGED
|
@@ -60,15 +60,17 @@ export const oneLine = (value) => String(value).replace(/\s+/g, ' ').slice(0, 50
|
|
|
60
60
|
const wallScheme = (url) => url.protocol === 'https:' || (url.protocol === 'http:' && LOOPBACK.test(url.hostname));
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* The Wall a tool key names, when `check` may ask it anything: an origin, https or on this machine,
|
|
64
|
-
*
|
|
63
|
+
* The Wall a tool key names, when `check` may ask it anything: an origin, https or on this machine, with nothing after
|
|
64
|
+
* it but Test's `/test`, where Test shares Live's hosts — no other path, no query, no credentials, no other scheme.
|
|
65
|
+
* Null otherwise. The key is read from the project's own toolaby.config.js, and a
|
|
65
66
|
* cloned project's key sent `check`'s requests wherever it named: a server inside the developer's network, any path on
|
|
66
67
|
* any host (25 Sep 2026).
|
|
67
68
|
*/
|
|
68
69
|
export function keySite(site) {
|
|
69
70
|
let url;
|
|
70
71
|
try { url = new URL(site); } catch { return null; }
|
|
71
|
-
|
|
72
|
+
const path = url.pathname === '/' ? '' : url.pathname;
|
|
73
|
+
return (path === '' || path === '/test') && `${url.origin}${path}` === site && wallScheme(url) ? site : null;
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
/**
|
|
@@ -115,23 +117,41 @@ export async function readBounded(res, limit) {
|
|
|
115
117
|
return Buffer.concat(chunks);
|
|
116
118
|
}
|
|
117
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
|
+
|
|
118
137
|
/**
|
|
119
138
|
* A request that follows a redirect only within the address it asked — a Wall moving a page on its own host — and
|
|
120
139
|
* refuses one elsewhere: fetch drops the session on the way, but the command took whatever the other address answered
|
|
121
|
-
* 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.
|
|
122
142
|
*
|
|
123
143
|
* And one that gives up at its time: the answer, redirects and body alike — read or not yet — within `ms` of asking, or
|
|
124
144
|
* a TimeoutError, as fetchBounded's. Bounded in size alone, a Wall that answered a byte at a time held `login`, `wire`
|
|
125
145
|
* and the MCP server for ever (round 8, 25 Sep 2026).
|
|
126
146
|
*/
|
|
127
|
-
export async function fetchHere(url, { ms = 30_000, ...init } = {}) {
|
|
147
|
+
export async function fetchHere(url, { ms = 30_000, wall = null, ...init } = {}) {
|
|
128
148
|
const signal = AbortSignal.timeout(ms);
|
|
129
|
-
const
|
|
149
|
+
const home = wall ?? new URL(url).origin;
|
|
130
150
|
let res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
131
151
|
for (let hops = 0; hops < 5 && res.status >= 300 && res.status < 400 && res.headers.get('location'); hops++) {
|
|
132
152
|
const next = new URL(res.headers.get('location'), url);
|
|
133
153
|
await res.body?.cancel().catch(() => {});
|
|
134
|
-
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`);
|
|
135
155
|
url = next.href;
|
|
136
156
|
res = await fetch(url, { ...init, redirect: 'manual', signal });
|
|
137
157
|
}
|
|
@@ -673,13 +693,18 @@ function publicLinkEntries(root, packed = null) {
|
|
|
673
693
|
/**
|
|
674
694
|
* The page `login` opens: the one the Wall gave, when it is the Wall's own over https — or, for a Wall on this
|
|
675
695
|
* machine, a page on this machine. Anything else is null, and is not opened: an address elsewhere, a downgrade to
|
|
676
|
-
* http, or a scheme such as file: that opens whatever it names.
|
|
696
|
+
* http, or a scheme such as file: that opens whatever it names. A Wall under a path gives a page under it: Test at
|
|
697
|
+
* toolaby.app/test approves its codes at toolaby.app/test/…, and Live's page of the same name knows none of them (a
|
|
698
|
+
* root path resolved against Test's own base loses /test: 29 Sep 2026).
|
|
677
699
|
*/
|
|
678
700
|
export function signInPage(link, wall) {
|
|
679
701
|
if (typeof link !== 'string') return null;
|
|
680
702
|
let home; let page;
|
|
681
703
|
try { home = new URL(wall); page = new URL(link, home); } catch { return null; }
|
|
682
704
|
if (page.username || page.password) return null;
|
|
705
|
+
const base = home.pathname.replace(/\/+$/, '');
|
|
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;
|
|
683
708
|
if (page.protocol === 'https:' && page.origin === home.origin) return page.href;
|
|
684
709
|
const web = page.protocol === 'http:' || page.protocol === 'https:';
|
|
685
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.
|
|
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": {
|