francois 0.12.0-dev.30

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 ADDED
@@ -0,0 +1,63 @@
1
+ # francois
2
+
3
+ **Mission control for your Claude Code fleet** — installed without an installer.
4
+
5
+ ```sh
6
+ npm i -g francois # stable
7
+ npm i -g francois@dev # rolling build of main
8
+ ```
9
+
10
+ Then launch it **from the Start Menu, Launchpad or your applications menu** like
11
+ any other app — or type `francois` in a terminal, whichever you prefer.
12
+
13
+ This package ships no binaries. Its postinstall downloads the platform build from
14
+ the matching [GitHub release](https://github.com/antoine-gmnz/francois/releases),
15
+ verifies it against a sha256 digest baked in at publish time, and registers it
16
+ with your desktop.
17
+
18
+ ## What gets installed where
19
+
20
+ | | |
21
+ |---|---|
22
+ | **Windows** | a Start Menu shortcut, plus an entry in Settings → Installed apps |
23
+ | **macOS** | the app bundle in `~/Applications` — Spotlight, Launchpad and the Dock all find it |
24
+ | **Linux** | a `.desktop` launcher and icon under `~/.local/share` |
25
+
26
+ All per-user: no administrator rights, no elevation prompt. `npm uninstall -g
27
+ francois` removes them again, and `francois shortcut --remove` is the manual
28
+ escape hatch.
29
+
30
+ ## Why install this way
31
+
32
+ Windows SmartScreen and macOS Gatekeeper key off the Mark-of-the-Web /
33
+ `com.apple.quarantine` attribute that a **browser** attaches at download time.
34
+ Binaries fetched by a CLI never carry one — so the same unsigned build that gets
35
+ blocked when downloaded as a `.dmg` or `.exe` launches clean from here. No
36
+ certificate, no *More info → Run anyway*, no right-click → Open.
37
+
38
+ ## Requirements
39
+
40
+ - Node 18+
41
+ - [Claude Code](https://claude.com/claude-code) on your `PATH`, authenticated — Francois spawns `claude` per session
42
+ - `git` on your `PATH` — powers the DIFF tab
43
+ - Windows only: the [WebView2 runtime](https://developer.microsoft.com/microsoft-edge/webview2/) (preinstalled on Windows 11 and current Windows 10)
44
+
45
+ Prebuilt for macOS (universal), Windows x64 and Linux x64. On anything else,
46
+ [build from source](https://github.com/antoine-gmnz/francois#build-from-source).
47
+
48
+ ## Usage
49
+
50
+ | Command | Effect |
51
+ |---|---|
52
+ | `francois` | launch the app and return to the shell |
53
+ | `francois --attach` | launch it in the foreground with its output attached |
54
+ | `francois --version` | print the app + package versions |
55
+ | `francois shortcut` | re-register the desktop entry (after deleting it, or a headless install) |
56
+ | `francois shortcut --remove` | unregister it |
57
+ | `francois --help` | usage |
58
+
59
+ Environment: `FRANCOIS_SKIP_DOWNLOAD=1` skips the postinstall download,
60
+ `FRANCOIS_DOWNLOAD_BASE` overrides the release host (mirrors, air-gapped setups).
61
+
62
+ [Full documentation](https://github.com/antoine-gmnz/francois) ·
63
+ [AGPL-3.0](https://github.com/antoine-gmnz/francois/blob/main/LICENSE)
Binary file
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * `francois` — launch the desktop app that install.js unpacked into vendor/.
6
+ *
7
+ * Unrecognised arguments are forwarded to the app binary, so this stays
8
+ * forward-compatible with the read-only CLI companion (specs/cli-companion.md)
9
+ * once that ships.
10
+ */
11
+
12
+ const { spawn, spawnSync } = require('node:child_process');
13
+ const path = require('node:path');
14
+
15
+ const desktop = require('../lib/desktop.js');
16
+ const {
17
+ assetKey,
18
+ readInstallRecord,
19
+ readManifest,
20
+ resolveExecutable,
21
+ supportedList,
22
+ } = require('../lib/platform.js');
23
+
24
+ const USAGE = `francois — mission control for your Claude Code fleet
25
+
26
+ Usage
27
+ francois launch the app (returns to the shell immediately)
28
+ francois --attach launch it in the foreground, keeping its output attached
29
+ francois --version print the app + package versions
30
+ francois shortcut re-register the Start Menu / Launchpad / menu entry
31
+ francois shortcut --remove unregister it
32
+ francois --help this message
33
+
34
+ Anything else is forwarded to the app.
35
+ `;
36
+
37
+ function die(message) {
38
+ process.stderr.write(`\nfrancois: ${message}\n\n`);
39
+ process.exit(1);
40
+ }
41
+
42
+ /**
43
+ * Tauri renders through the system webview, which on Windows means the WebView2
44
+ * runtime. It ships with Windows 11 and current Windows 10, but a bare machine
45
+ * can be missing it — and the app then fails with nothing useful on screen.
46
+ * A warning only: a false negative here must not block a working launch.
47
+ */
48
+ function warnIfWebView2Missing() {
49
+ if (process.platform !== 'win32') return;
50
+ const guid = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
51
+ const roots = [
52
+ `HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${guid}`,
53
+ `HKCU\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${guid}`,
54
+ ];
55
+ const found = roots.some(
56
+ (key) => spawnSync('reg', ['query', key, '/v', 'pv'], { stdio: 'ignore' }).status === 0,
57
+ );
58
+ if (!found) {
59
+ process.stderr.write(
60
+ 'francois: the Microsoft Edge WebView2 runtime was not found — the window may fail to open.\n' +
61
+ ' Install it from https://developer.microsoft.com/microsoft-edge/webview2/\n',
62
+ );
63
+ }
64
+ }
65
+
66
+ function main() {
67
+ const argv = process.argv.slice(2);
68
+
69
+ if (argv[0] === '--help' || argv[0] === '-h') {
70
+ process.stdout.write(USAGE);
71
+ return;
72
+ }
73
+
74
+ if (argv[0] === '--version' || argv[0] === '-v') {
75
+ const manifest = readManifest();
76
+ const pkg = require('../package.json');
77
+ process.stdout.write(`francois ${manifest ? manifest.appVersion : 'unknown'} (npm ${pkg.version})\n`);
78
+ return;
79
+ }
80
+
81
+ // Re-runnable on demand: a shortcut can be deleted, or the postinstall can have
82
+ // run somewhere the desktop wasn't reachable (SSH, a container, a CI image).
83
+ if (argv[0] === 'shortcut') {
84
+ const record = readInstallRecord();
85
+ if (!record) die('nothing is installed yet — run `npm i -g francois` first.');
86
+
87
+ if (argv.includes('--remove')) {
88
+ desktop.remove(record);
89
+ process.stdout.write(`francois: removed the ${record.productName} shortcut.\n`);
90
+ return;
91
+ }
92
+
93
+ const { notes } = desktop.install(record);
94
+ for (const note of notes) process.stdout.write(`francois: ${note}\n`);
95
+ return;
96
+ }
97
+
98
+ const executable = resolveExecutable();
99
+ if (!executable) {
100
+ if (!assetKey()) {
101
+ die(
102
+ `no prebuilt app for ${process.platform}/${process.arch} (published: ${supportedList()}).\n` +
103
+ 'Build from source: https://github.com/antoine-gmnz/francois#build-from-source',
104
+ );
105
+ }
106
+ die('the app payload is missing — reinstall with `npm i -g francois`.');
107
+ }
108
+
109
+ warnIfWebView2Missing();
110
+
111
+ const attach = argv[0] === '--attach';
112
+ const forwarded = attach ? argv.slice(1) : argv;
113
+
114
+ if (attach) {
115
+ const result = spawnSync(executable, forwarded, { stdio: 'inherit' });
116
+ process.exit(result.status === null ? 1 : result.status);
117
+ }
118
+
119
+ const child = spawn(executable, forwarded, {
120
+ detached: true,
121
+ stdio: 'ignore',
122
+ cwd: process.cwd(),
123
+ });
124
+ child.on('error', (error) => die(`could not launch ${path.basename(executable)}: ${error.message}`));
125
+ child.unref();
126
+ }
127
+
128
+ main();
package/install.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * postinstall — fetch this platform's Francois build from its GitHub release and
6
+ * unpack it into vendor/.
7
+ *
8
+ * Why this package exists at all: Windows SmartScreen and macOS Gatekeeper key
9
+ * off the Mark-of-the-Web / com.apple.quarantine attribute that a *browser*
10
+ * attaches at download time. A binary fetched by npm never carries one, so the
11
+ * same unsigned build that gets blocked when downloaded as a .dmg or .exe
12
+ * launches clean from here — no code-signing certificate involved.
13
+ *
14
+ * Dependency-free by necessity: this runs during `npm install`, so nothing but
15
+ * Node's standard library is available.
16
+ */
17
+
18
+ const crypto = require('node:crypto');
19
+ const fs = require('node:fs');
20
+ const http = require('node:http');
21
+ const https = require('node:https');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+ const { spawnSync } = require('node:child_process');
25
+
26
+ const desktop = require('./lib/desktop.js');
27
+ const {
28
+ VENDOR_DIR,
29
+ assetKey,
30
+ readManifest,
31
+ resolveExecutable,
32
+ supportedList,
33
+ writeInstallRecord,
34
+ } = require('./lib/platform.js');
35
+
36
+ /**
37
+ * The .app bundle an executable lives in (…/Francois.app/Contents/MacOS/francois
38
+ * → …/Francois.app), or null on platforms that ship a bare binary.
39
+ */
40
+ function bundleOf(executable) {
41
+ const marker = `${path.sep}Contents${path.sep}MacOS${path.sep}`;
42
+ const at = executable.indexOf(marker);
43
+ return at === -1 ? null : executable.slice(0, at);
44
+ }
45
+
46
+ const DOWNLOAD_BASE = process.env.FRANCOIS_DOWNLOAD_BASE || 'https://github.com';
47
+ const MAX_ATTEMPTS = 3;
48
+ const REQUEST_TIMEOUT_MS = 60_000;
49
+
50
+ function log(message) {
51
+ process.stdout.write(`francois: ${message}\n`);
52
+ }
53
+
54
+ function fail(message) {
55
+ process.stderr.write(`\nfrancois: ${message}\n\n`);
56
+ process.exit(1);
57
+ }
58
+
59
+ /**
60
+ * GitHub redirects release downloads to a CDN, so redirects must be followed.
61
+ * http is honoured alongside https only so that a self-hosted
62
+ * FRANCOIS_DOWNLOAD_BASE works; the default base is https.
63
+ */
64
+ function download(url, dest, redirectsLeft = 5) {
65
+ return new Promise((resolve, reject) => {
66
+ const client = url.startsWith('http://') ? http : https;
67
+ const request = client.get(url, { headers: { 'user-agent': 'francois-npm-installer' } }, (res) => {
68
+ const { statusCode, headers } = res;
69
+
70
+ if (statusCode >= 300 && statusCode < 400 && headers.location) {
71
+ res.resume();
72
+ if (redirectsLeft === 0) return reject(new Error('too many redirects'));
73
+ return resolve(download(new URL(headers.location, url).toString(), dest, redirectsLeft - 1));
74
+ }
75
+
76
+ if (statusCode !== 200) {
77
+ res.resume();
78
+ return reject(new Error(`HTTP ${statusCode} for ${url}`));
79
+ }
80
+
81
+ const file = fs.createWriteStream(dest);
82
+ res.pipe(file);
83
+ file.on('error', reject);
84
+ file.on('finish', () => file.close(() => resolve()));
85
+ });
86
+
87
+ request.setTimeout(REQUEST_TIMEOUT_MS, () => request.destroy(new Error('download timed out')));
88
+ request.on('error', reject);
89
+ });
90
+ }
91
+
92
+ async function downloadWithRetries(url, dest) {
93
+ for (let attempt = 1; ; attempt++) {
94
+ try {
95
+ await download(url, dest);
96
+ return;
97
+ } catch (error) {
98
+ fs.rmSync(dest, { force: true });
99
+ if (attempt === MAX_ATTEMPTS) throw error;
100
+ log(`download failed (${error.message}) — retrying ${attempt}/${MAX_ATTEMPTS - 1}`);
101
+ await new Promise((r) => setTimeout(r, attempt * 1000));
102
+ }
103
+ }
104
+ }
105
+
106
+ function sha256(file) {
107
+ return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
108
+ }
109
+
110
+ function run(command, args) {
111
+ const result = spawnSync(command, args, { stdio: 'ignore' });
112
+ if (result.error) throw result.error;
113
+ if (result.status !== 0) throw new Error(`${command} exited ${result.status}`);
114
+ }
115
+
116
+ /**
117
+ * .tar.gz everywhere except Windows, which gets a .zip. Windows 10 1803+ ships
118
+ * bsdtar as tar.exe (it reads zip too); Expand-Archive covers anything older.
119
+ */
120
+ function extract(archive, dest) {
121
+ if (archive.endsWith('.zip')) {
122
+ try {
123
+ run('tar', ['-xf', archive, '-C', dest]);
124
+ } catch {
125
+ run('powershell', [
126
+ '-NoProfile',
127
+ '-NonInteractive',
128
+ '-Command',
129
+ `Expand-Archive -LiteralPath '${archive}' -DestinationPath '${dest}' -Force`,
130
+ ]);
131
+ }
132
+ } else {
133
+ // System tar on macOS/Linux preserves the bundle's symlinks and exec bits.
134
+ run('tar', ['-xzf', archive, '-C', dest]);
135
+ }
136
+ }
137
+
138
+ async function main() {
139
+ if (process.env.FRANCOIS_SKIP_DOWNLOAD) {
140
+ log('FRANCOIS_SKIP_DOWNLOAD set — skipping the app download.');
141
+ return;
142
+ }
143
+
144
+ const key = assetKey();
145
+ if (!key) {
146
+ fail(
147
+ `no prebuilt app for ${process.platform}/${process.arch}.\n` +
148
+ `Published builds: ${supportedList()}.\n` +
149
+ 'Build from source instead: https://github.com/antoine-gmnz/francois#build-from-source',
150
+ );
151
+ }
152
+
153
+ const manifest = readManifest();
154
+ if (!manifest || !manifest.assets || !manifest.assets[key]) {
155
+ fail(
156
+ 'this package has no release manifest, so there is nothing to download.\n' +
157
+ 'That means it was not built by CI — install the published package with `npm i -g francois`.',
158
+ );
159
+ }
160
+
161
+ const asset = manifest.assets[key];
162
+ const url = `${DOWNLOAD_BASE}/${manifest.repo}/releases/download/${manifest.tag}/${asset.name}`;
163
+ const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'francois-install-'));
164
+ const archive = path.join(staging, asset.name);
165
+ // On the exit hook rather than in a finally, so that fail()'s process.exit()
166
+ // — which skips finally blocks — still clears the staging directory.
167
+ process.on('exit', () => fs.rmSync(staging, { recursive: true, force: true }));
168
+
169
+ try {
170
+ log(`downloading Francois ${manifest.appVersion} (${key})…`);
171
+ await downloadWithRetries(url, archive);
172
+
173
+ const actual = sha256(archive);
174
+ if (actual !== asset.sha256) {
175
+ fail(
176
+ `checksum mismatch for ${asset.name}.\n` +
177
+ ` expected ${asset.sha256}\n actual ${actual}\n` +
178
+ (manifest.channel === 'dev'
179
+ ? 'The rolling `dev` release is replaced on every push to main, so an older\n' +
180
+ 'dev package points at binaries that no longer exist. Install the current\n' +
181
+ 'one: npm i -g francois@dev'
182
+ : 'Refusing to install a binary that does not match the published release.'),
183
+ );
184
+ }
185
+
186
+ fs.rmSync(VENDOR_DIR, { recursive: true, force: true });
187
+ fs.mkdirSync(VENDOR_DIR, { recursive: true });
188
+ extract(archive, VENDOR_DIR);
189
+
190
+ if (process.platform === 'darwin') {
191
+ // Belt and braces: npm downloads never carry com.apple.quarantine, but a
192
+ // user-supplied FRANCOIS_DOWNLOAD_BASE or a proxy could. Best-effort.
193
+ spawnSync('xattr', ['-dr', 'com.apple.quarantine', VENDOR_DIR], { stdio: 'ignore' });
194
+ }
195
+
196
+ const unpacked = resolveExecutable();
197
+ if (!unpacked) fail(`the ${asset.name} archive did not contain the app.`);
198
+
199
+ // Register with the OS so Francois launches from the Start Menu / Launchpad /
200
+ // Applications menu like any installed app, not just from a terminal. This
201
+ // can relocate the payload (macOS moves the bundle to ~/Applications), so
202
+ // the returned paths — not the ones passed in — are what gets recorded.
203
+ const productName = manifest.productName || 'Francois';
204
+ const integration = desktop.install({
205
+ executable: unpacked,
206
+ bundle: bundleOf(unpacked),
207
+ productName,
208
+ channel: manifest.channel,
209
+ appVersion: manifest.appVersion,
210
+ });
211
+
212
+ writeInstallRecord({
213
+ executable: integration.executable,
214
+ bundle: integration.bundle,
215
+ productName,
216
+ channel: manifest.channel,
217
+ appVersion: manifest.appVersion,
218
+ tag: manifest.tag,
219
+ });
220
+
221
+ log('installed.');
222
+ for (const note of integration.notes) log(` ${note}`);
223
+ log(' terminal: francois');
224
+ } catch (error) {
225
+ fail(`install failed: ${error.message}`);
226
+ }
227
+ }
228
+
229
+ main();
package/lib/desktop.js ADDED
@@ -0,0 +1,254 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Desktop integration: make an npm-installed Francois behave like an app you
5
+ * installed, not like a CLI you have to remember the name of.
6
+ *
7
+ * npm delivers the bytes (which is what dodges SmartScreen and Gatekeeper —
8
+ * see install.js) but it only ever puts a command on PATH. Everything the OS
9
+ * needs to show the app in its own launchers is written here, per-user, with no
10
+ * elevation and no signature:
11
+ *
12
+ * Windows Start Menu .lnk + an HKCU uninstall entry (Settings > Installed apps)
13
+ * macOS the .app lives in ~/Applications, so Spotlight/Launchpad/Dock see it
14
+ * Linux a .desktop file + a hicolor icon in ~/.local/share
15
+ *
16
+ * Every step is best-effort: a machine with an unusual shell, a locked-down
17
+ * registry or no desktop environment at all must still end up with a working
18
+ * `francois` command. Failures are reported, never thrown.
19
+ */
20
+
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+ const { spawnSync } = require('node:child_process');
25
+
26
+ const ICON_SOURCE = path.join(__dirname, '..', 'assets', 'icon.png');
27
+
28
+ /**
29
+ * Stable per-channel slug. The dev channel is a separate app with its own data
30
+ * dir, so its shortcuts must not collide with a stable install's.
31
+ */
32
+ function appId(channel) {
33
+ return channel === 'dev' ? 'francois-dev' : 'francois';
34
+ }
35
+
36
+ function startMenuShortcut(productName, appData = process.env.APPDATA) {
37
+ if (!appData) return null;
38
+ return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${productName}.lnk`);
39
+ }
40
+
41
+ function uninstallRegistryKey(channel) {
42
+ return `HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${appId(channel)}`;
43
+ }
44
+
45
+ function desktopEntryPath(channel, home = os.homedir()) {
46
+ return path.join(home, '.local', 'share', 'applications', `${appId(channel)}.desktop`);
47
+ }
48
+
49
+ function desktopIconPath(channel, home = os.homedir()) {
50
+ return path.join(home, '.local', 'share', 'icons', 'hicolor', '128x128', 'apps', `${appId(channel)}.png`);
51
+ }
52
+
53
+ function applicationsDir(home = os.homedir()) {
54
+ return path.join(home, 'Applications');
55
+ }
56
+
57
+ /**
58
+ * A freedesktop.org .desktop entry. Exec is quoted because the AppImage sits
59
+ * under npm's global prefix, which on many systems contains spaces.
60
+ * StartupWMClass lets the shell group the running window under this launcher
61
+ * instead of showing a second, icon-less entry.
62
+ */
63
+ function desktopEntry({ productName, exec, icon }) {
64
+ return [
65
+ '[Desktop Entry]',
66
+ 'Type=Application',
67
+ `Name=${productName}`,
68
+ 'Comment=Mission control for your Claude Code fleet',
69
+ `Exec="${exec}" %U`,
70
+ `Icon=${icon}`,
71
+ 'Terminal=false',
72
+ 'Categories=Development;Utility;',
73
+ 'StartupWMClass=francois',
74
+ '',
75
+ ].join('\n');
76
+ }
77
+
78
+ /** Run a command purely for its side effect; never let a missing tool throw. */
79
+ function attempt(command, args) {
80
+ try {
81
+ return spawnSync(command, args, { stdio: 'ignore' }).status === 0;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ // ── Windows ─────────────────────────────────────────────────────────────────
88
+
89
+ function installWindows({ executable, productName, channel, appVersion, notes }) {
90
+ const shortcut = startMenuShortcut(productName);
91
+ if (!shortcut) {
92
+ notes.push('APPDATA is not set — skipped the Start Menu shortcut.');
93
+ return;
94
+ }
95
+
96
+ fs.mkdirSync(path.dirname(shortcut), { recursive: true });
97
+ // WScript.Shell is the only supported way to author a .lnk; there is no
98
+ // Node API for it and no plain-text equivalent of the format.
99
+ const ok = attempt('powershell', [
100
+ '-NoProfile',
101
+ '-NonInteractive',
102
+ '-Command',
103
+ [
104
+ '$s = (New-Object -ComObject WScript.Shell).CreateShortcut(' + psQuote(shortcut) + ');',
105
+ '$s.TargetPath = ' + psQuote(executable) + ';',
106
+ '$s.WorkingDirectory = ' + psQuote(path.dirname(executable)) + ';',
107
+ "$s.Description = 'Mission control for your Claude Code fleet';",
108
+ '$s.Save()',
109
+ ].join(' '),
110
+ ]);
111
+ notes.push(ok ? `Start Menu: ${productName}` : 'could not create the Start Menu shortcut.');
112
+
113
+ // Makes it appear in Settings > Installed apps like any other program. HKCU
114
+ // only, so this never needs elevation.
115
+ const key = uninstallRegistryKey(channel);
116
+ const values = [
117
+ ['DisplayName', 'REG_SZ', productName],
118
+ ['DisplayVersion', 'REG_SZ', appVersion],
119
+ ['Publisher', 'REG_SZ', 'Antoine Gimenez'],
120
+ ['DisplayIcon', 'REG_SZ', executable],
121
+ ['InstallLocation', 'REG_SZ', path.dirname(executable)],
122
+ ['UninstallString', 'REG_SZ', 'cmd.exe /c npm uninstall -g francois'],
123
+ ['NoModify', 'REG_DWORD', '1'],
124
+ ['NoRepair', 'REG_DWORD', '1'],
125
+ ];
126
+ const wrote = values.every(([name, type, data]) =>
127
+ attempt('reg', ['add', key, '/v', name, '/t', type, '/d', data, '/f']),
128
+ );
129
+ if (!wrote) notes.push('could not register the uninstall entry.');
130
+ }
131
+
132
+ function removeWindows({ productName, channel }) {
133
+ const shortcut = startMenuShortcut(productName);
134
+ if (shortcut) fs.rmSync(shortcut, { force: true });
135
+ attempt('reg', ['delete', uninstallRegistryKey(channel), '/f']);
136
+ }
137
+
138
+ /** Single-quoted PowerShell literal — no expansion, '' escapes a quote. */
139
+ function psQuote(value) {
140
+ return `'${String(value).replace(/'/g, "''")}'`;
141
+ }
142
+
143
+ // ── macOS ───────────────────────────────────────────────────────────────────
144
+
145
+ /**
146
+ * Move the bundle into ~/Applications. A .app buried in node_modules is invisible
147
+ * to Spotlight and Launchpad; in ~/Applications it is simply an installed Mac
148
+ * app. Returns the new bundle path.
149
+ */
150
+ function installMacos({ bundle, home, notes }) {
151
+ const target = path.join(applicationsDir(home), path.basename(bundle));
152
+ // Already in place — `francois shortcut` re-runs this, and blindly rm'ing the
153
+ // target would delete the very bundle we are about to move.
154
+ if (path.resolve(bundle) !== path.resolve(target)) {
155
+ fs.mkdirSync(applicationsDir(home), { recursive: true });
156
+ fs.rmSync(target, { recursive: true, force: true });
157
+ fs.renameSync(bundle, target);
158
+ }
159
+
160
+ // Nudge LaunchServices so Spotlight sees it now rather than whenever it next
161
+ // rescans. Absent or relocated on some systems, hence best-effort.
162
+ attempt(
163
+ '/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister',
164
+ ['-f', target],
165
+ );
166
+ notes.push(`Applications: ${path.basename(target)}`);
167
+ return target;
168
+ }
169
+
170
+ /** Scoped to ~/Applications so a hand-moved bundle elsewhere is never deleted. */
171
+ function removeMacos({ bundle, home }) {
172
+ if (bundle && path.resolve(bundle).startsWith(path.resolve(applicationsDir(home)))) {
173
+ fs.rmSync(bundle, { recursive: true, force: true });
174
+ }
175
+ }
176
+
177
+ // ── Linux ───────────────────────────────────────────────────────────────────
178
+
179
+ function installLinux({ executable, productName, channel, home, notes }) {
180
+ const icon = desktopIconPath(channel, home);
181
+ const entry = desktopEntryPath(channel, home);
182
+ fs.mkdirSync(path.dirname(icon), { recursive: true });
183
+ fs.mkdirSync(path.dirname(entry), { recursive: true });
184
+ fs.copyFileSync(ICON_SOURCE, icon);
185
+ fs.writeFileSync(entry, desktopEntry({ productName, exec: executable, icon }));
186
+ attempt('update-desktop-database', [path.dirname(entry)]);
187
+ notes.push(`Applications menu: ${productName}`);
188
+ }
189
+
190
+ function removeLinux({ channel, home }) {
191
+ const entry = desktopEntryPath(channel, home);
192
+ fs.rmSync(entry, { force: true });
193
+ fs.rmSync(desktopIconPath(channel, home), { force: true });
194
+ attempt('update-desktop-database', [path.dirname(entry)]);
195
+ }
196
+
197
+ // ── entry points ────────────────────────────────────────────────────────────
198
+
199
+ /**
200
+ * Register the app with the OS. Returns { executable, bundle, notes } — the
201
+ * paths may differ from what went in (macOS relocates the bundle), so the
202
+ * caller must persist what it gets back.
203
+ */
204
+ function install({
205
+ executable,
206
+ bundle,
207
+ productName,
208
+ channel,
209
+ appVersion,
210
+ platform = process.platform,
211
+ home = os.homedir(),
212
+ }) {
213
+ const notes = [];
214
+ try {
215
+ if (platform === 'win32') {
216
+ installWindows({ executable, productName, channel, appVersion, notes });
217
+ } else if (platform === 'darwin') {
218
+ const moved = installMacos({ bundle, home, notes });
219
+ // The bundle moved, so the executable path inside it moved with it.
220
+ executable = path.join(moved, path.relative(bundle, executable));
221
+ bundle = moved;
222
+ } else if (platform === 'linux') {
223
+ installLinux({ executable, productName, channel, home, notes });
224
+ }
225
+ } catch (error) {
226
+ notes.push(`desktop integration failed (${error.message}) — the \`francois\` command still works.`);
227
+ }
228
+ return { executable, bundle, notes };
229
+ }
230
+
231
+ /** Undo install(). Safe to call when nothing was ever registered. */
232
+ function remove({ bundle, productName, channel, platform = process.platform, home = os.homedir() }) {
233
+ try {
234
+ if (platform === 'win32') removeWindows({ productName, channel });
235
+ else if (platform === 'darwin') removeMacos({ bundle, home });
236
+ else if (platform === 'linux') removeLinux({ channel, home });
237
+ return true;
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
242
+
243
+ module.exports = {
244
+ ICON_SOURCE,
245
+ appId,
246
+ applicationsDir,
247
+ desktopEntry,
248
+ desktopEntryPath,
249
+ desktopIconPath,
250
+ install,
251
+ remove,
252
+ startMenuShortcut,
253
+ uninstallRegistryKey,
254
+ };
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Platform resolution shared by the postinstall (install.js) and the launcher
5
+ * (bin/francois.js): which release asset this machine needs, and where the
6
+ * unpacked executable ends up.
7
+ *
8
+ * Plain CommonJS with no dependencies — this runs during `npm install`, before
9
+ * anything else is guaranteed to exist.
10
+ */
11
+
12
+ const fs = require('node:fs');
13
+ const path = require('node:path');
14
+
15
+ /** `${process.platform}:${process.arch}` → release asset key. */
16
+ const SUPPORTED = {
17
+ // One universal .app covers both Macs, so both arches map to the same asset.
18
+ 'darwin:x64': 'darwin-universal',
19
+ 'darwin:arm64': 'darwin-universal',
20
+ 'win32:x64': 'win32-x64',
21
+ 'linux:x64': 'linux-x64',
22
+ };
23
+
24
+ const PACKAGE_ROOT = path.join(__dirname, '..');
25
+ const VENDOR_DIR = path.join(PACKAGE_ROOT, 'vendor');
26
+ const MANIFEST_PATH = path.join(PACKAGE_ROOT, 'manifest.json');
27
+ const INSTALL_RECORD = 'install.json';
28
+
29
+ /** The asset key for a platform/arch pair, or null when unsupported. */
30
+ function assetKey(platform = process.platform, arch = process.arch) {
31
+ return SUPPORTED[`${platform}:${arch}`] || null;
32
+ }
33
+
34
+ /** Human-readable list of what we do ship, for the unsupported-platform error. */
35
+ function supportedList() {
36
+ return [...new Set(Object.values(SUPPORTED))].sort().join(', ');
37
+ }
38
+
39
+ /**
40
+ * The manifest is written by CI at publish time (it pins the release tag and the
41
+ * per-asset sha256). A package built any other way won't have one.
42
+ */
43
+ function readManifest(manifestPath = MANIFEST_PATH) {
44
+ if (!fs.existsSync(manifestPath)) return null;
45
+ try {
46
+ return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /**
53
+ * What the postinstall actually did: where the payload ended up and what it
54
+ * registered with the OS. macOS moves the bundle out to ~/Applications, so this
55
+ * is the only reliable way to find it afterwards.
56
+ */
57
+ function readInstallRecord(vendorDir = VENDOR_DIR) {
58
+ try {
59
+ return JSON.parse(fs.readFileSync(path.join(vendorDir, INSTALL_RECORD), 'utf8'));
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function writeInstallRecord(record, vendorDir = VENDOR_DIR) {
66
+ fs.writeFileSync(path.join(vendorDir, INSTALL_RECORD), `${JSON.stringify(record, null, 2)}\n`);
67
+ }
68
+
69
+ /**
70
+ * Locate the runnable binary. Prefers what the postinstall recorded; falls back
71
+ * to scanning vendor/ so a payload unpacked by hand still runs.
72
+ *
73
+ * The macOS bundle name tracks Tauri's productName and so differs between the
74
+ * stable ("Francois.app") and dev ("Francois Dev.app") channels — hence the
75
+ * glob rather than a hardcoded name. Its inner executable is likewise named by
76
+ * the bundler, so we take whatever is in Contents/MacOS.
77
+ */
78
+ function resolveExecutable(vendorDir = VENDOR_DIR, platform = process.platform) {
79
+ const record = readInstallRecord(vendorDir);
80
+ if (record && record.executable && fs.existsSync(record.executable)) return record.executable;
81
+
82
+ if (!fs.existsSync(vendorDir)) return null;
83
+
84
+ if (platform === 'win32') {
85
+ const exe = path.join(vendorDir, 'francois.exe');
86
+ return fs.existsSync(exe) ? exe : null;
87
+ }
88
+
89
+ if (platform === 'linux') {
90
+ const appimage = path.join(vendorDir, 'francois.AppImage');
91
+ return fs.existsSync(appimage) ? appimage : null;
92
+ }
93
+
94
+ if (platform === 'darwin') {
95
+ const bundle = fs
96
+ .readdirSync(vendorDir)
97
+ .filter((entry) => entry.endsWith('.app'))
98
+ .sort()[0];
99
+ if (!bundle) return null;
100
+ const macos = path.join(vendorDir, bundle, 'Contents', 'MacOS');
101
+ if (!fs.existsSync(macos)) return null;
102
+ const binary = fs.readdirSync(macos).sort()[0];
103
+ return binary ? path.join(macos, binary) : null;
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ module.exports = {
110
+ INSTALL_RECORD,
111
+ MANIFEST_PATH,
112
+ PACKAGE_ROOT,
113
+ SUPPORTED,
114
+ VENDOR_DIR,
115
+ assetKey,
116
+ readInstallRecord,
117
+ readManifest,
118
+ resolveExecutable,
119
+ supportedList,
120
+ writeInstallRecord,
121
+ };
package/manifest.json ADDED
@@ -0,0 +1,12 @@
1
+ {
2
+ "repo": "antoine-gmnz/francois",
3
+ "tag": "dev",
4
+ "channel": "dev",
5
+ "appVersion": "0.12.0",
6
+ "productName": "Francois Dev",
7
+ "assets": {
8
+ "darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "069ede3611b4353b89a529ad4a2601590f90340753f24627351b49d58c795bff" },
9
+ "linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "6a57e3c38afce1537affdfd389d3452b926542fa31704c08ec89ae68aeca42af" },
10
+ "win32-x64": { "name": "francois-win32-x64.zip", "sha256": "e8e735c18e595ea16ac401722cffe95051592b82a9a0032c0e9afffa1d349428" }
11
+ }
12
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "francois",
3
+ "version": "0.12.0-dev.30",
4
+ "description": "Mission control for your Claude Code fleet — installs the Francois desktop app without an installer.",
5
+ "license": "AGPL-3.0-only",
6
+ "author": "Antoine Gimenez",
7
+ "homepage": "https://github.com/antoine-gmnz/francois#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/antoine-gmnz/francois.git",
11
+ "directory": "packaging/npm"
12
+ },
13
+ "bugs": "https://github.com/antoine-gmnz/francois/issues",
14
+ "keywords": [
15
+ "claude",
16
+ "claude-code",
17
+ "anthropic",
18
+ "terminal",
19
+ "tauri",
20
+ "desktop"
21
+ ],
22
+ "bin": {
23
+ "francois": "bin/francois.js"
24
+ },
25
+ "scripts": {
26
+ "postinstall": "node install.js",
27
+ "preuninstall": "node uninstall.js"
28
+ },
29
+ "files": [
30
+ "assets/icon.png",
31
+ "bin/",
32
+ "lib/desktop.js",
33
+ "lib/platform.js",
34
+ "install.js",
35
+ "uninstall.js",
36
+ "manifest.json",
37
+ "README.md"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ },
42
+ "os": [
43
+ "darwin",
44
+ "win32",
45
+ "linux"
46
+ ]
47
+ }
package/uninstall.js ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * preuninstall — unregister the desktop integration before npm deletes the
6
+ * package directory.
7
+ *
8
+ * This matters most on macOS, where the .app was moved out to ~/Applications and
9
+ * would otherwise survive `npm uninstall -g francois` as an orphan.
10
+ *
11
+ * npm's uninstall lifecycle does not fire in every situation (a manually deleted
12
+ * global folder, some CI teardowns), so `francois shortcut --remove` exists as
13
+ * the explicit escape hatch. Never fails the uninstall.
14
+ */
15
+
16
+ const desktop = require('./lib/desktop.js');
17
+ const { readInstallRecord } = require('./lib/platform.js');
18
+
19
+ try {
20
+ const record = readInstallRecord();
21
+ if (record) desktop.remove(record);
22
+ } catch {
23
+ // Nothing here is worth blocking an uninstall over.
24
+ }