fyn 3.0.4 → 3.1.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/README.md +231 -14
- package/bin/check-node.mjs +24 -0
- package/bin/fun.mjs +14 -0
- package/bin/fyn.mjs +14 -0
- package/bin/index.mjs +43 -0
- package/dist/fyn.mjs +2518 -1084
- package/package.json +11 -9
- package/bin/bundle.js +0 -24
- package/bin/fun.js +0 -13
- package/bin/fyn.js +0 -11
- package/bin/index.js +0 -24
- package/dist/v8-compile-cache.js +0 -373
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
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
151
|
-
|
|
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
|
-
"
|
|
158
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 };
|