toolaby 1.0.3 → 1.2.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 CHANGED
@@ -3,11 +3,13 @@
3
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
4
 
5
5
  ```sh
6
- npm i -g toolaby # once (or run every command through `npx -y toolaby …`)
7
- toolaby login # once: a code to approve in your browser
8
- toolaby wire <tool-id> # in your extension's folder; `toolaby tools` lists your ids
6
+ npm i -g toolaby # once (or run every command through `npx -y toolaby …`)
7
+ toolaby wire <tool-id> # in your extension's folder, on Test; signs you in first if it must
8
+ toolaby wire <tool-id> --live # the same, on Live: the store build
9
9
  ```
10
10
 
11
+ Every command acts on the test Wall (`test.toolaby.app`) unless told `--live` (`toolaby.app`), as Stripe's command line does. `toolaby tools` lists your ids on either side.
12
+
11
13
  ## What it does
12
14
 
13
15
  **`toolaby login`** signs you in. It shows a code and opens the Toolaby dashboard; you approve the code there — as the account the dashboard is signed in to — and the command is signed in as that account from then on. It works the way GitHub's and Stripe's command lines sign in, and only ever acts on that account's own tools.
@@ -27,6 +29,6 @@ If the extension is built with WXT, CRXJS or Plasmo, the command puts the same f
27
29
  ## Where things live
28
30
 
29
31
  - Your session: `~/.config/toolaby/credentials.json`, readable by you alone (mode 600). `toolaby logout` deletes it after revoking it.
30
- - `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`).
32
+ - The session is per side. `TOOLABY_TOKEN` stands in for the file where there is no home directory (CI). `TOOLABY_WALL` or `--wall <url>` names any other Wall.
31
33
 
32
34
  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 CHANGED
@@ -7,12 +7,16 @@
7
7
  // toolaby tools your tools, with the ids the commands want
8
8
  // toolaby whoami · logout · help
9
9
  //
10
+ // Test and live, as Stripe's command line has them: every command acts on
11
+ // the TEST Wall (test.toolaby.app) unless told `--live`, so the everyday
12
+ // command is the safe one and going live is a word you type on purpose.
10
13
  // Signed in, the command acts as you and only on your tools, as Clerk's and
11
14
  // Stripe's do: nothing is fetched for a tool your workspaces do not own. The
12
15
  // 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
+ // side; a command that needs one and has none signs you in on the spot.
17
+ // `logout` revokes it on the server before forgetting it. TOOLABY_TOKEN
18
+ // stands in for the file where there is no home (CI); TOOLABY_WALL or
19
+ // `--wall <url>` names any other Wall (a laptop's, a self-hosted one).
16
20
  //
17
21
  // The wiring rules — what a folder is and what its manifest needs — are the
18
22
  // platform's own (wall/src/packages/wiring), carried here transpiled in lib/,
@@ -24,7 +28,7 @@ import { spawn } from 'node:child_process';
24
28
  import { wireManifest, WORKER_LINES } from './lib/manifest.js';
25
29
  import { detectProject, CODE_FILES, filesToWrite, BUILD_COPY_NOTE } from './lib/project.js';
26
30
 
27
- const VERSION = '1.0.3';
31
+ const VERSION = '1.2.0';
28
32
  const CLIENT_ID = 'toolaby-cli';
29
33
  const argv = process.argv.slice(2);
30
34
  // `--version` and `--help` are commands of their own; every other command is the first bare word.
@@ -33,7 +37,11 @@ const rest = argv.filter((a) => a !== command);
33
37
  const positional = rest.filter((a) => !a.startsWith('--'));
34
38
  const opt = (k, d = null) => { const i = rest.indexOf(k); return i >= 0 && rest[i + 1] && !rest[i + 1].startsWith('--') ? rest[i + 1] : d; };
35
39
  const flag = (k) => rest.includes(k);
36
- const wall = (opt('--wall') ?? process.env.TOOLABY_WALL ?? 'https://toolaby.app').replace(/\/$/, '');
40
+ const WALLS = { test: 'https://test.toolaby.app', live: 'https://toolaby.app' };
41
+ const live = flag('--live');
42
+ const wall = (opt('--wall') ?? process.env.TOOLABY_WALL ?? (live ? WALLS.live : WALLS.test)).replace(/\/$/, '');
43
+ /** The side, for every message: what a developer must never be unsure of. */
44
+ const side = wall === WALLS.live ? 'Live' : wall === WALLS.test ? 'Test' : wall;
37
45
  const out = (s = '') => console.log(s);
38
46
  const fail = (s) => { console.error(`\n ${s}\n`); process.exit(1); };
39
47
 
@@ -72,7 +80,7 @@ async function login() {
72
80
  const { device_code, user_code, verification_uri, verification_uri_complete, interval = 5, expires_in = 600 } = start.json;
73
81
  const link = verification_uri_complete || `${verification_uri}?user_code=${encodeURIComponent(user_code)}`;
74
82
  const page = link.startsWith('http') ? link : `${wall}${link}`;
75
- 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`);
83
+ out(`\n Sign in to the ${side} Wall to continue.\n\n Open ${page}\n Code ${user_code} (approve it there — it expires in ${Math.round(expires_in / 60)} minutes)\n`);
76
84
  openBrowser(page);
77
85
  let every = Math.max(1, Number(interval) || 5) * 1000;
78
86
  const until = Date.now() + Number(expires_in) * 1000;
@@ -88,7 +96,7 @@ async function login() {
88
96
  const all = readCredentials();
89
97
  all[wall] = { token, email, at: new Date().toISOString() };
90
98
  writeCredentials(all);
91
- out(`\n\n Signed in${email ? ` as ${email}` : ''}.\n`);
99
+ out(`\n\n Signed in${email ? ` as ${email}` : ''} · ${side}.\n`);
92
100
  return token;
93
101
  }
94
102
  const why = poll.json.error;
@@ -107,11 +115,11 @@ async function tokenOrLogin() {
107
115
 
108
116
  async function whoami() {
109
117
  const token = storedToken();
110
- if (!token) fail(`Not signed in to ${wall}. Run \`toolaby login\`.`);
118
+ if (!token) fail(`Not signed in to ${side} (${wall}). Run \`toolaby login${live ? ' --live' : ''}\`.`);
111
119
  const me = await api('/api/cli/me', { token });
112
120
  if (me.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
113
121
  if (!me.ok) fail(`The Wall answered HTTP ${me.status}.`);
114
- out(`\n ${me.json.email} · ${wall}\n`);
122
+ out(`\n ${me.json.email} · ${side} · ${wall}\n`);
115
123
  for (const w of me.json.workspaces) out(` ${w.name} (${w.slug}, ${w.role}) ${w.site}`);
116
124
  out();
117
125
  }
@@ -119,22 +127,22 @@ async function whoami() {
119
127
  async function logout() {
120
128
  const all = readCredentials();
121
129
  const entry = all[wall];
122
- if (!entry && !process.env.TOOLABY_TOKEN) { out(`\n Not signed in to ${wall}.\n`); return; }
130
+ if (!entry && !process.env.TOOLABY_TOKEN) { out(`\n Not signed in to ${side} (${wall}).\n`); return; }
123
131
  // Revoked on the server first; forgotten here whatever the server said — but the difference is reported.
124
132
  const res = await api('/api/dev-auth/sign-out', { method: 'POST', body: {}, token: entry?.token ?? process.env.TOOLABY_TOKEN }).catch(() => ({ ok: false, status: 0 }));
125
133
  delete all[wall];
126
134
  writeCredentials(all);
127
- 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`);
135
+ out(res.ok ? `\n Signed out of ${side} (${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`);
128
136
  if (process.env.TOOLABY_TOKEN) out(' TOOLABY_TOKEN is set in this shell and still authenticates.\n');
129
137
  }
130
138
 
131
139
  async function tools() {
132
140
  const token = await tokenOrLogin();
133
141
  const res = await api('/api/cli/tools', { token });
134
- if (res.status === 401) fail(`The session for ${wall} is no longer valid. Run \`toolaby login\`.`);
142
+ if (res.status === 401) fail(`The session for ${side} is no longer valid. Run \`toolaby login${live ? ' --live' : ''}\`.`);
135
143
  if (!res.ok) fail(`The Wall answered HTTP ${res.status}.`);
136
- 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; }
137
- out();
144
+ if (!res.json.tools.length) { out(`\n No tools on ${side} yet. Make one in the dashboard, then \`toolaby wire <tool-id>${live ? ' --live' : ''}\` in your extension's folder.\n`); return; }
145
+ out(`\n ${side}\n`);
138
146
  for (const t of res.json.tools) out(` ${t.slug.padEnd(40)} ${t.name}${t.bundle ? ' (bundle)' : ''} ${t.site}`);
139
147
  out();
140
148
  }
@@ -155,10 +163,11 @@ const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8'))
155
163
  const isId = (s) => typeof s === 'string' && /^[a-z0-9][a-z0-9-]{1,118}$/.test(s);
156
164
 
157
165
  /** The tool's files from the Wall, as the signed-in developer: a tool of theirs, or a clear no. */
158
- async function filesFor(slug, scaffold) {
166
+ async function filesFor(slug, scaffold, manifestKey = null) {
159
167
  const token = await tokenOrLogin();
160
- const res = await api(`/api/cli/wiring/${slug}${scaffold ? '?scaffold=1' : ''}`, { token });
161
- if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold); }
168
+ const q = new URLSearchParams(); if (scaffold) q.set('scaffold', '1'); if (manifestKey) q.set('manifestKey', manifestKey);
169
+ const res = await api(`/api/cli/wiring/${slug}${q.size ? `?${q}` : ''}`, { token });
170
+ if (res.status === 401) { out(' The stored session is no longer valid; signing in again.'); await login(); return filesFor(slug, scaffold, manifestKey); }
162
171
  if (res.status === 404) {
163
172
  // Say who the command is: the tool may well exist — in a workspace of another account of theirs.
164
173
  const me = await api('/api/cli/me', { token }).catch(() => null);
@@ -178,17 +187,19 @@ async function wire() {
178
187
  const has = (p) => tree.has(p.replace(/\/$/, ''));
179
188
  const manifest = has('manifest.json') ? readJson(join(dir, 'manifest.json')) : null;
180
189
  if (has('manifest.json') && !manifest) fail('manifest.json could not be read as JSON.');
181
- const plan = await filesFor(slug, false);
190
+ // A key the manifest already has is the identity the platform adopts; otherwise the platform's goes in.
191
+ const plan = await filesFor(slug, false, typeof manifest?.key === 'string' ? manifest.key : null);
182
192
  const site = plan.site;
193
+ const manifestKey = plan.identity?.manifestKey ?? null;
183
194
  const configSource = ['manifest.config.ts', 'manifest.config.js', 'manifest.ts', 'src/manifest.ts'].find(has);
184
- const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readFileSync(join(dir, configSource), 'utf8') : null, site, tree });
195
+ const project = detectProject({ has, packageJson: has('package.json') ? readJson(join(dir, 'package.json')) : null, manifest, manifestSource: configSource ? readFileSync(join(dir, configSource), 'utf8') : null, site, tree, manifestKey });
185
196
  // Nothing recognised: nothing is written and nothing guessed. A manifest below the top is where to point --into; otherwise the Set up page's Do it myself has the files and the three things to paste.
186
197
  if (project.kind === 'unknown') fail(`${dir} has no manifest.json at the top and is not a WXT, CRXJS or Plasmo project${project.foundManifest ? ` — there is one at ${project.foundManifest}: run this with --into ${dirname(project.foundManifest) || '.'}` : ''}.\n Nothing was written. For a layout of your own, the tool's Set up page → Do it myself has the files and the three things to paste.`);
187
198
 
188
199
  // The extension's own popup: the manifest's, or — wired before — the one the existing configuration remembers.
189
200
  const configPath = join(dir, `${project.codeDir}toolaby.config.js`);
190
201
  const remembered = existsSync(configPath) ? readFileSync(configPath, 'utf8').match(/appPopup:\s*'([^']*)'/)?.[1] || null : null;
191
- const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered }) : null;
202
+ const wiring = project.manifestFile && manifest ? wireManifest(manifest, { site, has, remembered, manifestKey }) : null;
192
203
  const appPopup = wiring ? wiring.appPopup : project.wrapper ? (project.appPopup ?? remembered) : null;
193
204
  const targets = filesToWrite(project, appPopup);
194
205
  const present = targets.filter((f) => existsSync(join(dir, f)));
@@ -209,7 +220,7 @@ async function wire() {
209
220
  ownWorker = { file: project.worker, classic: false };
210
221
  }
211
222
 
212
- out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`}\n`);
223
+ out(`\n ${plan.name} → ${dir}${project.kind === 'plain' ? '' : ` (${project.label})`} · ${side}${side === 'Test' ? ' (the tk_test_ key; `--live` for the store build)' : ''}\n`);
213
224
  out(` Written: ${targets.join(', ')}${did.length ? `,\n ${did.join(',\n ')}` : ''}.\n`);
214
225
  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`);
215
226
  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`);
@@ -239,14 +250,15 @@ function help() {
239
250
  out(`
240
251
  toolaby — the Toolaby command line (${VERSION})
241
252
 
242
- toolaby login sign in: a code to approve in the browser
243
- toolaby wire <tool-id> wire the extension in this folder to your tool
253
+ toolaby wire <tool-id> wire the extension in this folder to your tool, on Test
254
+ toolaby wire <tool-id> --live the same, on Live: the store build
244
255
  --into <folder> another folder --force replace the platform's files
245
256
  toolaby create <tool-id> a new, minimal extension for your tool, in <tool-id>/ --dir <folder>
246
257
  toolaby tools your tools, with their ids
247
- toolaby whoami · logout
258
+ toolaby login · whoami · logout the session, per side; a command that needs one signs you in itself
248
259
 
249
- --wall <url> the Wall to talk to (https://toolaby.app; TOOLABY_WALL) TOOLABY_TOKEN a session, for CI
260
+ Every command acts on Test (test.toolaby.app) unless told --live (toolaby.app).
261
+ --wall <url> or TOOLABY_WALL names any other Wall; TOOLABY_TOKEN a session, for CI.
250
262
  `);
251
263
  }
252
264
 
package/lib/manifest.js CHANGED
@@ -6,15 +6,25 @@
6
6
  * the storage permission; the platform's address as a host permission and
7
7
  * for externally_connectable (the site's sign-in and purchase handoffs); a
8
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.
9
+ * not there; the Wall's popup in front of the extension's own, which the
10
+ * configuration then remembers; and the extension's identity — the `key`
11
+ * that fixes its id, which the tool key is bound to (identity.ts) — unless
12
+ * the manifest has one of its own, which is then the identity the platform
13
+ * adopts. Pure: given the manifest and what is on disk, it says what to
14
+ * write.
12
15
  */
13
16
  export const WALL_POPUP = 'toolaby-popup.html';
14
17
  export const WORKER_LINES = "import { toolaby } from './toolaby.js';\n\ntoolaby.startBackground();\n";
15
18
  export function wireManifest(input, o) {
16
19
  const manifest = JSON.parse(JSON.stringify(input));
17
20
  const did = [];
21
+ // The identity: the manifest's own key stays (the platform adopts it); otherwise the platform's goes in, and the unpacked extension's id is known before it loads.
22
+ const own = typeof manifest.key === 'string' && manifest.key.trim() ? manifest.key.trim() : null;
23
+ if (!own && o.manifestKey) {
24
+ manifest.key = o.manifestKey;
25
+ did.push('a "key", so the extension keeps one id across reloads and machines');
26
+ }
27
+ const manifestKey = own ?? o.manifestKey ?? null;
18
28
  const add = (list, value) => { const arr = Array.isArray(list) ? [...list] : []; if (!arr.includes(value))
19
29
  arr.push(value); return arr; };
20
30
  if (!(Array.isArray(manifest.permissions) && manifest.permissions.includes('storage'))) {
@@ -48,5 +58,5 @@ export function wireManifest(input, o) {
48
58
  else {
49
59
  ownWorker = { file: named, classic: manifest.background?.type !== 'module' };
50
60
  }
51
- return { manifest, did, writeWorker, ownWorker, appPopup };
61
+ return { manifest, did, writeWorker, ownWorker, appPopup, manifestKey };
52
62
  }
package/lib/project.js CHANGED
@@ -21,6 +21,7 @@ const dep = (pkg, name) => !!(pkg?.dependencies?.[name] || pkg?.devDependencies?
21
21
  const first = (has, candidates) => candidates.find(has) ?? null;
22
22
  export function detectProject(o) {
23
23
  const { has, packageJson: pkg, manifest, site } = o;
24
+ const keyLine = o.manifestKey ? `key: '${o.manifestKey}',` : null;
24
25
  const scripts = pkg?.scripts ?? {};
25
26
  const built = !!scripts.build || has('dist') || has('build');
26
27
  const wxt = dep(pkg, 'wxt') || !!first(has, ['wxt.config.ts', 'wxt.config.js', 'wxt.config.mts', 'wxt.config.mjs']);
@@ -38,6 +39,7 @@ export function detectProject(o) {
38
39
  `// wxt.config.ts — what the Wall needs in the manifest${appPopup ? ', and its popup in front of yours' : ''}`,
39
40
  `export default defineConfig({`,
40
41
  ` manifest: {`,
42
+ ...(keyLine ? [` ${keyLine} // the extension's id, known to the platform`] : []),
41
43
  ` permissions: ['storage'],`,
42
44
  ` host_permissions: ['${site}/*'],`,
43
45
  ` externally_connectable: { matches: ['${site}/*'] },`,
@@ -66,7 +68,7 @@ export function detectProject(o) {
66
68
  const worker = first(has, [`${src}background.ts`, `${src}background.js`, `${src}background/index.ts`, `${src}background/index.js`]);
67
69
  const workerFile = worker ?? `${src}background.ts`;
68
70
  const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
69
- const code = JSON.stringify({ manifest: { permissions: ['storage'], host_permissions: [`${site}/*`], externally_connectable: { matches: [`${site}/*`] } } }, null, 2).replace(/^\{\n|\n\}$/g, '');
71
+ const code = JSON.stringify({ manifest: { ...(o.manifestKey ? { key: o.manifestKey } : {}), permissions: ['storage'], host_permissions: [`${site}/*`], externally_connectable: { matches: [`${site}/*`] } } }, null, 2).replace(/^\{\n|\n\}$/g, '');
70
72
  return {
71
73
  kind: 'plasmo', label: 'Plasmo', codeDir: '', popupDir: '', manifestFile: null, wrapper: false, appPopup: null, worker, built: false, foundManifest: null,
72
74
  createWorker: worker ? null : { file: workerFile, content: workerLines },
@@ -84,6 +86,7 @@ export function detectProject(o) {
84
86
  const workerLines = `import { toolaby } from '${importPath(workerFile)}';\n\ntoolaby.startBackground();\n`;
85
87
  const code = [
86
88
  `// ${configFile ?? 'manifest.config.ts'} — add inside defineManifest({ … })`,
89
+ ...(keyLine && !/\bkey:/.test(source) ? [`${keyLine} // the extension's id, known to the platform`] : []),
87
90
  `permissions: ['storage'],`,
88
91
  `host_permissions: ['${site}/*'],`,
89
92
  `externally_connectable: { matches: ['${site}/*'] },`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolaby",
3
- "version": "1.0.3",
3
+ "version": "1.2.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
6
  "bin": { "toolaby": "./bin.mjs" },