francois 0.20.0 → 0.20.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.
@@ -0,0 +1,455 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * extension-install FR-27..FR-30 — `francois ext install|list|remove` operate
5
+ * DIRECTLY on the filesystem: no socket, no `CliMethod`, works with the app
6
+ * closed. Plain CommonJS with no dependencies, same discipline as the rest of
7
+ * `packaging/npm/` — this can run inside `npm install`/from a bare `node`
8
+ * invocation before anything else is guaranteed to exist.
9
+ *
10
+ * Mirrors (in spirit, not in code) `src-tauri/src/extensions/manifest.rs` and
11
+ * `registry.rs` — the SAME id/size rules (FR-3, FR-5, FR-28, FR-29) plus a
12
+ * scoped FR-9/FR-10 argv0 check (`assertNoForbiddenArgv0`), reimplemented
13
+ * here because this package cannot depend on the Rust core. This is NOT full
14
+ * schema validation: panel/provider/detect *shape* (FR-6, FR-12, FR-21…) is
15
+ * validated only by the app itself at load time, same as before.
16
+ */
17
+
18
+ const fs = require('node:fs');
19
+ const os = require('node:os');
20
+ const path = require('node:path');
21
+ const { spawnSync } = require('node:child_process');
22
+
23
+ /** extension-install FR-3 (contract/extensions.ts `EXTENSION_ID_PATTERN`). */
24
+ const EXTENSION_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
25
+ /** FR-5: mirrors `MANIFEST_MAX_BYTES` in src-tauri/src/extensions/mod.rs — a
26
+ * manifest larger than this is one the app will always refuse to load, so
27
+ * `francois ext install` must refuse it too rather than install it dead. */
28
+ const MANIFEST_MAX_BYTES = 256 * 1024;
29
+
30
+ /** FR-9: bare binary name, off `PATH`. Mirrors `valid_argv0` /
31
+ * `ARGV0_PATTERN` in src-tauri/src/extensions/mod.rs (contract/extensions.ts). */
32
+ const ARGV0_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/;
33
+ /** FR-10: mirrors `SHELL_ARGV0_BLOCKLIST` in src-tauri/src/extensions/mod.rs. */
34
+ const SHELL_ARGV0_BLOCKLIST = new Set([
35
+ 'sh',
36
+ 'bash',
37
+ 'zsh',
38
+ 'fish',
39
+ 'cmd',
40
+ 'cmd.exe',
41
+ 'powershell',
42
+ 'powershell.exe',
43
+ 'pwsh',
44
+ 'pwsh.exe',
45
+ 'env',
46
+ ]);
47
+
48
+ function assertValidArgv0(argv0) {
49
+ if (typeof argv0 !== 'string' || !ARGV0_PATTERN.test(argv0) || SHELL_ARGV0_BLOCKLIST.has(argv0)) {
50
+ throw new Error(`"${sanitizeForDisplay(argv0)}" is not a valid argv0 (must match ARGV0_PATTERN and not be a shell)`);
51
+ }
52
+ }
53
+
54
+ /** Manifests are capped at `MANIFEST_MAX_BYTES`, but a small file can still
55
+ * encode tens of thousands of nesting levels (`[[[[…]]]]`) — a recursive
56
+ * walk over that would blow the call stack before FR-5's size check even
57
+ * matters. This is well beyond any manifest a real plugin would ever need. */
58
+ const MAX_MANIFEST_DEPTH = 200;
59
+
60
+ /** FR-9/FR-10: walk every `argv0` (provider/source) and `commandSucceeds`
61
+ * detect argv the manifest declares, anywhere in its tree, and refuse a
62
+ * manifest the app will silently reject at load time — see the module doc
63
+ * for what this does and does not check. Iterative (explicit stack), so a
64
+ * pathologically deep manifest is refused cleanly instead of overflowing
65
+ * the call stack. */
66
+ function assertNoForbiddenArgv0(root) {
67
+ const stack = [{ node: root, depth: 0 }];
68
+ while (stack.length > 0) {
69
+ const { node, depth } = stack.pop();
70
+ if (depth > MAX_MANIFEST_DEPTH) {
71
+ throw new Error(`extension.json is nested more than ${MAX_MANIFEST_DEPTH} levels deep`);
72
+ }
73
+ if (Array.isArray(node)) {
74
+ for (const item of node) stack.push({ node: item, depth: depth + 1 });
75
+ continue;
76
+ }
77
+ if (!node || typeof node !== 'object') continue;
78
+ if (typeof node.argv0 === 'string') assertValidArgv0(node.argv0);
79
+ if (node.kind === 'commandSucceeds' && Array.isArray(node.argv) && typeof node.argv[0] === 'string') {
80
+ assertValidArgv0(node.argv[0]);
81
+ }
82
+ for (const value of Object.values(node)) stack.push({ node: value, depth: depth + 1 });
83
+ }
84
+ }
85
+
86
+ /** Strips C0/C1 controls + bidi-control code points, mirroring
87
+ * `sanitizeForDisplay` in src/features/extensions/extensions.ts — the manifest
88
+ * `label` is untrusted, disk-supplied text and must never carry terminal
89
+ * control sequences to stdout. */
90
+ // eslint-disable-next-line no-control-regex
91
+ const CONTROL_OR_BIDI_RE =
92
+ /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/gu;
93
+ function sanitizeForDisplay(value) {
94
+ return typeof value === 'string' ? value.replace(CONTROL_OR_BIDI_RE, '') : value;
95
+ }
96
+
97
+ /** `src-tauri/tauri.conf.json`'s `identifier` — used only to locate the app's
98
+ * OWN `app_data_dir()/extensions.json` toggles, so `list` can report the real
99
+ * enabled/disabled state (FR-27) without a running app. If the app's bundle
100
+ * identifier ever changes, this constant must move with it. */
101
+ const APP_IDENTIFIER = 'com.francois.desktop';
102
+
103
+ /** FR-1: `~/.francois/extensions` — the one registry directory. */
104
+ function extensionsDir(home = os.homedir()) {
105
+ return path.join(home, '.francois', 'extensions');
106
+ }
107
+
108
+ /** Where Tauri's `app_data_dir()` resolves for this app, per OS — the SAME
109
+ * directory `extensions.json` (the toggles) is persisted to.
110
+ *
111
+ * Each branch joins with the separator flavour of the platform it resolves FOR,
112
+ * not the one the process happens to run on. `platform` is a parameter so a
113
+ * caller can ask for another OS's directory; with the ambient `path.join` that
114
+ * answer came back in the host's separators — a "darwin" path reading
115
+ * `\Users\u\Library\Application Support` on Windows. On the native platform
116
+ * these are exactly `path.join`, so nothing about the real lookup changes. */
117
+ function appDataDir({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
118
+ if (platform === 'darwin') {
119
+ return path.posix.join(home, 'Library', 'Application Support', APP_IDENTIFIER);
120
+ }
121
+ if (platform === 'win32') {
122
+ const base = env.APPDATA || path.win32.join(home, 'AppData', 'Roaming');
123
+ return path.win32.join(base, APP_IDENTIFIER);
124
+ }
125
+ const base = env.XDG_DATA_HOME || path.posix.join(home, '.local', 'share');
126
+ return path.posix.join(base, APP_IDENTIFIER);
127
+ }
128
+
129
+ /** Best-effort: a missing/unreadable/unparseable file reads as "nothing
130
+ * enabled" — the same default the core itself falls back to (FR-15). */
131
+ function readToggles(opts = {}) {
132
+ const file = path.join(appDataDir(opts), 'extensions.json');
133
+ try {
134
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
135
+ return (doc && typeof doc.toggles === 'object' && doc.toggles) || {};
136
+ } catch {
137
+ return {};
138
+ }
139
+ }
140
+
141
+ function isValidExtensionId(id) {
142
+ return typeof id === 'string' && EXTENSION_ID_PATTERN.test(id);
143
+ }
144
+
145
+ /** A local path vs. a git remote — an scp-like `user@host:path`, a URL with a
146
+ * scheme, or anything ending `.git`. */
147
+ function isGitUrl(source) {
148
+ return /^[\w.-]+@[\w.-]+:/.test(source) || /^[a-z][a-z0-9+.-]*:\/\//i.test(source) || source.endsWith('.git');
149
+ }
150
+
151
+ /** FR-28a: the bare-name convention. `cohorte` → the repository
152
+ * `francois-plugin-cohorte`. This is a URL SHORTHAND, not the plugin registry
153
+ * §2 refuses: there is no index to fetch, nothing to search, no version to
154
+ * resolve, and no list anyone curates. A name that does not follow the
155
+ * convention is still installable — by its full URL, exactly as before. */
156
+ const PLUGIN_REPO_PREFIX = 'francois-plugin-';
157
+ /** The owner a bare `<name>` (no `<owner>/`) is looked up under. */
158
+ const DEFAULT_PLUGIN_OWNER = 'antoine-gmnz';
159
+ /** GitHub's own owner rule: alphanumerics and single hyphens, not at the ends. */
160
+ const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$/;
161
+
162
+ /** `francois-plugin-cohorte` → `cohorte`. Applied to every source kind, so an
163
+ * extension's id never depends on how it was fetched. */
164
+ function stripRepoPrefix(name) {
165
+ return name.startsWith(PLUGIN_REPO_PREFIX) ? name.slice(PLUGIN_REPO_PREFIX.length) : name;
166
+ }
167
+
168
+ function conventionUrl(owner, name) {
169
+ return `https://github.com/${owner}/${PLUGIN_REPO_PREFIX}${name}.git`;
170
+ }
171
+
172
+ /**
173
+ * FR-28a — decide what `install <source>` actually means, and what id it lands
174
+ * under. Precedence, most explicit first:
175
+ *
176
+ * 1. an explicit git URL ⇒ cloned as given
177
+ * 2. an EXISTING local directory ⇒ copied (so `install ./cohorte` and
178
+ * `install cohorte` next to a real directory keep working — a bare name
179
+ * never silently reaches the network when a local answer exists)
180
+ * 3. `<name>` or `<owner>/<name>` ⇒ the convention above
181
+ *
182
+ * The id comes from the NAME the user typed, never from the repository's
183
+ * basename — otherwise `francois-plugin-cohorte` would install under the id
184
+ * `francois-plugin-cohorte` and FR-3 would mint every panel as
185
+ * `francois-plugin-cohorte:health`.
186
+ */
187
+ function resolveInstallSource(source, { cwd = process.cwd() } = {}) {
188
+ if (!source) throw new Error('a source path or git URL is required');
189
+ // A leading `-` would be read as a flag by `git clone` (or by `ssh`/`git`
190
+ // fetching a scp-like remote) once this string reaches spawnSync — the
191
+ // CVE-2017-1000117 argv-injection class. Refuse it before anything below
192
+ // gets a chance to treat it as a URL, a path, or a bare name.
193
+ if (source.startsWith('-')) {
194
+ throw new Error(`"${sanitizeForDisplay(source)}" is not a valid extension source (must not start with "-")`);
195
+ }
196
+
197
+ if (isGitUrl(source)) {
198
+ const id = stripRepoPrefix(idFromSource(source));
199
+ if (!isValidExtensionId(id)) {
200
+ throw new Error(`"${sanitizeForDisplay(id)}" is not a valid extension id (must match ${EXTENSION_ID_PATTERN})`);
201
+ }
202
+ return { kind: 'git', location: source, id };
203
+ }
204
+
205
+ // A path the user actually has wins over anything remote.
206
+ const local = path.resolve(cwd, source);
207
+ if (fs.existsSync(local) && fs.statSync(local).isDirectory()) {
208
+ // The prefix is stripped here too, so cloning `francois-plugin-cohorte`
209
+ // by hand and installing the PATH lands on the same id as installing the
210
+ // NAME. Anything else would make the id depend on how you fetched it.
211
+ return { kind: 'dir', location: local, id: stripRepoPrefix(idFromSource(local)) };
212
+ }
213
+
214
+ // Neither a URL nor a directory ⇒ the convention. A source that LOOKS like a
215
+ // path is refused as a bad id rather than being reinterpreted as a repo name:
216
+ // `../evil` is a typo'd or hostile path, and "no such directory" is the honest
217
+ // answer to it — not "cloning github.com/../francois-plugin-evil".
218
+ const parts = source.split('/');
219
+ if (source.includes('\\') || parts.some((p) => p === '' || p === '.' || p === '..')) {
220
+ throw new Error(`"${sanitizeForDisplay(source)}" is not a valid extension id (must match ${EXTENSION_ID_PATTERN})`);
221
+ }
222
+ if (parts.length > 2) {
223
+ throw new Error(`"${sanitizeForDisplay(source)}" is not a valid extension id (must match ${EXTENSION_ID_PATTERN})`);
224
+ }
225
+ const [owner, name] = parts.length === 2 ? parts : [DEFAULT_PLUGIN_OWNER, parts[0]];
226
+ if (!isValidExtensionId(name)) {
227
+ throw new Error(`"${sanitizeForDisplay(name)}" is not a valid extension id (must match ${EXTENSION_ID_PATTERN})`);
228
+ }
229
+ if (!OWNER_PATTERN.test(owner)) {
230
+ throw new Error(`"${sanitizeForDisplay(owner)}" is not a valid GitHub owner`);
231
+ }
232
+ return { kind: 'git', location: conventionUrl(owner, name), id: name };
233
+ }
234
+
235
+ /** FR-28: the target id is resolved from the SOURCE's own name — never read
236
+ * from the manifest. */
237
+ function idFromSource(source) {
238
+ const stripped = source.replace(/[/\\]+$/, '').replace(/\.git$/, '');
239
+ const last = stripped.split(/[/\\]/).pop() || '';
240
+ return last.toLowerCase();
241
+ }
242
+
243
+ /**
244
+ * The full read: distinguishes "too large" (`tooLarge: true`, `manifest:
245
+ * null`) from "missing or unparseable" (`tooLarge: false`, `manifest: null`)
246
+ * so a caller building an error message can name the actual cause instead of
247
+ * collapsing both into the same "missing or is not valid JSON" text.
248
+ */
249
+ function statManifest(dir) {
250
+ const manifestPath = path.join(dir, 'extension.json');
251
+ let stat;
252
+ try {
253
+ stat = fs.statSync(manifestPath);
254
+ } catch {
255
+ return { manifest: null, tooLarge: false };
256
+ }
257
+ if (stat.size > MANIFEST_MAX_BYTES) return { manifest: null, tooLarge: true };
258
+ try {
259
+ return { manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')), tooLarge: false };
260
+ } catch {
261
+ return { manifest: null, tooLarge: false };
262
+ }
263
+ }
264
+
265
+ function readManifest(dir) {
266
+ return statManifest(dir).manifest;
267
+ }
268
+
269
+ /** The size-specific vs. generic message a caller reports for a manifest that
270
+ * failed to load — shared by `assertValidManifestOrCleanup` and the local-copy
271
+ * install path so both name the same cause the same way. */
272
+ function manifestLoadErrorMessage(dir, status) {
273
+ const manifestPath = path.join(dir, 'extension.json');
274
+ return status.tooLarge
275
+ ? `${manifestPath} exceeds the ${MANIFEST_MAX_BYTES} byte limit`
276
+ : `${manifestPath} is missing or is not valid JSON`;
277
+ }
278
+
279
+ /** FR-27: id, label, path, enabled/disabled — read straight off the
280
+ * directory, so this works with the app closed. */
281
+ function listExtensions(opts = {}) {
282
+ const dir = extensionsDir(opts.home);
283
+ let entries;
284
+ try {
285
+ entries = fs.readdirSync(dir, { withFileTypes: true });
286
+ } catch {
287
+ return [];
288
+ }
289
+ const toggles = readToggles(opts);
290
+ return entries
291
+ .filter((e) => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'extension.json')))
292
+ .map((e) => e.name)
293
+ .sort()
294
+ .map((id) => {
295
+ const manifest = readManifest(path.join(dir, id));
296
+ const entry = toggles[id];
297
+ const enabled = Boolean(entry && entry.enabled === true);
298
+ // The manifest is untrusted, disk-supplied text — and so is `id`
299
+ // itself, a directory name that need not have gone through
300
+ // `francois ext install`'s EXTENSION_ID_PATTERN check (a hand-copied
301
+ // directory reaches here too). Sanitize both before they ever reach
302
+ // stdout (`francois ext list`) or any other consumer.
303
+ const dirPath = path.join(dir, id);
304
+ return {
305
+ id: sanitizeForDisplay(id),
306
+ label: sanitizeForDisplay((manifest && typeof manifest.label === 'string' && manifest.label) || id),
307
+ path: sanitizeForDisplay(dirPath),
308
+ enabled,
309
+ valid: Boolean(manifest),
310
+ };
311
+ });
312
+ }
313
+
314
+ function copyDirSync(src, dest) {
315
+ fs.mkdirSync(dest, { recursive: true });
316
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
317
+ // A cloned/copied plugin's own VCS history is not part of the plugin.
318
+ if (entry.name === '.git') continue;
319
+ const from = path.join(src, entry.name);
320
+ const to = path.join(dest, entry.name);
321
+ // A symlink in the source tree must never be dereferenced: copying its
322
+ // TARGET's content would silently snapshot an arbitrary file the user
323
+ // can read into the extensions registry. Recreate the link itself.
324
+ if (entry.isSymbolicLink()) {
325
+ fs.symlinkSync(fs.readlinkSync(from), to);
326
+ } else if (entry.isDirectory()) {
327
+ copyDirSync(from, to);
328
+ } else {
329
+ fs.copyFileSync(from, to);
330
+ }
331
+ }
332
+ }
333
+
334
+ function assertValidManifestOrCleanup(dir) {
335
+ const status = statManifest(dir);
336
+ if (!status.manifest) {
337
+ fs.rmSync(dir, { recursive: true, force: true });
338
+ throw new Error(manifestLoadErrorMessage(dir, status));
339
+ }
340
+ const manifest = status.manifest;
341
+ try {
342
+ assertNoForbiddenArgv0(manifest);
343
+ } catch (error) {
344
+ fs.rmSync(dir, { recursive: true, force: true });
345
+ throw error;
346
+ }
347
+ return manifest;
348
+ }
349
+
350
+ /** FR-28/FR-31: copy a local directory, or clone a git URL, into
351
+ * `~/.francois/extensions/<id>/`. Refuses to overwrite an existing directory
352
+ * unless `force`; validates the manifest before returning; never enables
353
+ * anything (FR-30 — consent is the app's, and only the app's). */
354
+ function installExtension(source, { home = os.homedir(), force = false, cwd = process.cwd() } = {}) {
355
+ const resolved = resolveInstallSource(source, { cwd });
356
+ const dir = extensionsDir(home);
357
+ fs.mkdirSync(dir, { recursive: true });
358
+
359
+ if (resolved.kind === 'git') {
360
+ const { id, location } = resolved;
361
+ const target = path.join(dir, id);
362
+ const targetExists = fs.existsSync(target);
363
+ if (targetExists && !force) throw new Error(`${target} already exists — pass --force to overwrite.`);
364
+
365
+ // FR-28: an overwrite must validate the NEW clone before the OLD install is
366
+ // touched — clone into a scratch dir under the registry root, validate
367
+ // there, and only then remove `target` and swap the scratch dir into place.
368
+ // Without this, an invalid update would destroy a working install.
369
+ const scratch = targetExists ? fs.mkdtempSync(path.join(dir, `.${id}-install-`)) : null;
370
+ const cloneTarget = scratch ?? target;
371
+ const result = spawnSync('git', ['clone', '--depth', '1', '--', location, cloneTarget], {
372
+ stdio: 'inherit',
373
+ });
374
+ if (result.error || result.status !== 0) {
375
+ fs.rmSync(cloneTarget, { recursive: true, force: true });
376
+ throw new Error(`git clone failed${result.status != null ? ` (exit ${result.status})` : ''}`);
377
+ }
378
+ // `assertValidManifestOrCleanup` removes `cloneTarget` itself on failure —
379
+ // `target` (the pre-existing install) is never touched until this returns.
380
+ const manifest = assertValidManifestOrCleanup(cloneTarget);
381
+ if (scratch) {
382
+ fs.rmSync(target, { recursive: true, force: true });
383
+ fs.renameSync(scratch, target);
384
+ }
385
+ return { id, path: target, manifest, source: location };
386
+ }
387
+
388
+ const { id, location } = resolved;
389
+ if (!isValidExtensionId(id)) {
390
+ throw new Error(`"${sanitizeForDisplay(id)}" is not a valid extension id (must match ${EXTENSION_ID_PATTERN})`);
391
+ }
392
+ // FR-28: validated BEFORE anything is written.
393
+ const sourceStatus = statManifest(location);
394
+ if (!sourceStatus.manifest) {
395
+ throw new Error(manifestLoadErrorMessage(location, sourceStatus));
396
+ }
397
+ const sourceManifest = sourceStatus.manifest;
398
+ assertNoForbiddenArgv0(sourceManifest);
399
+ const target = path.join(dir, id);
400
+ if (fs.existsSync(target)) {
401
+ if (!force) throw new Error(`${target} already exists — pass --force to overwrite.`);
402
+ fs.rmSync(target, { recursive: true, force: true });
403
+ }
404
+ copyDirSync(location, target);
405
+ return { id, path: target, manifest: sourceManifest, source: location };
406
+ }
407
+
408
+ /** FR-29: refuses any path that does not resolve under
409
+ * `~/.francois/extensions/` — defense in depth alongside `EXTENSION_ID_PATTERN`
410
+ * already ruling out `/` and `..` in `id`. */
411
+ function resolveExtensionDir(id, home = os.homedir()) {
412
+ if (!isValidExtensionId(id)) {
413
+ throw new Error(`"${sanitizeForDisplay(id)}" is not a valid extension id`);
414
+ }
415
+ const dir = path.resolve(extensionsDir(home));
416
+ const target = path.resolve(dir, id);
417
+ if (target !== dir && !target.startsWith(dir + path.sep)) {
418
+ throw new Error(`refusing to remove a path outside ${dir}`);
419
+ }
420
+ return target;
421
+ }
422
+
423
+ /** FR-29: its toggle/consent record is dropped with it — nothing else needs
424
+ * to act, since a missing directory is what the core's own FR-19 reconciles
425
+ * on next load. */
426
+ function removeExtension(id, { home = os.homedir() } = {}) {
427
+ const target = resolveExtensionDir(id, home);
428
+ if (!fs.existsSync(target)) {
429
+ throw new Error(`${target} does not exist`);
430
+ }
431
+ fs.rmSync(target, { recursive: true, force: true });
432
+ return target;
433
+ }
434
+
435
+ module.exports = {
436
+ EXTENSION_ID_PATTERN,
437
+ MANIFEST_MAX_BYTES,
438
+ ARGV0_PATTERN,
439
+ SHELL_ARGV0_BLOCKLIST,
440
+ assertNoForbiddenArgv0,
441
+ sanitizeForDisplay,
442
+ APP_IDENTIFIER,
443
+ extensionsDir,
444
+ appDataDir,
445
+ readToggles,
446
+ isValidExtensionId,
447
+ isGitUrl,
448
+ idFromSource,
449
+ readManifest,
450
+ listExtensions,
451
+ installExtension,
452
+ resolveInstallSource,
453
+ removeExtension,
454
+ resolveExtensionDir,
455
+ };
package/manifest.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "repo": "antoine-gmnz/francois",
3
- "tag": "v0.20.0",
3
+ "tag": "v0.20.1",
4
4
  "channel": "stable",
5
- "appVersion": "0.20.0",
5
+ "appVersion": "0.20.1",
6
6
  "productName": "Francois",
7
7
  "assets": {
8
- "darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "f71c4b04ed7074993b565f9f77201b4bb395fcd35d6b88defb4c96fb876cd9d3" },
9
- "linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "2f1a6a3a5e219c83372e4d93cab0e6a7d2dd629925fca164d01cab1fb09505d3" },
10
- "win32-x64": { "name": "francois-win32-x64.zip", "sha256": "0fff45de784cd74a3437e4d898c8b44d4b74492a22246ccae17e4e55b24a7679" }
8
+ "darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "79388fe04bff0085204b2d36fbd12ced04baaa2c1fff57f12713e4c555b4979e" },
9
+ "linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "969d1f46d003b192ceb6d997b33f629780fcda950231abc0a8bac18553bf34f4" },
10
+ "win32-x64": { "name": "francois-win32-x64.zip", "sha256": "4d9f43981929155675dcc82023e7c643d878eae0fb2bc70ecee8734a91e9df8d" }
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "francois",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "description": "Mission control for your Claude Code fleet — installs the Francois desktop app without an installer.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "author": "Antoine Gimenez",
@@ -29,8 +29,8 @@
29
29
  "files": [
30
30
  "assets/icon.png",
31
31
  "bin/",
32
- "lib/desktop.js",
33
- "lib/platform.js",
32
+ "lib/",
33
+ "!**/*.test.mjs",
34
34
  "install.js",
35
35
  "uninstall.js",
36
36
  "manifest.json",
Binary file