francois 0.19.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.
- package/lib/extensions.js +455 -0
- package/manifest.json +5 -5
- package/package.json +3 -3
- package/bin/francois.test.mjs +0 -0
|
@@ -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.
|
|
3
|
+
"tag": "v0.20.1",
|
|
4
4
|
"channel": "stable",
|
|
5
|
-
"appVersion": "0.
|
|
5
|
+
"appVersion": "0.20.1",
|
|
6
6
|
"productName": "Francois",
|
|
7
7
|
"assets": {
|
|
8
|
-
"darwin-universal": { "name": "francois-darwin-universal.tar.gz", "sha256": "
|
|
9
|
-
"linux-x64": { "name": "francois-linux-x64.tar.gz", "sha256": "
|
|
10
|
-
"win32-x64": { "name": "francois-win32-x64.zip", "sha256": "
|
|
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.
|
|
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/
|
|
33
|
-
"
|
|
32
|
+
"lib/",
|
|
33
|
+
"!**/*.test.mjs",
|
|
34
34
|
"install.js",
|
|
35
35
|
"uninstall.js",
|
|
36
36
|
"manifest.json",
|
package/bin/francois.test.mjs
DELETED
|
Binary file
|