francois 0.18.13 → 0.19.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/bin/francois.js CHANGED
@@ -10,9 +10,11 @@
10
10
  */
11
11
 
12
12
  const { spawn, spawnSync } = require('node:child_process');
13
+ const fs = require('node:fs');
13
14
  const path = require('node:path');
14
15
 
15
16
  const desktop = require('../lib/desktop.js');
17
+ const extensions = require('../lib/extensions.js');
16
18
  const {
17
19
  assetKey,
18
20
  readInstallRecord,
@@ -30,11 +32,118 @@ Usage
30
32
  francois shortcut re-register the Start Menu / Launchpad / menu entry
31
33
  francois shortcut --remove unregister it
32
34
  francois uninstall unregister the desktop entry, then npm-uninstall this package
35
+ francois ext install <name> install a plugin by name, local path, or git URL
36
+ francois ext list list installed plugins (works with the app closed)
37
+ francois ext remove <id> delete an installed plugin
33
38
  francois --help this message
34
39
 
35
40
  Anything else is forwarded to the app.
36
41
  `;
37
42
 
43
+ const EXT_USAGE = `francois ext — manage plugins in ~/.francois/extensions (filesystem only,
44
+ never the app's socket — this works with the app closed).
45
+
46
+ Usage
47
+ francois ext install <name|path|git-url> [--force]
48
+ francois ext list
49
+ francois ext remove <id> [--yes]
50
+
51
+ A bare <name> resolves by CONVENTION to the repository
52
+ \`github.com/antoine-gmnz/francois-plugin-<name>\`; \`<owner>/<name>\` uses that
53
+ owner instead. There is no index and nothing to search — the convention is a
54
+ URL shorthand, so any plugin is still installable by its full URL. An existing
55
+ local directory of that name always wins, so nothing reaches the network when
56
+ a local answer exists.
57
+
58
+ Installing never enables a plugin — review and enable it in Extensions (⌘K).
59
+ `;
60
+
61
+ /** A single blocking line read off stdin — no dependency, works in a real
62
+ * terminal and under a piped `echo y |` in CI alike. */
63
+ function confirmSync(question) {
64
+ process.stdout.write(question);
65
+ const buf = Buffer.alloc(4096);
66
+ let bytesRead = 0;
67
+ try {
68
+ bytesRead = fs.readSync(0, buf, 0, buf.length, null);
69
+ } catch {
70
+ return false;
71
+ }
72
+ const answer = buf.toString('utf8', 0, bytesRead).trim().toLowerCase();
73
+ return answer === 'y' || answer === 'yes';
74
+ }
75
+
76
+ function runExt(args) {
77
+ const sub = args[0];
78
+ if (!sub || sub === '--help' || sub === '-h') {
79
+ process.stdout.write(EXT_USAGE);
80
+ return;
81
+ }
82
+
83
+ if (sub === 'install') {
84
+ const force = args.includes('--force');
85
+ const source = args.slice(1).find((a) => !a.startsWith('--'));
86
+ if (!source) die('francois ext install <name|path|git-url>');
87
+ let result;
88
+ try {
89
+ result = extensions.installExtension(source, { force });
90
+ } catch (error) {
91
+ die(error.message);
92
+ }
93
+ // The resolved source is printed because a bare name is ambiguous by
94
+ // design: the user must be able to see whether it copied a directory or
95
+ // cloned a repository, without re-deriving the convention in their head.
96
+ // `result.source`/`result.id`/`result.path` are all untrusted (attacker-
97
+ // controlled path/URL text) — sanitize before they ever reach stdout, the
98
+ // same as the `list` path.
99
+ process.stdout.write(
100
+ `francois: installed "${extensions.sanitizeForDisplay(result.id)}" from ${extensions.sanitizeForDisplay(result.source)}\n`
101
+ );
102
+ process.stdout.write(`francois: at ${extensions.sanitizeForDisplay(result.path)}\n`);
103
+ // FR-15/FR-28: discovery is not authorization — it always arrives inert.
104
+ process.stdout.write('francois: disabled — enable it in Extensions (⌘K)\n');
105
+ return;
106
+ }
107
+
108
+ if (sub === 'list') {
109
+ const found = extensions.listExtensions();
110
+ if (found.length === 0) {
111
+ process.stdout.write(`francois: nothing installed yet — see ${extensions.extensionsDir()}\n`);
112
+ return;
113
+ }
114
+ for (const ext of found) {
115
+ const status = !ext.valid ? 'invalid manifest' : ext.enabled ? 'enabled' : 'disabled';
116
+ process.stdout.write(`${ext.id}\t${ext.label}\t${ext.path}\t${status}\n`);
117
+ }
118
+ return;
119
+ }
120
+
121
+ if (sub === 'remove') {
122
+ const id = args[1];
123
+ if (!id || id.startsWith('--')) die('francois ext remove <id>');
124
+ let target;
125
+ try {
126
+ target = extensions.resolveExtensionDir(id);
127
+ } catch (error) {
128
+ die(error.message);
129
+ }
130
+ process.stdout.write(`francois: about to delete ${target}\n`);
131
+ if (!args.includes('--yes') && !confirmSync('remove this extension? [y/N] ')) {
132
+ process.stdout.write('francois: cancelled.\n');
133
+ return;
134
+ }
135
+ try {
136
+ extensions.removeExtension(id);
137
+ } catch (error) {
138
+ die(error.message);
139
+ }
140
+ process.stdout.write(`francois: removed "${id}".\n`);
141
+ return;
142
+ }
143
+
144
+ die(`unknown "francois ext ${sub}" — see \`francois ext --help\`.`);
145
+ }
146
+
38
147
  function die(message) {
39
148
  process.stderr.write(`\nfrancois: ${message}\n\n`);
40
149
  process.exit(1);
@@ -79,6 +188,13 @@ function main() {
79
188
  return;
80
189
  }
81
190
 
191
+ // extension-install FR-27: filesystem verbs, off the socket — work with the
192
+ // app closed, and never reach the app's dispatch below.
193
+ if (argv[0] === 'ext') {
194
+ runExt(argv.slice(1));
195
+ return;
196
+ }
197
+
82
198
  // Re-runnable on demand: a shortcut can be deleted, or the postinstall can have
83
199
  // run somewhere the desktop wasn't reachable (SSH, a container, a CI image).
84
200
  if (argv[0] === 'shortcut') {
Binary file
package/manifest.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "repo": "antoine-gmnz/francois",
3
- "tag": "v0.18.13",
3
+ "tag": "v0.19.0",
4
4
  "channel": "stable",
5
- "appVersion": "0.18.13",
5
+ "appVersion": "0.19.0",
6
6
  "productName": "Francois",
7
7
  "assets": {
8
- "darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "d7fe763c2ba6130611de9249af49d06f89e3d0e75170f515886af39245a3d1cf" },
9
- "linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "3275ac629e4427ad34470cadcb92ec140267ecabf20275c8922175dc577515e1" },
10
- "win32-x64": { "name": "francois-win32-x64.zip", "sha256": "b6b2db077be05e9f343142c2763c933908f478b742faacbbe74b24d2a738d362" }
8
+ "darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "ab291ffde31a9b86790d6d478be32081ef40cdafd1e74d8234f5431b36726cf3" },
9
+ "linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "f991296c452aae7a3217be955845b15f6350ee3734b8d53cb8e2d2d2045b3c4c" },
10
+ "win32-x64": { "name": "francois-win32-x64.zip", "sha256": "45b6b69bcd720a284c167c4341546b31edc0cd22563e3110a16057a4e2ed3af0" }
11
11
  }
12
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "francois",
3
- "version": "0.18.13",
3
+ "version": "0.19.0",
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",