fyn 3.0.5 → 3.1.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/README.md CHANGED
@@ -138,33 +138,123 @@ TypeScript paths, or equivalent tool settings to use it.
138
138
  ### Lifecycle script allow list (`fyn.allowScripts`)
139
139
 
140
140
  As a security hardening measure, `fyn` does **not** run a package's npm lifecycle
141
- scripts (`preinstall`, `install`, `postinstall`) during install unless the package
142
- came from a configured registry (the primary `registry` or a `@scope:registry`) or
143
- is a local `file:`/`link:`/symlink dependency.
141
+ scripts (`preinstall`, `install`, `postinstall`) during install unless someone has
142
+ approved that package. Installing a package otherwise runs its author's code before
143
+ you have read a line of it, and a compromised release reaches every machine that
144
+ installs it.
144
145
 
145
- Packages pulled from other sources `github:`, git URLs (`git+https`, `git+ssh`,
146
- …), and `http(s)` tarball URLs have their lifecycle scripts **skipped by default**,
147
- and `fyn` prints a warning showing how to allow them.
146
+ Approval is per package, in `fyn.allowScripts`. The only packages exempt are your
147
+ own `file:`/`link:` dependencies and fynpo siblings, which the pull request that
148
+ changed them already reviewed.
149
+
150
+ When an install finds scripts nobody has approved it **stops and asks**, on a
151
+ terminal. In CI — or anywhere else there is no terminal to ask on — it **fails**,
152
+ rather than quietly handing you a tree whose native packages were never built. See
153
+ [`fyn.scriptPolicy`](#choosing-a-trust-model-fynscriptpolicy) for the opt-out back to
154
+ trusting a package because of where it came from.
148
155
 
149
156
  To allow specific scripts for such a package, add a `fyn.allowScripts` map to your
150
- `package.json`. Each key is `name@<spec-or-version>` and the value is the list of
151
- allowed script names:
157
+ `package.json`. Each key is a package name and each value says which versions and
158
+ which scripts are approved:
159
+
160
+ ```json
161
+ {
162
+ "fyn": {
163
+ "allowScripts": {
164
+ "sharp": { "semver": "^0.34.4", "scripts": ["install"] },
165
+ "esbuild": { "semver": "^0.28.2 || ^0.29.0" },
166
+ "canvas": { "scripts": ["install"] },
167
+ "zlib-sync": { "scripts": ["*", "!postinstall"] },
168
+ "lodash": {},
169
+ "malware": false
170
+ }
171
+ }
172
+ }
173
+ ```
174
+
175
+ - **`semver`** — approved only for versions matching this range. Omit it to approve
176
+ every version. A union works: `"^0.28.2 || ^0.29.0"`.
177
+ - **`scripts`** — approved only for these lifecycle scripts. Omit it to approve all
178
+ of them. Script names are matched case-insensitively.
179
+ - So `esbuild` above is "those two release lines, any script", `canvas` is "any version,
180
+ `install` only", `zlib-sync` is "any script except `postinstall`", and `lodash` is "any
181
+ version, any script".
182
+ - Inside `scripts`, **`!name` denies that one script** and `+name` allows it, same as a bare
183
+ name. So `["*", "!postinstall"]` is "every install script except postinstall", and
184
+ `["+install", "!preinstall"]` spells both halves out. `!*` denies them all. A `!` beats a
185
+ bare or `+` name for the same script, whichever order they appear in.
186
+ - **`false`** denies a package outright. A denial wins over everything: any other
187
+ entry matching the same package, `allowTopLevelScripts`, an `approve --all`, and
188
+ an approval in a wider scope. Removing the `false` is the only way to undo it.
189
+ [`fyn.denyScripts`](#blacklisting-packages-fyndenyscripts) says the same thing in its own
190
+ map, can also scope by version and script, and is what `install-scripts deny` writes.
191
+
192
+ This is the form `fyn install-scripts approve` writes. Several older and shorter
193
+ forms are still read, so a hand-written or npm-written allowlist keeps working:
194
+
195
+ | entry | means |
196
+ |---|---|
197
+ | `"sharp": true` / `"sharp": "*"` | any version, any script |
198
+ | `"sharp": ["install"]` / `"sharp": "install"` | any version, those scripts |
199
+ | `"sharp": "0.34.4"` / `"sharp": "^0.34.0"` | matching versions, any script — npm's form |
200
+ | `"sharp@^0.34.0": ["install"]` | the range in the key, those scripts |
201
+ | `"foo@github:user/foo#v1": ["install"]` | matched against the requested spec |
202
+
203
+ A range in the key is matched against the **resolved version**, so `sharp@^0.34.0`
204
+ covers `0.34.4`. A key whose spec is not a semver range — a `github:`/git/URL spec —
205
+ is matched literally against what the dependency asked for, since there is no
206
+ version to range over. When a key and its value both carry a version constraint,
207
+ both have to be satisfied.
208
+
209
+ #### Blacklisting packages (`fyn.denyScripts`)
210
+
211
+ `fyn.denyScripts` is the allowlist's opposite, and it takes **the same map shape** — it answers
212
+ the same two questions, which versions and which scripts. The only difference is that a match
213
+ denies:
152
214
 
153
215
  ```json
154
216
  {
155
217
  "fyn": {
156
218
  "allowScripts": {
157
- "foo@github:user/foo#v1": ["install", "postinstall"],
158
- "bar@2.3.0": ["preinstall"]
219
+ "sharp": { "semver": "^0.34.4", "scripts": ["install"] }
220
+ },
221
+ "denyScripts": {
222
+ "malware": {},
223
+ "sketchy": { "semver": "^2.0.0" },
224
+ "esbuild": { "scripts": ["postinstall"] }
159
225
  }
160
226
  }
161
227
  }
162
228
  ```
163
229
 
164
- - The key matches **either** the original dependency spec (e.g. `foo@github:user/foo#v1`)
165
- **or** the resolved version (e.g. `bar@2.3.0`).
166
- - Script names are matched case-insensitively.
167
- - Use `["*"]` (or `true`) as the value to allow all lifecycle scripts for that package.
230
+ As on the allow side, an absent `semver` means every version and an absent `scripts` means
231
+ every install script. So `malware` is denied outright, `sketchy` only at 2.x, and `esbuild`
232
+ keeps every script but `postinstall`.
233
+
234
+ Setting both is the point: `allowScripts` says what you reviewed, `denyScripts` says what you
235
+ refuse, and **deny wins** — over a matching `allowScripts` entry, over `allowTopLevelScripts`,
236
+ over an `install-scripts approve --all`, over `scriptPolicy: "all"`, and over an approval
237
+ recorded in a wider scope. Removing the entry is the only way to undo it.
238
+
239
+ - Entries need no `!` markers. Every entry in this map is already a denial, so `scripts` there
240
+ lists what to deny.
241
+ - It applies at **every scope** — `fynpo.json` `fyn.options`, the package's own
242
+ `package.json`, and `--deny-scripts` on the command line — and the three **union**. No scope
243
+ can drop what a wider one denied, so a monorepo-wide denial is not something an individual
244
+ package can talk its way out of.
245
+ - `fyn install-scripts deny <pkg>` writes an empty entry — `{}`, every version, every script.
246
+ In a fynpo repo it writes the root `fynpo.json`; `--local` writes the package's own
247
+ `package.json`. Narrowing an entry is a hand edit.
248
+ - For one run: `fyn install --deny-scripts=malware,sketchy`. A bare name there means the same
249
+ as `{}`.
250
+ - A denied package is **skipped, not queued for review**. The install reports it in the
251
+ end-of-install summary and carries on — it is never offered to the approval prompt, because
252
+ approving it could not take effect.
253
+ - `install-scripts prune` never touches it. It drops stale *approvals* — measured against what
254
+ is actually installed, hoisted packages included — and a denial for a package you no longer
255
+ install is still the answer if it ever comes back.
256
+ - The older `"allowScripts": { "malware": false }` form is still read and denies the whole
257
+ package; `denyScripts` is the form that can also scope by version and script.
168
258
 
169
259
  #### Trusting direct dependencies (`fyn.allowTopLevelScripts`)
170
260
 
@@ -182,6 +272,10 @@ any non-registry package that is declared **directly** in your top-level
182
272
  }
183
273
  ```
184
274
 
275
+ - **`"source"` mode only.** Under `"review"` (the default) it is ignored: the question there
276
+ is whether someone read the code, and "I typed this name into my `package.json`" does not
277
+ answer it. A blanket exemption for every direct dependency would be the widest hole in the
278
+ policy, and a stale `true` would open it silently.
185
279
  - This is **off by default**; the deny-by-default policy above is unchanged.
186
280
  - It only applies to dependencies you declared directly in the top-level
187
281
  `package.json`. Non-registry packages pulled in **transitively** stay blocked
@@ -196,6 +290,129 @@ any non-registry package that is declared **directly** in your top-level
196
290
  > has been pushed there. Declaring it in your `package.json` is an explicit trust
197
291
  > decision — pin to a commit/tarball you've reviewed when that matters.
198
292
 
293
+ #### Choosing a trust model (`fyn.scriptPolicy`)
294
+
295
+ `fyn.scriptPolicy` picks which question decides whether a package may run its scripts:
296
+
297
+ | mode | registry packages | git/URL packages | workspace-local packages |
298
+ |---|---|---|---|
299
+ | `"review"` *(default)* | need an allowlist entry | need an allowlist entry | run their scripts |
300
+ | `"source"` | run their scripts | need an allowlist entry | run their scripts |
301
+ | `"all"` | run their scripts | run their scripts | run their scripts |
302
+ | `"off"` | nothing runs | nothing runs | nothing runs |
303
+
304
+ `"review"` asks whether someone approved *this code* — npm 12's model, and the only one that
305
+ covers a compromised release of an ordinary dependency. `"source"` asks only where the package
306
+ *came from*, so anything off a configured registry runs; it is the opt-out for a project that
307
+ would rather not maintain an allowlist. `"off"` is npm's `ignore-scripts`, and like npm's it
308
+ wins over the allowlist rather than being overridden by it.
309
+
310
+ ```json
311
+ {
312
+ "fyn": {
313
+ "scriptPolicy": "source"
314
+ }
315
+ }
316
+ ```
317
+
318
+ Or for one run: `fyn install --script-policy=source`.
319
+
320
+ `"all"` asks nothing: every package runs its scripts whatever its source, including the
321
+ `github:`/git/URL ones every other mode blocks. Reach for it when you have decided the tree is
322
+ already trusted — a vendored or internally mirrored dependency set, or a throwaway sandbox — and
323
+ maintaining approvals buys you nothing. It is the loosest mode fyn has, looser than the
324
+ behavior fyn had before the allowlist existed.
325
+
326
+ A denial is still honored under `"all"`: `fyn.denyScripts` and an `allowScripts` `false` are
327
+ checked *before* the mode is. That makes `"all"` plus denials a blacklist — everything runs
328
+ except what you name — instead of a switch that discards the denials you already recorded.
329
+
330
+ ```json
331
+ {
332
+ "fyn": {
333
+ "scriptPolicy": "all",
334
+ "denyScripts": { "malware": {} }
335
+ }
336
+ }
337
+ ```
338
+
339
+ Workspace-local packages — `file:`/`link:` deps and fynpo siblings — are exempt in **every**
340
+ mode, including `"review"`: an allowlist is a review gate on code you did not write, and
341
+ monorepo source is reviewed by the pull request that changed it. Set
342
+ `fyn.reviewLocalPackages: true` if you want them reviewed like anything else. A local path
343
+ declared *by* a git package is not workspace-local and stays blocked.
344
+
345
+ ##### What an unapproved package looks like
346
+
347
+ Under `"review"`, an install that finds unapproved install scripts stops before running
348
+ anything:
349
+
350
+ ```
351
+ 2 packages want to run install scripts that have not been approved:
352
+ sharp@0.34.4 install
353
+ esbuild@0.28.2 postinstall
354
+ Approve? [a]ll / [s]elect / [n]one (default)
355
+ ```
356
+
357
+ `a` approves them all, `s` walks them one at a time, `n` continues with those scripts skipped.
358
+ An approval is written to your `package.json` — or the monorepo's `fynpo.json` — so the next
359
+ install does not ask again.
360
+
361
+ Where there is no terminal to ask on — CI, a pipe, a git hook — the install **fails** with the
362
+ same list and a non-zero exit, instead of producing a tree whose native packages silently never
363
+ built. Record the approvals in `package.json` and commit them, the way you would a lockfile.
364
+
365
+ To see what a project would need to approve without changing what an install runs:
366
+
367
+ ```
368
+ fyn install --script-policy=source --allow-scripts-pending
369
+ ```
370
+
371
+ #### One allowlist for a fynpo monorepo
372
+
373
+ In a fynpo repo the allowlist belongs at the root, in `fynpo.json` under `fyn.options` — one
374
+ approval per dependency, reviewed once, rather than a copy in each of twenty packages:
375
+
376
+ ```json
377
+ {
378
+ "fyn": {
379
+ "options": {
380
+ "scriptPolicy": "review",
381
+ "allowScripts": { "sharp": true, "esbuild@0.28.2": ["postinstall"] }
382
+ }
383
+ },
384
+ "packages": ["packages/*"]
385
+ }
386
+ ```
387
+
388
+ Scopes combine as: **fynpo config → package.json → CLI**. Approvals accumulate across them; a
389
+ package may add its own but a denial at any scope is final — a `denyScripts` entry or an
390
+ `allowScripts` `false`, either way no tighter scope can lift it. For `scriptPolicy` a package may
391
+ only *tighten* what the repo asked for (`"review"` → `"off"`, never back to `"source"`); a CLI
392
+ flag is a one-off and overrides outright.
393
+
394
+ #### Reviewing with `fyn install-scripts`
395
+
396
+ ```
397
+ fyn install-scripts ls # what is awaiting review (--json for data)
398
+ fyn install-scripts approve <pkg>... # allow those packages (--all for everything pending)
399
+ fyn install-scripts deny <pkg>... # blacklist those packages (fyn.denyScripts)
400
+ fyn install-scripts prune # drop entries for packages no longer installed
401
+ ```
402
+
403
+ `ls` reads what the last install recorded, so run `fyn install` (optionally with
404
+ `--allow-scripts-pending`) first.
405
+
406
+ `approve` scopes what it writes to the release line it reviewed —
407
+ `"sharp": { "semver": "^0.34.4", "scripts": ["install"] }` — and to the scripts the package
408
+ actually has, so a jump past that range, or a release that later adds a `preinstall`, comes
409
+ back for review. Approving a second version widens that one entry's `semver` into a union
410
+ rather than adding a near-duplicate. `--no-allow-scripts-pin` omits `semver`, approving every
411
+ version. In a fynpo repo it writes to the root `fynpo.json`; `--local` writes to the package's
412
+ own `package.json`.
413
+
414
+ Approving does not run anything retroactively — run `fyn install` afterwards.
415
+
199
416
  ### Registry-only transitive dependencies (`fyn.enforceRegistryDeps`)
200
417
 
201
418
  By default, `fyn` requires that **transitive** (non-top-level) dependencies
@@ -0,0 +1,24 @@
1
+ //
2
+ // Refuse to run on a node older than package.json engines, with a message rather than whatever
3
+ // syntax error the bundle would produce.
4
+ //
5
+ // This is its own module, imported before the bundle, because node parses an entire static
6
+ // import graph before evaluating any of it - a check sitting above a static import of the
7
+ // bundle would never get to run on the versions it exists for.
8
+ //
9
+ const MIN_NODE = "22.12.0";
10
+
11
+ const parts = version => version.split("-")[0].split(".").map(n => parseInt(n, 10));
12
+
13
+ const older = (a, b) => {
14
+ const [pa, pb] = [parts(a), parts(b)];
15
+ for (let i = 0; i < 3; i++) {
16
+ if (pa[i] !== pb[i]) return pa[i] < pb[i];
17
+ }
18
+ return false;
19
+ };
20
+
21
+ if (older(process.versions.node, MIN_NODE)) {
22
+ console.error(`fyn requires node >= ${MIN_NODE}, and this is ${process.versions.node}.`);
23
+ process.exit(1);
24
+ }
package/bin/fun.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "./check-node.mjs";
4
+
5
+ // dynamic so the version check above runs first - see check-node.mjs
6
+ const { fun } = await import("./index.mjs");
7
+
8
+ try {
9
+ await fun();
10
+ process.exit(0);
11
+ } catch (err) {
12
+ console.error(err);
13
+ process.exit(1);
14
+ }
package/bin/fyn.mjs ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "./check-node.mjs";
4
+
5
+ // dynamic so the version check above runs first - see check-node.mjs
6
+ const { run } = await import("./index.mjs");
7
+
8
+ try {
9
+ await run();
10
+ process.exit(0);
11
+ } catch (err) {
12
+ console.log(err);
13
+ process.exit(1);
14
+ }
package/bin/index.mjs ADDED
@@ -0,0 +1,43 @@
1
+ //
2
+ // Programmatic entry point. The release bundle is ESM (dist/fyn.mjs) because chalker uses
3
+ // top-level await to optionally load ESM-only chalk, and no CJS output format can represent
4
+ // module-scope await.
5
+ //
6
+ // The bundle is reached through a URL built at runtime rather than a literal specifier, and
7
+ // that is load-bearing rather than stylistic: a literal is statically analyzable, so a consumer
8
+ // that bundles this module - fynpo does - inlines the whole ~3.7MB fyn bundle into its own and
9
+ // ships a frozen second copy of a package it already depends on. A computed URL keeps fyn a
10
+ // runtime dependency of its consumers, which is what it should be.
11
+ //
12
+ // Loading is deferred to the first call for the same reason it always was: this is the whole
13
+ // CLI, and a consumer holding a reference to `run` should not pay to evaluate it.
14
+ //
15
+ import { fileURLToPath, pathToFileURL } from "node:url";
16
+ import Path from "node:path";
17
+
18
+ const bundleUrl = pathToFileURL(
19
+ Path.join(Path.dirname(fileURLToPath(import.meta.url)), "..", "dist", "fyn.mjs")
20
+ ).href;
21
+
22
+ let bundle;
23
+
24
+ const load = async () => {
25
+ if (!bundle) {
26
+ bundle = await import(bundleUrl);
27
+ }
28
+ return bundle;
29
+ };
30
+
31
+ /**
32
+ * @param {...unknown} args forwarded to the CLI's run
33
+ * @returns {Promise<unknown>} what the CLI's run resolves to
34
+ */
35
+ export const run = async (...args) => (await load()).run(...args);
36
+
37
+ /**
38
+ * @param {...unknown} args forwarded to the CLI's fun
39
+ * @returns {Promise<unknown>} what the CLI's fun resolves to
40
+ */
41
+ export const fun = async (...args) => (await load()).fun(...args);
42
+
43
+ export default { run, fun };