toolaby 1.0.0
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 +31 -0
- package/bin.mjs +248 -0
- package/lib/manifest.js +52 -0
- package/lib/project.js +116 -0
- package/package.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# toolaby
|
|
2
|
+
|
|
3
|
+
The Toolaby command line. It puts the Wall — sign-in, a Free plan, paid plans, licences and subscriptions — into a Chrome extension you already have, or makes a new one for you.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npx toolaby login # once: a code to approve in your browser
|
|
7
|
+
npx toolaby wire wow--screenzard # in your extension's folder
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## What it does
|
|
11
|
+
|
|
12
|
+
**`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there, and the command is signed in as you from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on your own tools.
|
|
13
|
+
|
|
14
|
+
**`toolaby wire <tool-id>`**, run in your extension's folder, wires that extension to your tool:
|
|
15
|
+
|
|
16
|
+
- it adds the Wall's files — a small client library, a config with your tool's id and public key, and the Wall's popup, which stands in front of yours;
|
|
17
|
+
- it adds three lines to your `manifest.json` (a permission and your Wall address) and creates a background file if you have none;
|
|
18
|
+
- your own code does not change. When your popup opens, the Wall's page checks what the Free plan holds and either sends the person straight to your popup or shows sign-in, the free-use meter or the plans.
|
|
19
|
+
|
|
20
|
+
If the extension is built with WXT, CRXJS or Plasmo, the command puts the same files where that build expects them and prints the few lines to paste into the framework's config, because the framework generates the manifest itself. Nothing is installed from npm.
|
|
21
|
+
|
|
22
|
+
**`toolaby create <tool-id>`** makes a new, minimal extension in a folder named after the tool, ready for *Load unpacked*.
|
|
23
|
+
|
|
24
|
+
**`toolaby tools`** lists your tools and their ids. **`toolaby whoami`** says who you are signed in as. **`toolaby logout`** signs the command out and revokes its session on the server.
|
|
25
|
+
|
|
26
|
+
## Where things live
|
|
27
|
+
|
|
28
|
+
- Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
|
|
29
|
+
- `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI). `TOOLABY_WALL` or `--wall <url>` names another Wall (the default is `https://toolaby.app`).
|
|
30
|
+
|
|
31
|
+
The files the command writes into your extension contain nothing secret: your tool's id, your Wall's address and the *public* key your extension verifies licences with — the same things every copy of your extension carries once it is published.
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Toolaby command line.
|
|
3
|
+
//
|
|
4
|
+
// toolaby login sign in: a code to approve in the browser (Better Auth's device flow)
|
|
5
|
+
// toolaby wire <tool-id> wire the extension in this folder to your tool — plain, WXT, CRXJS or Plasmo
|
|
6
|
+
// toolaby create <tool-id> make a new, minimal extension for your tool in <tool-id>/
|
|
7
|
+
// toolaby tools your tools, with the ids the commands want
|
|
8
|
+
// toolaby whoami · logout · help
|
|
9
|
+
//
|
|
10
|
+
// Signed in, the command acts as you and only on your tools, as Clerk's and
|
|
11
|
+
// Stripe's do: nothing is fetched for a tool your workspaces do not own. The
|
|
12
|
+
// session lives in ~/.config/toolaby/credentials.json (mode 600), one per
|
|
13
|
+
// Wall; `logout` revokes it on the server before forgetting it. TOOLABY_TOKEN
|
|
14
|
+
// stands in for the file where there is no home (CI); TOOLABY_WALL for the
|
|
15
|
+
// Wall's address (https://toolaby.app unless told).
|
|
16
|
+
//
|
|
17
|
+
// The wiring rules — what a folder is and what its manifest needs — are the
|
|
18
|
+
// platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
|
|
19
|
+
// so this and the dashboard's Wire button never disagree.
|
|
20
|
+
import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSync, chmodSync, unlinkSync } from 'node:fs';
|
|
21
|
+
import { join, resolve, dirname } from 'node:path';
|
|
22
|
+
import { homedir } from 'node:os';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { wireManifest, WORKER_LINES } from './lib/manifest.js';
|
|
25
|
+
import { detectProject, CODE_FILES, filesToWrite } from './lib/project.js';
|
|
26
|
+
|
|
27
|
+
const VERSION = '1.0.0';
|
|
28
|
+
const CLIENT_ID = 'toolaby-cli';
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const command = argv.find((a) => !a.startsWith('--')) ?? 'help';
|
|
31
|
+
const rest = argv.filter((a) => a !== command);
|
|
32
|
+
const positional = rest.filter((a) => !a.startsWith('--'));
|
|
33
|
+
const opt = (k, d = null) => { const i = rest.indexOf(k); return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : d; };
|
|
34
|
+
const flag = (k) => rest.includes(k);
|
|
35
|
+
const wall = (opt('--wall') ?? process.env.TOOLABY_WALL ?? 'https://toolaby.app').replace(/\/$/, '');
|
|
36
|
+
const out = (s = '') => console.log(s);
|
|
37
|
+
const fail = (s) => { console.error(`\n ${s}\n`); process.exit(1); };
|
|
38
|
+
|
|
39
|
+
// ── The session: one per Wall, in the user's config, readable by them alone ─────────────────────────────────────────
|
|
40
|
+
const CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'toolaby');
|
|
41
|
+
const CREDENTIALS = join(CONFIG_DIR, 'credentials.json');
|
|
42
|
+
const readCredentials = () => { try { return JSON.parse(readFileSync(CREDENTIALS, 'utf8')); } catch { return {}; } };
|
|
43
|
+
function writeCredentials(all) {
|
|
44
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileSync(CREDENTIALS, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
46
|
+
try { chmodSync(CREDENTIALS, 0o600); } catch { /* not every filesystem has modes */ }
|
|
47
|
+
}
|
|
48
|
+
const storedToken = () => process.env.TOOLABY_TOKEN || readCredentials()[wall]?.token || null;
|
|
49
|
+
|
|
50
|
+
async function api(path, { method = 'GET', body, token = storedToken(), raw = false } = {}) {
|
|
51
|
+
const res = await fetch(`${wall}${path}`, {
|
|
52
|
+
method,
|
|
53
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': `toolaby-cli/${VERSION}`, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
54
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
55
|
+
}).catch((e) => { throw new Error(`Could not reach ${wall}: ${e.message}`); });
|
|
56
|
+
if (raw) return res;
|
|
57
|
+
const json = await res.json().catch(() => ({}));
|
|
58
|
+
return { status: res.status, ok: res.ok, json };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── login: RFC 8628, the way GitHub's and Stripe's command lines sign in ─────────────────────────────────────────────
|
|
62
|
+
function openBrowser(url) {
|
|
63
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
64
|
+
try { spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref(); } catch { /* the URL is printed anyway */ }
|
|
65
|
+
}
|
|
66
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
67
|
+
|
|
68
|
+
async function login() {
|
|
69
|
+
const start = await api('/api/dev-auth/device/code', { method: 'POST', body: { client_id: CLIENT_ID }, token: null });
|
|
70
|
+
if (!start.ok || !start.json.device_code) fail(`The Wall at ${wall} did not start a sign-in (HTTP ${start.status}). Is the address right? --wall names another.`);
|
|
71
|
+
const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
|
|
72
|
+
const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
|
|
73
|
+
const page = link.startsWith('http') ? link : `${wall}${link}`;
|
|
74
|
+
out(`\n Sign in to the Wall to continue.\n\n Open ${page}\n Code ${user_code} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
|
|
75
|
+
openBrowser(page);
|
|
76
|
+
let every = Math.max(1, Number(interval) || 5) * 1000;
|
|
77
|
+
const until = Date.now() + Number(expires_in) * 1000;
|
|
78
|
+
process.stdout.write(' Waiting for your approval');
|
|
79
|
+
while (Date.now() < until) {
|
|
80
|
+
await sleep(every);
|
|
81
|
+
process.stdout.write('.');
|
|
82
|
+
const poll = await api('/api/dev-auth/device/token', { method: 'POST', body: { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code, client_id: CLIENT_ID }, token: null });
|
|
83
|
+
if (poll.ok && poll.json.access_token) {
|
|
84
|
+
const token = poll.json.access_token;
|
|
85
|
+
const me = await api('/api/cli/me', { token });
|
|
86
|
+
const email = me.ok ? me.json.email : null;
|
|
87
|
+
const all = readCredentials();
|
|
88
|
+
all[wall] = { token, email, at: new Date().toISOString() };
|
|
89
|
+
writeCredentials(all);
|
|
90
|
+
out(`\n\n Signed in${email ? ` as ${email}` : ''}. The session is in ${CREDENTIALS}; \`toolaby logout\` revokes it.\n`);
|
|
91
|
+
return token;
|
|
92
|
+
}
|
|
93
|
+
const why = poll.json.error;
|
|
94
|
+
if (why === 'slow_down') every += 5000;
|
|
95
|
+
else if (why === 'expired_token') fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
96
|
+
else if (why === 'access_denied') fail('The sign-in was denied in the browser. Nothing was signed in.');
|
|
97
|
+
else if (why && why !== 'authorization_pending') fail(`The Wall answered "${why}"${poll.json.error_description ? `: ${poll.json.error_description}` : ''}.`);
|
|
98
|
+
}
|
|
99
|
+
fail('The code expired before it was approved. Run `toolaby login` again.');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A token to act with: the stored one, else a sign-in right here — `wire` in a fresh terminal is one command, not two. */
|
|
103
|
+
async function tokenOrLogin() {
|
|
104
|
+
return storedToken() ?? (await login());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function whoami() {
|
|
108
|
+
const token = storedToken();
|
|
109
|
+
if (!token) fail(`Not signed in to ${wall}. Run \`toolaby login\`.`);
|
|
110
|
+
const me = await api('/api/cli/me', { token });
|
|
111
|
+
if (me.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
|
|
112
|
+
if (!me.ok) fail(`The Wall answered HTTP ${me.status}.`);
|
|
113
|
+
out(`\n ${me.json.email} · ${wall}\n`);
|
|
114
|
+
for (const w of me.json.workspaces) out(` ${w.name} (${w.slug}, ${w.role}) ${w.site}`);
|
|
115
|
+
out();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function logout() {
|
|
119
|
+
const all = readCredentials();
|
|
120
|
+
const entry = all[wall];
|
|
121
|
+
if (!entry && !process.env.TOOLABY_TOKEN) { out(`\n Not signed in to ${wall}.\n`); return; }
|
|
122
|
+
// Revoked on the server first; forgotten here whatever the server said — but the difference is reported.
|
|
123
|
+
const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ?? process.env.TOOLABY_TOKEN }).catch(() => ({ ok: false, status: 0 }));
|
|
124
|
+
delete all[wall];
|
|
125
|
+
writeCredentials(all);
|
|
126
|
+
out(res.ok ? `\n Signed out of ${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`);
|
|
127
|
+
if (process.env.TOOLABY_TOKEN) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function tools() {
|
|
131
|
+
const token = await tokenOrLogin();
|
|
132
|
+
const res = await api('/api/cli/tools', { token });
|
|
133
|
+
if (res.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
|
|
134
|
+
if (!res.ok) fail(`The Wall answered HTTP ${res.status}.`);
|
|
135
|
+
if (!res.json.tools.length) { out('\n No tools yet. Make one in the dashboard, then `toolaby wire <tool-id>` in your extension\'s folder.\n'); return; }
|
|
136
|
+
out();
|
|
137
|
+
for (const t of res.json.tools) out(` ${t.slug.padEnd(40)} ${t.name}${t.bundle ? ' (bundle)' : ''} ${t.site}`);
|
|
138
|
+
out();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── wire and create ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
const SKIP = new Set(['node_modules', '.git', '.output', 'dist', 'build', '.wxt', '.plasmo', '.next']);
|
|
143
|
+
function listTree(dir, depth, prefix = '', acc = new Set()) {
|
|
144
|
+
for (const name of readdirSync(dir)) {
|
|
145
|
+
const path = `${prefix}${name}`;
|
|
146
|
+
acc.add(path);
|
|
147
|
+
let isDir = false;
|
|
148
|
+
try { isDir = statSync(join(dir, name)).isDirectory(); } catch { /* gone */ }
|
|
149
|
+
if (isDir && depth > 0 && !SKIP.has(name)) listTree(join(dir, name), depth - 1, `${path}/`, acc);
|
|
150
|
+
}
|
|
151
|
+
return acc;
|
|
152
|
+
}
|
|
153
|
+
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
154
|
+
const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
|
|
155
|
+
|
|
156
|
+
/** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
|
|
157
|
+
async function filesFor(slug, scaffold) {
|
|
158
|
+
const token = await tokenOrLogin();
|
|
159
|
+
const res = await api(`/api/cli/wiring/${slug}${scaffold ? '?scaffold=1' : ''}`, { token });
|
|
160
|
+
if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold); }
|
|
161
|
+
if (res.status === 404) fail(res.json.message ?? `No tool "${slug}" in your workspaces.`);
|
|
162
|
+
if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
|
|
163
|
+
return res.json;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function wire() {
|
|
167
|
+
const slug = positional[0];
|
|
168
|
+
if (!isId(slug)) fail('usage: toolaby wire <tool-id> [--into <folder>] [--force] — `toolaby tools` lists your ids');
|
|
169
|
+
const dir = resolve(opt('--into') ?? '.');
|
|
170
|
+
if (!existsSync(dir)) fail(`${dir} does not exist`);
|
|
171
|
+
const tree = listTree(dir, 3);
|
|
172
|
+
const has = (p) => tree.has(p.replace(/\/$/, ''));
|
|
173
|
+
const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
|
|
174
|
+
if (has('manifest.json') && !manifest) fail('manifest.json could not be read as JSON.');
|
|
175
|
+
const plan = await filesFor(slug, false);
|
|
176
|
+
const site = plan.site;
|
|
177
|
+
const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
|
|
178
|
+
const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readFileSync(join(dir, configSource), 'utf8') : null, site });
|
|
179
|
+
if (project.kind === 'unknown') fail(`${dir} has no manifest.json and is not a WXT, CRXJS or Plasmo project — run this inside your extension's folder, or point --into at it.`);
|
|
180
|
+
|
|
181
|
+
// The extension's own popup: the manifest's, or — wired before — the one the existing configuration remembers.
|
|
182
|
+
const configPath = join(dir, `${project.codeDir}toolaby.config.js`);
|
|
183
|
+
const remembered = existsSync(configPath) ? readFileSync(configPath, 'utf8').match(/appPopup:\s*'([^']*)'/)?.[1] || null : null;
|
|
184
|
+
const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered }) : null;
|
|
185
|
+
const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
|
|
186
|
+
const targets = filesToWrite(project, appPopup);
|
|
187
|
+
const present = targets.filter((f) => existsSync(join(dir, f)));
|
|
188
|
+
if (present.length && !flag('--force')) fail(`${present.join(', ')} already in ${dir}; add --force to replace them.`);
|
|
189
|
+
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
190
|
+
for (const path of targets) write(path, plan.files[path.split('/').pop()].replaceAll('__APP_POPUP__', appPopup ?? ''));
|
|
191
|
+
const did = [];
|
|
192
|
+
let ownWorker = null;
|
|
193
|
+
if (wiring) {
|
|
194
|
+
if (wiring.writeWorker) write(wiring.writeWorker, WORKER_LINES);
|
|
195
|
+
if (wiring.did.length) write('manifest.json', JSON.stringify(wiring.manifest, null, 2) + '\n');
|
|
196
|
+
did.push(...wiring.did);
|
|
197
|
+
if (wiring.ownWorker) ownWorker = wiring.ownWorker;
|
|
198
|
+
} else if (project.createWorker) {
|
|
199
|
+
write(project.createWorker.file, project.createWorker.content);
|
|
200
|
+
did.push(`a background entry, ${project.createWorker.file} (it starts the platform)`);
|
|
201
|
+
} else if (project.worker) {
|
|
202
|
+
ownWorker = { file: project.worker, classic: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`}\n`);
|
|
206
|
+
out(` Written: ${targets.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
|
|
207
|
+
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`);
|
|
208
|
+
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`);
|
|
209
|
+
out(` Next: ${project.kind === 'plain' ? '' : 'build or start the dev server, then '}chrome://extensions → reload it. The tool's Set up page says "checked in" the moment it starts.`);
|
|
210
|
+
if (project.note) out(` ${project.note}\n`);
|
|
211
|
+
else if (appPopup) out(` Your popup opens as before; what the Free plan holds (Pricing, in the dashboard) decides when the Wall's panel takes its place.\n`);
|
|
212
|
+
else out(` No popup to stand in front of: count a use where your feature runs — the Set up page shows the line.\n`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function create() {
|
|
216
|
+
const slug = positional[0];
|
|
217
|
+
if (!isId(slug)) fail('usage: toolaby create <tool-id> [--dir <folder>] — `toolaby tools` lists your ids');
|
|
218
|
+
const dir = resolve(opt('--dir') ?? slug);
|
|
219
|
+
if (existsSync(dir)) fail(`${dir} already exists`);
|
|
220
|
+
const plan = await filesFor(slug, true);
|
|
221
|
+
const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
|
|
222
|
+
for (const f of ['manifest.json', ...CODE_FILES, 'background.js', 'popup.html', 'popup.css', 'popup.js', 'README.md']) write(f, plan.files[f].replaceAll('__APP_POPUP__', ''));
|
|
223
|
+
for (const s of [16, 48, 128]) {
|
|
224
|
+
const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
|
|
225
|
+
write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
|
|
226
|
+
}
|
|
227
|
+
out(`\n ${plan.name} → ${dir}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. Click the icon: the popup shows what the Free plan holds; Buy opens ${plan.site}/tools/${slug}\n\n Edit background.js: your work goes behind toolaby.gate().\n`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function help() {
|
|
231
|
+
out(`
|
|
232
|
+
toolaby — the Toolaby command line (${VERSION})
|
|
233
|
+
|
|
234
|
+
toolaby login sign in: a code to approve in the browser
|
|
235
|
+
toolaby wire <tool-id> wire the extension in this folder to your tool
|
|
236
|
+
--into <folder> another folder --force replace the platform's files
|
|
237
|
+
toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
|
|
238
|
+
toolaby tools your tools, with their ids
|
|
239
|
+
toolaby whoami · logout
|
|
240
|
+
|
|
241
|
+
--wall <url> the Wall to talk to (https://toolaby.app; TOOLABY_WALL) TOOLABY_TOKEN a session, for CI
|
|
242
|
+
`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, create, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
|
|
246
|
+
const run = commands[command];
|
|
247
|
+
if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
|
|
248
|
+
run().catch((e) => fail(e.message));
|
package/lib/manifest.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generated from wall/src/packages/wiring/manifest.ts by wall/scripts/pack-create.mjs — edit there.
|
|
2
|
+
/**
|
|
3
|
+
* What wiring an extension does to its manifest — the same in the browser
|
|
4
|
+
* (Wire my extension, from the tool's page) and in the terminal
|
|
5
|
+
* (`toolaby wire`, packages/toolaby-cli/bin.mjs):
|
|
6
|
+
* the storage permission; the platform's address as a host permission and
|
|
7
|
+
* for externally_connectable (the site's sign-in and purchase handoffs); a
|
|
8
|
+
* module service worker when the manifest names none, or names one that is
|
|
9
|
+
* not there; and the Wall's popup in front of the extension's own, which
|
|
10
|
+
* the configuration then remembers. Pure: given the manifest and what is
|
|
11
|
+
* on disk, it says what to write.
|
|
12
|
+
*/
|
|
13
|
+
export const WALL_POPUP = 'toolaby-popup.html';
|
|
14
|
+
export const WORKER_LINES = "import { toolaby } from './toolaby.js';\n\ntoolaby.startBackground();\n";
|
|
15
|
+
export function wireManifest(input, o) {
|
|
16
|
+
const manifest = JSON.parse(JSON.stringify(input));
|
|
17
|
+
const did = [];
|
|
18
|
+
const add = (list, value) => { const arr = Array.isArray(list) ? [...list] : []; if (!arr.includes(value))
|
|
19
|
+
arr.push(value); return arr; };
|
|
20
|
+
if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('storage'))) {
|
|
21
|
+
manifest.permissions = add(manifest.permissions, 'storage');
|
|
22
|
+
did.push('the "storage" permission');
|
|
23
|
+
}
|
|
24
|
+
if (!(Array.isArray(manifest.host_permissions) && manifest.host_permissions.includes(`${o.site}/*`))) {
|
|
25
|
+
manifest.host_permissions = add(manifest.host_permissions, `${o.site}/*`);
|
|
26
|
+
did.push(`a host permission for ${o.site}`);
|
|
27
|
+
}
|
|
28
|
+
const ec = manifest.externally_connectable ?? {};
|
|
29
|
+
if (!(Array.isArray(ec.matches) && ec.matches.includes(`${o.site}/*`))) {
|
|
30
|
+
manifest.externally_connectable = { ...ec, matches: add(ec.matches, `${o.site}/*`) };
|
|
31
|
+
did.push(`externally_connectable for ${o.site}`);
|
|
32
|
+
}
|
|
33
|
+
// The extension's own popup: the manifest's, or the one remembered when the manifest already names the Wall's.
|
|
34
|
+
const declared = typeof manifest.action?.default_popup === 'string' ? manifest.action.default_popup : null;
|
|
35
|
+
const appPopup = declared && declared !== WALL_POPUP ? declared : declared === WALL_POPUP ? (o.remembered || null) : null;
|
|
36
|
+
if (appPopup && declared !== WALL_POPUP) {
|
|
37
|
+
manifest.action = { ...manifest.action, default_popup: WALL_POPUP };
|
|
38
|
+
did.push(`the Wall's popup in front of ${appPopup}: it opens on to yours while the device may, and shows the panel when it may not`);
|
|
39
|
+
}
|
|
40
|
+
const named = typeof manifest.background?.service_worker === 'string' && manifest.background.service_worker.trim() ? manifest.background.service_worker.trim() : null;
|
|
41
|
+
let writeWorker = null;
|
|
42
|
+
let ownWorker = null;
|
|
43
|
+
if (!named || !o.has(named)) {
|
|
44
|
+
writeWorker = named ?? (o.has('background.js') ? 'toolaby.background.js' : 'background.js');
|
|
45
|
+
manifest.background = { service_worker: writeWorker, type: 'module' };
|
|
46
|
+
did.push(`a service worker, ${writeWorker} (two lines: it starts the platform)`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
ownWorker = { file: named, classic: manifest.background?.type !== 'module' };
|
|
50
|
+
}
|
|
51
|
+
return { manifest, did, writeWorker, ownWorker, appPopup };
|
|
52
|
+
}
|
package/lib/project.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Generated from wall/src/packages/wiring/project.ts by wall/scripts/pack-create.mjs — edit there.
|
|
2
|
+
/** Always written, where the build imports from: the client, its config and types, and the panel a popup of the developer's own can draw. */
|
|
3
|
+
export const CODE_FILES = ['toolaby.config.js', 'toolaby.js', 'toolaby-client.module.js', 'toolaby.d.ts', 'paywall.js', 'paywall.css'];
|
|
4
|
+
/** The Wall's popup, where the build copies files as they are; it imports the panel beside it, so the panel goes there too when that is elsewhere. */
|
|
5
|
+
export const POPUP_FILES = ['toolaby-popup.html', 'toolaby-popup.js', 'toolaby-popup.css'];
|
|
6
|
+
export const PANEL_FILES = ['paywall.js', 'paywall.css'];
|
|
7
|
+
/** Every file a wiring writes, with its path: the code files, and — with a popup to stand in front of — the Wall's popup and, beside it, the panel. */
|
|
8
|
+
export const filesToWrite = (p, appPopup) => [
|
|
9
|
+
...CODE_FILES.map((f) => `${p.codeDir}${f}`),
|
|
10
|
+
...(appPopup ? [...POPUP_FILES, ...(p.popupDir !== p.codeDir ? PANEL_FILES : [])].map((f) => `${p.popupDir}${f}`) : []),
|
|
11
|
+
];
|
|
12
|
+
/** The import of toolaby.js from a file at `from`, both relative to the root. */
|
|
13
|
+
export function importPath(from, codeDir = '') {
|
|
14
|
+
const depth = from.split('/').length - 1;
|
|
15
|
+
const up = depth === 0 ? './' : '../'.repeat(depth);
|
|
16
|
+
return `${up}${codeDir}toolaby.js`;
|
|
17
|
+
}
|
|
18
|
+
const dep = (pkg, name) => !!(pkg?.dependencies?.[name] || pkg?.devDependencies?.[name]);
|
|
19
|
+
const first = (has, candidates) => candidates.find(has) ?? null;
|
|
20
|
+
export function detectProject(o) {
|
|
21
|
+
const { has, packageJson: pkg, manifest, site } = o;
|
|
22
|
+
const wxt = dep(pkg, 'wxt') || !!first(has, ['wxt.config.ts', 'wxt.config.js', 'wxt.config.mts', 'wxt.config.mjs']);
|
|
23
|
+
const plasmo = dep(pkg, 'plasmo');
|
|
24
|
+
const crxjs = dep(pkg, '@crxjs/vite-plugin');
|
|
25
|
+
if (wxt) {
|
|
26
|
+
// srcDir: the root, or src/ when the entrypoints live there.
|
|
27
|
+
const src = !has('entrypoints') && has('src/entrypoints') ? 'src/' : '';
|
|
28
|
+
const worker = first(has, [`${src}entrypoints/background.ts`, `${src}entrypoints/background.js`, `${src}entrypoints/background/index.ts`, `${src}entrypoints/background/index.js`]);
|
|
29
|
+
const popup = first(has, [`${src}entrypoints/popup.html`, `${src}entrypoints/popup/index.html`]);
|
|
30
|
+
const appPopup = popup ? 'popup.html' : null;
|
|
31
|
+
const workerFile = worker ?? `${src}entrypoints/background.ts`;
|
|
32
|
+
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\nexport default defineBackground(() => {\n toolaby.startBackground();\n});\n`;
|
|
33
|
+
const code = [
|
|
34
|
+
`// wxt.config.ts — what the Wall needs in the manifest${appPopup ? ', and its popup in front of yours' : ''}`,
|
|
35
|
+
`export default defineConfig({`,
|
|
36
|
+
` manifest: {`,
|
|
37
|
+
` permissions: ['storage'],`,
|
|
38
|
+
` host_permissions: ['${site}/*'],`,
|
|
39
|
+
` externally_connectable: { matches: ['${site}/*'] },`,
|
|
40
|
+
` },`,
|
|
41
|
+
...(appPopup ? [
|
|
42
|
+
` hooks: {`,
|
|
43
|
+
` // WXT names your popup entrypoint after merging the manifest above; the hook runs after that.`,
|
|
44
|
+
` 'build:manifestGenerated': (_, manifest) => {`,
|
|
45
|
+
` manifest.action = { ...manifest.action, default_popup: 'toolaby-popup.html' };`,
|
|
46
|
+
` },`,
|
|
47
|
+
` },`,
|
|
48
|
+
] : []),
|
|
49
|
+
`});`,
|
|
50
|
+
].join('\n');
|
|
51
|
+
return {
|
|
52
|
+
kind: 'wxt', label: 'WXT', codeDir: '', popupDir: `${src}public/`, manifestFile: null, wrapper: true, appPopup, worker,
|
|
53
|
+
createWorker: worker ? null : { file: workerFile, content: workerLines },
|
|
54
|
+
workerLines, snippets: [{ file: first(has, ['wxt.config.ts', 'wxt.config.js', 'wxt.config.mts', 'wxt.config.mjs']) ?? 'wxt.config.ts', language: 'ts', code }],
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (plasmo) {
|
|
58
|
+
// Plasmo builds only the pages its own manifest names (popup.tsx → popup.html, from .plasmo/), and its manifest
|
|
59
|
+
// override replaces `action` whole: a page of ours in front of the popup would leave the popup unbuilt. So no
|
|
60
|
+
// wrapper here — the gate goes in the popup's code, one call, and the panel is a function away (paywall.js).
|
|
61
|
+
const src = has('src') && !first(has, ['popup.tsx', 'popup.html', 'background.ts', 'background.js']) ? 'src/' : '';
|
|
62
|
+
const worker = first(has, [`${src}background.ts`, `${src}background.js`, `${src}background/index.ts`, `${src}background/index.js`]);
|
|
63
|
+
const workerFile = worker ?? `${src}background.ts`;
|
|
64
|
+
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
65
|
+
const code = JSON.stringify({ manifest: { permissions: ['storage'], host_permissions: [`${site}/*`], externally_connectable: { matches: [`${site}/*`] } } }, null, 2).replace(/^\{\n|\n\}$/g, '');
|
|
66
|
+
return {
|
|
67
|
+
kind: 'plasmo', label: 'Plasmo', codeDir: '', popupDir: '', manifestFile: null, wrapper: false, appPopup: null, worker,
|
|
68
|
+
createWorker: worker ? null : { file: workerFile, content: workerLines },
|
|
69
|
+
workerLines, snippets: [{ file: 'package.json', language: 'json', code }],
|
|
70
|
+
note: 'Plasmo builds only the pages its manifest names, so the Wall\'s popup cannot stand in front of yours: ask the gate in your popup — the lines under Fine-tune.',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (crxjs && !has('manifest.json')) {
|
|
74
|
+
const configFile = first(has, ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'manifest.js', 'src/manifest.ts', 'src/manifest.js']);
|
|
75
|
+
const source = o.manifestSource ?? '';
|
|
76
|
+
const declared = source.match(/default_popup:\s*['"]([^'"]+)['"]/)?.[1] ?? null;
|
|
77
|
+
const appPopup = declared && declared !== 'toolaby-popup.html' ? declared : null;
|
|
78
|
+
const worker = source.match(/service_worker:\s*['"]([^'"]+)['"]/)?.[1] ?? first(has, ['src/background.ts', 'src/background.js', 'background.ts', 'background.js']);
|
|
79
|
+
const workerFile = worker ?? 'src/background.ts';
|
|
80
|
+
const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
|
|
81
|
+
const code = [
|
|
82
|
+
`// ${configFile ?? 'manifest.config.ts'} — add inside defineManifest({ … })`,
|
|
83
|
+
`permissions: ['storage'],`,
|
|
84
|
+
`host_permissions: ['${site}/*'],`,
|
|
85
|
+
`externally_connectable: { matches: ['${site}/*'] },`,
|
|
86
|
+
...(appPopup ? [`action: { default_popup: 'toolaby-popup.html' },`] : []),
|
|
87
|
+
...(worker ? [] : [`background: { service_worker: '${workerFile}', type: 'module' },`]),
|
|
88
|
+
].join('\n');
|
|
89
|
+
// CRXJS builds only the pages the manifest names; once ours is the popup, theirs stays built as a Vite input.
|
|
90
|
+
const viteFile = first(has, ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) ?? 'vite.config.ts';
|
|
91
|
+
const vite = [
|
|
92
|
+
`// ${viteFile} — CRXJS builds only the pages the manifest names: this keeps your popup built, for the Wall's to hand over to`,
|
|
93
|
+
`export default defineConfig({`,
|
|
94
|
+
` plugins: [crx({ manifest })],`,
|
|
95
|
+
` build: { rollupOptions: { input: { popup: '${appPopup}' } } },`,
|
|
96
|
+
`});`,
|
|
97
|
+
].join('\n');
|
|
98
|
+
return {
|
|
99
|
+
kind: 'crxjs', label: 'CRXJS', codeDir: '', popupDir: '', manifestFile: null, wrapper: true, appPopup, worker: worker && has(worker) ? worker : null,
|
|
100
|
+
createWorker: worker && has(worker) ? null : { file: workerFile, content: workerLines },
|
|
101
|
+
workerLines, snippets: [{ file: configFile ?? 'manifest.config.ts', language: 'ts', code }, ...(appPopup ? [{ file: viteFile, language: 'ts', code: vite }] : [])],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
if (has('manifest.json')) {
|
|
105
|
+
// A plain extension, or CRXJS with a real manifest.json: the manifest is edited (wireManifest), everything at the root.
|
|
106
|
+
const declared = typeof manifest?.action?.default_popup === 'string' ? manifest.action.default_popup : null;
|
|
107
|
+
const named = typeof manifest?.background?.service_worker === 'string' ? manifest.background.service_worker : null;
|
|
108
|
+
const workerFile = named && has(named) ? named : 'background.js';
|
|
109
|
+
return {
|
|
110
|
+
kind: crxjs ? 'crxjs' : 'plain', label: crxjs ? 'CRXJS' : 'a plain extension', codeDir: '', popupDir: '', manifestFile: 'manifest.json', wrapper: true,
|
|
111
|
+
appPopup: declared && declared !== 'toolaby-popup.html' ? declared : null, worker: named && has(named) ? named : null, createWorker: null,
|
|
112
|
+
workerLines: `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`, snippets: [],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'unknown', label: 'unknown', codeDir: '', popupDir: '', manifestFile: null, wrapper: false, appPopup: null, worker: null, createWorker: null, workerLines: '', snippets: [] };
|
|
116
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toolaby",
|
|
3
|
+
"version": "1.0.0",
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": { "toolaby": "./bin.mjs" },
|
|
7
|
+
"files": ["bin.mjs", "lib", "README.md"],
|
|
8
|
+
"engines": { "node": ">=18" },
|
|
9
|
+
"keywords": ["chrome-extension", "toolaby", "licensing", "cli"],
|
|
10
|
+
"homepage": "https://toolaby.app",
|
|
11
|
+
"repository": { "type": "git", "url": "https://github.com/miltostave/Toolaby.git", "directory": "packages/toolaby-cli" },
|
|
12
|
+
"license": "MIT"
|
|
13
|
+
}
|