toolaby 1.4.3 → 1.4.5

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/bin.mjs CHANGED
@@ -32,9 +32,9 @@ import { homedir } from 'node:os';
32
32
  import { spawn } from 'node:child_process';
33
33
  import { createHmac, randomBytes } from 'node:crypto';
34
34
  import { wireManifest, WORKER_LINES } from './lib/manifest.js';
35
- import { detectProject, CODE_FILES, filesToWrite, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
35
+ import { detectProject, CODE_FILES, filesToWrite, fillPages, scaffoldManifest, scaffoldFilesFor, surfacesOf, hasPopup, hasSidePanel, SURFACES, STALE_FILES, BUILD_COPY_NOTE } from './lib/project.js';
36
36
 
37
- const VERSION = '1.4.3';
37
+ const VERSION = '1.4.5';
38
38
  const CLIENT_ID = 'toolaby-cli';
39
39
  const argv = process.argv.slice(2);
40
40
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -218,7 +218,7 @@ async function wire() {
218
218
  // A command older than the Wall asks for files the Wall no longer hands out: said, with the way to the current one, rather than a crash.
219
219
  const missing = targets.map((p) => p.split('/').pop()).filter((name) => typeof plan.files[name] !== 'string');
220
220
  if (missing.length) fail(`This command (${VERSION}) is older than the Wall at ${wall}: it expects ${missing.join(', ')}, which the Wall no longer hands out.\n Update it: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz ${command} ${slug}`);
221
- for (const path of targets) write(path, plan.files[path.split('/').pop()].replaceAll('__APP_POPUP__', appPopup ?? '').replaceAll('__APP_SIDE_PANEL__', appSidePanel ?? ''));
221
+ for (const path of targets) write(path, fillPages(plan.files[path.split('/').pop()], { appPopup, appSidePanel }));
222
222
  // What an earlier wiring wrote and this one does not, wherever it put them: taken away, so nothing stale is shipped.
223
223
  const removed = [];
224
224
  for (const at of new Set([project.codeDir, project.popupDir, 'public/', 'src/public/'])) for (const f of STALE_FILES) { const p = join(dir, `${at}${f}`); if (existsSync(p)) { unlinkSync(p); removed.push(`${at}${f}`); } }
@@ -256,12 +256,28 @@ async function create() {
256
256
  if (existsSync(dir)) fail(`${dir} already exists`);
257
257
  const plan = await filesFor(slug, true);
258
258
  const write = (rel, content) => { mkdirSync(dirname(join(dir, rel)), { recursive: true }); writeFileSync(join(dir, rel), content); };
259
- 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__', '').replaceAll('__APP_SIDE_PANEL__', ''));
259
+ // The scaffold's own files as the Wall names them (older Walls did not, hence the list): a Wall that adds one to the
260
+ // minimal extension is written by this command too, instead of a manifest naming a file that never arrived.
261
+ const scaffold = Array.isArray(plan.scaffold) && plan.scaffold.length ? plan.scaffold : ['manifest.json', 'background.js', 'popup.html', 'sidepanel.html', 'popup.css', 'popup.js', 'README.md'];
262
+ // Which surfaces this extension is made with: both (the default), one of them, or none at all for a tool that works
263
+ // from the background. Asked for once here, rather than written and then deleted by hand.
264
+ const asked = opt('--surfaces');
265
+ if (asked && !SURFACES.includes(asked)) fail(`--surfaces takes ${SURFACES.join(', ')} — not "${asked}".`);
266
+ const surfaces = surfacesOf(asked);
267
+ const wanted = [...CODE_FILES, ...scaffoldFilesFor(surfaces, scaffold)];
268
+ const absent = wanted.filter((f) => typeof plan.files[f] !== 'string');
269
+ if (absent.length) fail(`The Wall at ${wall} named ${absent.join(', ')} for a new extension but did not hand ${absent.length > 1 ? 'them' : 'it'} over.\n Nothing was written. Update this command: npm install -g toolaby@latest — or run the Wall's own copy: npx --yes ${wall}/toolaby.tgz create ${slug}`);
270
+ // Both surfaces, and the panel drawn inside them: no page of the Wall's stands in front, so both names stay empty.
271
+ for (const f of wanted) write(f, fillPages(plan.files[f]));
272
+ // The manifest names the side panel only now that the page is beside it — an extension whose side_panel points at a
273
+ // missing file does not load at all, so a Wall that stops serving the page must not leave the key behind either.
274
+ write('manifest.json', JSON.stringify(scaffoldManifest(JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf8')), { sidePanel: hasSidePanel(surfaces), popup: hasPopup(surfaces) }), null, 2) + '\n');
260
275
  for (const s of [16, 48, 128]) {
261
276
  const res = await fetch(`${wall}/boilerplate/icons/icon${s}.png`);
262
277
  write(join('icons', `icon${s}.png`), Buffer.from(await res.arrayBuffer()));
263
278
  }
264
- 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`);
279
+ const clicking = { both: 'Click the icon: the popup shows what the Free plan holds. Open side panel, in it, is the same page at the panel\'s width', popup: 'Click the icon: the popup shows what the Free plan holds', 'side-panel': 'Click the icon: the side panel opens — it has no popup, so the click is the panel\'s', none: 'No popup and no side panel: nothing opens on a click. Your code says when to sell — toolaby.openPaymentPage()' }[surfaces];
280
+ out(`\n ${plan.name} → ${dir}${surfaces === 'both' ? '' : ` (--surfaces ${surfaces})`}\n\n 1. chrome://extensions → Developer mode → Load unpacked → ${dir}\n 2. ${clicking}; Buy opens ${plan.site}/tools/${slug}\n\n Edit background.js: your work goes behind toolaby.gate().\n Surfaces, and how to add or drop one later: ${plan.site}/docs/surfaces\n`);
265
281
  }
266
282
 
267
283
  // ── webhooks: the catalogue, and a delivery on demand ───────────────────────────────────────────────────────────────
@@ -357,6 +373,7 @@ function help() {
357
373
  --into <folder> another folder --force replace the platform's files
358
374
  toolaby upgrade <tool-id> the platform's files replaced with the current ones — the popup, the client — yours untouched
359
375
  toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
376
+ --surfaces both|popup|side-panel|none what it opens (default: both)
360
377
  toolaby tools your tools, with their ids
361
378
  toolaby webhooks events the six events, with an example of each
362
379
  toolaby webhooks trigger <event> an example delivery to your endpoints; --to <url> sends it to a local server instead
package/lib/manifest.js CHANGED
@@ -50,13 +50,22 @@ export function wireManifest(input, o) {
50
50
  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`);
51
51
  }
52
52
  // The extension's own side panel, the same way: the Wall's in front, with the permission a side panel needs.
53
+ // Chrome spells it side_panel.default_path, Firefox sidebar_action.default_panel; a manifest may carry both.
54
+ const theirs = (v) => (typeof v === 'string' && v !== WALL_SIDE_PANEL ? v : null);
53
55
  const declaredPanel = typeof manifest.side_panel?.default_path === 'string' ? manifest.side_panel.default_path : null;
54
- const appSidePanel = declaredPanel && declaredPanel !== WALL_SIDE_PANEL ? declaredPanel : declaredPanel === WALL_SIDE_PANEL ? (o.rememberedSidePanel || null) : null;
55
- if (appSidePanel && declaredPanel !== WALL_SIDE_PANEL) {
56
+ const declaredSidebar = typeof manifest.sidebar_action?.default_panel === 'string' ? manifest.sidebar_action.default_panel : null;
57
+ const ours = declaredPanel === WALL_SIDE_PANEL || declaredSidebar === WALL_SIDE_PANEL;
58
+ const appSidePanel = theirs(declaredPanel) ?? theirs(declaredSidebar) ?? (ours ? o.rememberedSidePanel || null : null);
59
+ if (theirs(declaredPanel)) {
56
60
  manifest.side_panel = { ...manifest.side_panel, default_path: WALL_SIDE_PANEL };
57
61
  if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('sidePanel')))
58
62
  manifest.permissions = add(manifest.permissions, 'sidePanel');
59
- did.push(`the Wall's side panel in front of ${appSidePanel}: the same as the popup, at the panel's width`);
63
+ did.push(`the Wall's side panel in front of ${declaredPanel}: the same as the popup, at the panel's width`);
64
+ }
65
+ // Firefox's sidebar, when it names the same page — one that names another is left alone rather than pointed elsewhere.
66
+ if (theirs(declaredSidebar) && (!declaredPanel || declaredSidebar === declaredPanel)) {
67
+ manifest.sidebar_action = { ...manifest.sidebar_action, default_panel: WALL_SIDE_PANEL };
68
+ did.push(`the Wall's side panel in front of ${declaredSidebar} in Firefox's sidebar_action too`);
60
69
  }
61
70
  const named = typeof manifest.background?.service_worker === 'string' && manifest.background.service_worker.trim() ? manifest.background.service_worker.trim() : null;
62
71
  let writeWorker = null;
package/lib/project.js CHANGED
@@ -19,6 +19,40 @@ export const filesToWrite = (p, appPopup, appSidePanel = null) => [
19
19
  ...(appSidePanel ? SIDE_PANEL_FILES.map((f) => `${p.popupDir}${f}`) : []),
20
20
  ...(p.extraFiles ?? []).map((f) => f.to),
21
21
  ];
22
+ export const SURFACES = ['both', 'popup', 'side-panel', 'none'];
23
+ export const surfacesOf = (v) => SURFACES.includes(v ?? '') ? v : 'both';
24
+ export const hasPopup = (s) => s === 'both' || s === 'popup';
25
+ export const hasSidePanel = (s) => s === 'both' || s === 'side-panel';
26
+ /** The scaffold's own files for those surfaces: the manifest and the background always, the pages as asked for. */
27
+ export const scaffoldFilesFor = (s, all) => all.filter((f) => (f === 'popup.html' ? hasPopup(s) : f === 'sidepanel.html' ? hasSidePanel(s) : f === 'popup.js' || f === 'popup.css' ? s !== 'none' : true));
28
+ /**
29
+ * The scaffold's manifest, once it is known which pages were really written. Chrome refuses to load an extension
30
+ * whose `side_panel.default_path` names a file that is not there — the whole extension, not just the panel — so the
31
+ * minimal extension ships a manifest that names only its popup, and whatever writes the pages settles it here: the
32
+ * side panel added with its page, the popup taken out when there is none (the icon stays; the click then opens the
33
+ * side panel, or nothing, which is what a background-only tool wants).
34
+ */
35
+ export function scaffoldManifest(manifest, wrote) {
36
+ const out = { ...manifest };
37
+ if (wrote.popup === false) {
38
+ const action = { ...out.action };
39
+ delete action.default_popup;
40
+ out.action = action;
41
+ }
42
+ if (!wrote.sidePanel)
43
+ return out;
44
+ const permissions = Array.isArray(out.permissions) ? [...out.permissions] : [];
45
+ if (!permissions.includes('sidePanel'))
46
+ permissions.push('sidePanel');
47
+ return { ...out, permissions, side_panel: { default_path: 'sidepanel.html' } };
48
+ }
49
+ /**
50
+ * The last two placeholders in a handed-out file — the extension's own popup and side panel, which only the
51
+ * writer knows, since it is the one that read the manifest. Everything else is filled where the files are built
52
+ * (wiring/files.ts). Every hand-out goes through this: the Wire button, the command, and the zip on the Set up
53
+ * page, which once shipped `appSidePanel: '__APP_SIDE_PANEL__'` because it filled the popup's name and not this one.
54
+ */
55
+ export const fillPages = (text, pages = {}) => text.replaceAll('__APP_POPUP__', pages.appPopup ?? '').replaceAll('__APP_SIDE_PANEL__', pages.appSidePanel ?? '');
22
56
  /** The import of toolaby.js from a file at `from`, both relative to the root. */
23
57
  export function importPath(from, codeDir = '') {
24
58
  const depth = from.split('/').length - 1;
@@ -109,7 +143,8 @@ export function detectProject(o) {
109
143
  // A plain extension with a build (webpack, Rollup, Parcel, esbuild…) is wired the same way — the manifest is a file and
110
144
  // the files go beside it — but only what the build copies reaches the output, so that is said.
111
145
  const declared = typeof manifest?.action?.default_popup === 'string' ? manifest.action.default_popup : null;
112
- const declaredPanel = typeof manifest?.side_panel?.default_path === 'string' ? manifest.side_panel.default_path : null;
146
+ // Chrome's side_panel, else Firefox's sidebar_action: the same page, two spellings.
147
+ const declaredPanel = typeof manifest?.side_panel?.default_path === 'string' ? manifest.side_panel.default_path : typeof manifest?.sidebar_action?.default_panel === 'string' ? manifest.sidebar_action.default_panel : null;
113
148
  const named = typeof manifest?.background?.service_worker === 'string' ? manifest.background.service_worker : null;
114
149
  const workerFile = named && has(named) ? named : 'background.js';
115
150
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.4.3",
3
+ "version": "1.4.5",
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
6
  "bin": {