toolaby 1.2.0 → 1.4.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.
Files changed (3) hide show
  1. package/bin.mjs +95 -2
  2. package/lib/project.js +11 -13
  3. package/package.json +23 -6
package/bin.mjs CHANGED
@@ -5,6 +5,11 @@
5
5
  // toolaby wire <tool-id> wire the extension in this folder to your tool — plain, WXT, CRXJS or Plasmo
6
6
  // toolaby create <tool-id> make a new, minimal extension for your tool in <tool-id>/
7
7
  // toolaby tools your tools, with the ids the commands want
8
+ // toolaby webhooks events the six events, with an example of each
9
+ // toolaby webhooks trigger <event> send an example delivery: to your registered endpoints, or with --to straight to a local server
10
+ // toolaby webhooks endpoints the workspace's endpoints
11
+ // toolaby webhooks add <url> register an endpoint; the secret is printed once
12
+ // toolaby webhooks remove <id> remove one
8
13
  // toolaby whoami · logout · help
9
14
  //
10
15
  // Test and live, as Stripe's command line has them: every command acts on
@@ -25,10 +30,11 @@ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync, statSy
25
30
  import { join, resolve, dirname } from 'node:path';
26
31
  import { homedir } from 'node:os';
27
32
  import { spawn } from 'node:child_process';
33
+ import { createHmac, randomBytes } from 'node:crypto';
28
34
  import { wireManifest, WORKER_LINES } from './lib/manifest.js';
29
35
  import { detectProject, CODE_FILES, filesToWrite, BUILD_COPY_NOTE } from './lib/project.js';
30
36
 
31
- const VERSION = '1.2.0';
37
+ const VERSION = '1.4.0';
32
38
  const CLIENT_ID = 'toolaby-cli';
33
39
  const argv = process.argv.slice(2);
34
40
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -246,6 +252,90 @@ async function create() {
246
252
  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`);
247
253
  }
248
254
 
255
+ // ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
256
+ //
257
+ // `trigger <event>` sends the catalogue's example of the event through the
258
+ // Wall to every endpoint the workspace registered — signed, logged, retried,
259
+ // as a real one. `trigger <event> --to <url>` skips the Wall: the command
260
+ // signs the example itself, Standard Webhooks style, with a secret you name
261
+ // (or one it prints) and POSTs it to the URL — a server on localhost, no
262
+ // tunnel, no deploy — so a handler can be written and run before anything
263
+ // is registered.
264
+ const DEV_SECRET = 'whsec_dG9vbGFieS1kZXYtd2ViaG9vay1zZWNyZXQtMDAwMQ==';
265
+ async function catalogue() {
266
+ const res = await api('/api/cli/webhooks/events', { token: await tokenOrLogin() });
267
+ if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`toolaby login${live ? ' --live' : ''}\`.`);
268
+ if (!res.ok) fail(`The Wall answered HTTP ${res.status}.`);
269
+ return res.json.events;
270
+ }
271
+ async function webhooks() {
272
+ const sub = positional[0];
273
+ if (sub === 'events') {
274
+ const events = await catalogue();
275
+ out(`\n ${side}\n`);
276
+ for (const e of events) out(` ${e.type.padEnd(26)} ${e.description}`);
277
+ out(`\n toolaby webhooks trigger <event> → your registered endpoints\n toolaby webhooks trigger <event> --to <url> → a local server, signed with a secret you name\n`);
278
+ return;
279
+ }
280
+ if (sub === 'trigger') {
281
+ const type = positional[1];
282
+ if (!type) fail('usage: toolaby webhooks trigger <event> [--to <url>] [--secret <whsec_…>] — `toolaby webhooks events` lists them');
283
+ const to = opt('--to');
284
+ if (!to) {
285
+ const res = await api('/api/cli/webhooks/trigger', { method: 'POST', body: { type, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
286
+ if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
287
+ out(`\n Sent ${res.json.sent} to every endpoint of ${res.json.workspace} · ${side}. Its id is ${res.json.id}; the delivery log is under Configure → Webhooks.\n`);
288
+ return;
289
+ }
290
+ const events = await catalogue();
291
+ const event = events.find((e) => e.type === type);
292
+ if (!event) fail(`No event "${type}". \`toolaby webhooks events\` lists them.`);
293
+ const secret = opt('--secret') ?? DEV_SECRET;
294
+ if (!/^whsec_[A-Za-z0-9+/=]+$/.test(secret)) fail('--secret must be a Standard Webhooks secret: whsec_ followed by base64.');
295
+ const id = `msg_${randomBytes(12).toString('base64url')}`;
296
+ const timestamp = Math.floor(Date.now() / 1000);
297
+ const body = JSON.stringify({ id: `evt_${type.replace('.', '_')}_${Date.now().toString(36)}`, type, created: timestamp, livemode: false, data: event.example });
298
+ const signature = 'v1,' + createHmac('sha256', Buffer.from(secret.slice(6), 'base64')).update(`${id}.${timestamp}.${body}`).digest('base64');
299
+ let res;
300
+ try {
301
+ res = await fetch(to, { method: 'POST', headers: { 'Content-Type': 'application/json', 'webhook-id': id, 'webhook-timestamp': String(timestamp), 'webhook-signature': signature, 'User-Agent': `toolaby-cli/${VERSION}` }, body });
302
+ } catch (e) { fail(`Could not reach ${to}: ${e.message}`); }
303
+ const text = (await res.text()).slice(0, 200);
304
+ out(`\n POST ${to} → ${res.status}${text ? ` ${text.replace(/\s+/g, ' ')}` : ''}`);
305
+ out(` ${type}, signed with ${opt('--secret') ? 'your secret' : `${DEV_SECRET} — set WALL_WEBHOOK_SECRET to it to verify`}.\n`);
306
+ if (res.status >= 300) out(' A real delivery would be retried until your server answers 2xx.\n');
307
+ return;
308
+ }
309
+ if (sub === 'endpoints') {
310
+ const ws = opt('--workspace');
311
+ const res = await api(`/api/cli/webhooks/endpoints${ws ? `?workspace=${encodeURIComponent(ws)}` : ''}`, { token: await tokenOrLogin() });
312
+ if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
313
+ out(`\n ${res.json.workspace} · ${side}\n`);
314
+ if (res.json.endpoints.length === 0) out(' None yet. `toolaby webhooks add <url>` registers one.');
315
+ for (const e of res.json.endpoints) out(` ${e.id} ${e.disabled ? 'paused ' : 'on '} ${e.url}${e.description ? ` — ${e.description}` : ''}${e.events.length ? ` (${e.events.join(', ')})` : ''}`);
316
+ out();
317
+ return;
318
+ }
319
+ if (sub === 'add') {
320
+ const url = positional[1];
321
+ if (!url) fail('usage: toolaby webhooks add <url> [--description <text>] [--events a.b,c.d] [--workspace <slug>]');
322
+ const events = (opt('--events') ?? '').split(',').map((e) => e.trim()).filter(Boolean);
323
+ const res = await api('/api/cli/webhooks/endpoints', { method: 'POST', body: { url, description: opt('--description') ?? '', events, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
324
+ if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
325
+ out(`\n Added ${res.json.id} → ${res.json.url} · ${res.json.workspace} · ${side}\n\n Signing secret, shown once — keep it where your server keeps secrets:\n\n ${res.json.secret}\n`);
326
+ return;
327
+ }
328
+ if (sub === 'remove') {
329
+ const id = positional[1];
330
+ if (!id) fail('usage: toolaby webhooks remove <ep_…> [--workspace <slug>] — `toolaby webhooks endpoints` lists them');
331
+ const res = await api('/api/cli/webhooks/endpoints', { method: 'DELETE', body: { id, workspace: opt('--workspace') ?? undefined }, token: await tokenOrLogin() });
332
+ if (!res.ok) fail(res.json.message ?? `The Wall answered HTTP ${res.status}.`);
333
+ out(`\n Removed ${res.json.removed} · ${res.json.workspace} · ${side}. Deliveries to it stop at once.\n`);
334
+ return;
335
+ }
336
+ fail('usage: toolaby webhooks events | trigger <event> [--to <url>] | endpoints | add <url> | remove <id>');
337
+ }
338
+
249
339
  function help() {
250
340
  out(`
251
341
  toolaby — the Toolaby command line (${VERSION})
@@ -255,6 +345,9 @@ function help() {
255
345
  --into <folder> another folder --force replace the platform's files
256
346
  toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
257
347
  toolaby tools your tools, with their ids
348
+ toolaby webhooks events the six events, with an example of each
349
+ toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
350
+ toolaby webhooks endpoints your endpoints; add <url> registers one (secret shown once), remove <id> removes one
258
351
  toolaby login · whoami · logout the session, per side; a command that needs one signs you in itself
259
352
 
260
353
  Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
@@ -262,7 +355,7 @@ function help() {
262
355
  `);
263
356
  }
264
357
 
265
- const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, create, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
358
+ const commands = { login: async () => { await login(); }, logout, whoami, tools, wire, create, webhooks, help, '--help': help, '-h': help, '--version': () => out(VERSION), '-v': () => out(VERSION) };
266
359
  const run = commands[command];
267
360
  if (!run) { console.error(`\n Unknown command "${command}".`); help(); process.exit(1); }
268
361
  Promise.resolve().then(run).catch((e) => fail(e.message));
package/lib/project.js CHANGED
@@ -3,12 +3,15 @@
3
3
  export const BUILD_COPY_NOTE = 'A build ships this extension: make sure it copies the platform\'s files to its output — toolaby.js, toolaby.config.js, toolaby-client.module.js, paywall.js, paywall.css and toolaby-popup.* — the way it copies your popup.html.';
4
4
  /** 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. */
5
5
  export const CODE_FILES = ['toolaby.config.js', 'toolaby.js', 'toolaby-client.module.js', 'toolaby.d.ts', 'paywall.js', 'paywall.css'];
6
+ /** Beside the client when the build generates the manifest: the fragment it spreads in, derived from the key (boilerplate/toolaby.manifest.js). */
7
+ export const MANIFEST_HELPER = ['toolaby.manifest.js', 'toolaby.manifest.d.ts'];
6
8
  /** 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. */
7
9
  export const POPUP_FILES = ['toolaby-popup.html', 'toolaby-popup.js', 'toolaby-popup.css'];
8
10
  export const PANEL_FILES = ['paywall.js', 'paywall.css'];
9
11
  /** 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. */
10
12
  export const filesToWrite = (p, appPopup) => [
11
13
  ...CODE_FILES.map((f) => `${p.codeDir}${f}`),
14
+ ...(p.manifestFile ? [] : MANIFEST_HELPER.map((f) => `${p.codeDir}${f}`)),
12
15
  ...(appPopup ? [...POPUP_FILES, ...(p.popupDir !== p.codeDir ? PANEL_FILES : [])].map((f) => `${p.popupDir}${f}`) : []),
13
16
  ];
14
17
  /** The import of toolaby.js from a file at `from`, both relative to the root. */
@@ -21,7 +24,6 @@ const dep = (pkg, name) => !!(pkg?.dependencies?.[name] || pkg?.devDependencies?
21
24
  const first = (has, candidates) => candidates.find(has) ?? null;
22
25
  export function detectProject(o) {
23
26
  const { has, packageJson: pkg, manifest, site } = o;
24
- const keyLine = o.manifestKey ? `key: '${o.manifestKey}',` : null;
25
27
  const scripts = pkg?.scripts ?? {};
26
28
  const built = !!scripts.build || has('dist') || has('build');
27
29
  const wxt = dep(pkg, 'wxt') || !!first(has, ['wxt.config.ts', 'wxt.config.js', 'wxt.config.mts', 'wxt.config.mjs']);
@@ -36,14 +38,11 @@ export function detectProject(o) {
36
38
  const workerFile = worker ?? `${src}entrypoints/background.ts`;
37
39
  const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\nexport default defineBackground(() => {\n toolaby.startBackground();\n});\n`;
38
40
  const code = [
39
- `// wxt.config.ts — what the Wall needs in the manifest${appPopup ? ', and its popup in front of yours' : ''}`,
41
+ `// wxt.config.ts — the Wall's needs in the manifest come from toolaby.config.js${appPopup ? ', and its popup goes in front of yours' : ''}`,
42
+ `import { manifest as toolaby } from './toolaby.manifest.js';`,
43
+ ``,
40
44
  `export default defineConfig({`,
41
- ` manifest: {`,
42
- ...(keyLine ? [` ${keyLine} // the extension's id, known to the platform`] : []),
43
- ` permissions: ['storage'],`,
44
- ` host_permissions: ['${site}/*'],`,
45
- ` externally_connectable: { matches: ['${site}/*'] },`,
46
- ` },`,
45
+ ` manifest: { ...toolaby }, // the extension's id, the storage permission, the Wall's address`,
47
46
  ...(appPopup ? [
48
47
  ` hooks: {`,
49
48
  ` // WXT names your popup entrypoint after merging the manifest above; the hook runs after that.`,
@@ -85,11 +84,10 @@ export function detectProject(o) {
85
84
  const workerFile = worker ?? 'src/background.ts';
86
85
  const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
87
86
  const code = [
88
- `// ${configFile ?? 'manifest.config.ts'} — add inside defineManifest({ … })`,
89
- ...(keyLine && !/\bkey:/.test(source) ? [`${keyLine} // the extension's id, known to the platform`] : []),
90
- `permissions: ['storage'],`,
91
- `host_permissions: ['${site}/*'],`,
92
- `externally_connectable: { matches: ['${site}/*'] },`,
87
+ `// ${configFile ?? 'manifest.config.ts'} — the Wall's needs come from toolaby.config.js: import once, spread inside defineManifest({ … })`,
88
+ `import { manifest as toolaby } from '${importPath(configFile ?? 'manifest.config.ts').replace(/toolaby\.js$/, 'toolaby.manifest.js')}';`,
89
+ ``,
90
+ `...toolaby, // the extension's id, the storage permission, the Wall's address`,
93
91
  ...(appPopup ? [`action: { default_popup: 'toolaby-popup.html' },`] : []),
94
92
  ...(worker ? [] : [`background: { service_worker: '${workerFile}', type: 'module' },`]),
95
93
  ].join('\n');
package/package.json CHANGED
@@ -1,13 +1,30 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
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.",
5
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"],
6
+ "bin": {
7
+ "toolaby": "bin.mjs"
8
+ },
9
+ "files": [
10
+ "bin.mjs",
11
+ "lib",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "keywords": [
18
+ "chrome-extension",
19
+ "toolaby",
20
+ "licensing",
21
+ "cli"
22
+ ],
10
23
  "homepage": "https://toolaby.app",
11
- "repository": { "type": "git", "url": "https://github.com/miltostave/Toolaby.git", "directory": "packages/toolaby-cli" },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/miltostave/Toolaby.git",
27
+ "directory": "packages/toolaby-cli"
28
+ },
12
29
  "license": "MIT"
13
30
  }