@gjsify/cli 0.3.6 → 0.3.8

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.
@@ -1,122 +1,30 @@
1
1
  import { build } from "esbuild";
2
2
  import { gjsifyPlugin } from "@gjsify/esbuild-plugin-gjsify";
3
3
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, } from "@gjsify/esbuild-plugin-gjsify/globals";
4
- import { dirname, extname, join } from "node:path";
5
- import { fileURLToPath } from "node:url";
4
+ import { getBundleDir, rewriteContents } from "@gjsify/esbuild-plugin-gjsify";
5
+ import { getPnpPlugin } from "@gjsify/resolve-npm/pnp-relay";
6
+ import { dirname, extname } from "node:path";
6
7
  import { chmod, readFile, writeFile } from "node:fs/promises";
7
- import { existsSync } from "node:fs";
8
- import { createRequire } from "node:module";
9
8
  const GJS_SHEBANG = "#!/usr/bin/env -S gjs -m\n";
10
- /** Walk up from dir until .pnp.cjs is found; return its directory or null. */
11
- function findPnpRoot(dir) {
12
- let current = dir;
13
- while (true) {
14
- if (existsSync(join(current, ".pnp.cjs")))
15
- return current;
16
- const parent = dirname(current);
17
- if (parent === current)
18
- return null;
19
- current = parent;
20
- }
21
- }
22
9
  /**
23
- * If the current project uses Yarn PnP, return the official
24
- * @yarnpkg/esbuild-plugin-pnp plugin so esbuild can resolve
25
- * modules from zip archives without manual extraction.
10
+ * Resolve the gjsify-flavoured PnP plugin. Anchors the relay on this file's
11
+ * URL so transitive `@gjsify/*` polyfills (reached via @gjsify/cli's deps on
12
+ * @gjsify/{node,web}-polyfills) are resolvable for external consumers without
13
+ * each one having to be a direct devDep.
26
14
  *
27
- * Custom onResolve: when the project's PnP context throws
28
- * UNDECLARED_DEPENDENCY, retry via a two-hop relay:
29
- * 1. @gjsify/cli context (direct dep of the project using gjsify build)
30
- * 2. @gjsify/node-polyfills context (direct dep of @gjsify/cli, has all
31
- * node polyfills as direct deps including @gjsify/node-globals)
32
- * 3. @gjsify/web-polyfills context (direct dep of @gjsify/cli, has all
33
- * web polyfills as direct deps including @gjsify/fetch, @gjsify/abort-controller)
34
- * For bare specifiers that the gjsify alias plugin maps (e.g. `abort-controller`),
35
- * fall through so that plugin can handle the transformation first.
15
+ * Wires the @gjsify/esbuild-plugin-gjsify rewriter (`__filename`/`__dirname`
16
+ * injection for CJS code in node_modules) into the pnp plugin's onLoad —
17
+ * esbuild stops at the first matching onLoad, so the rewriter MUST run from
18
+ * inside the pnp plugin's onLoad rather than as a separate registration.
36
19
  */
37
- async function getPnpPlugin() {
38
- if (!findPnpRoot(process.cwd()))
39
- return null;
40
- try {
41
- const { pnpPlugin } = await import("@yarnpkg/esbuild-plugin-pnp");
42
- // gjsify's own file path @gjsify/cli has node-polyfills + web-polyfills
43
- // as direct deps, so we can resolve them as relay issuers from here.
44
- const gjsifyIssuer = fileURLToPath(import.meta.url);
45
- // Two-hop relay: node-polyfills and web-polyfills have all individual
46
- // @gjsify/* packages as direct deps. Resolving from their package.json
47
- // paths allows PnP to use them as issuers — sub-path imports
48
- // (`@gjsify/foo/register/bar`) then resolve through the polyfill's
49
- // dep graph. Resolve to package.json (always present, exports-agnostic)
50
- // rather than main/module (the polyfills meta packages have no main).
51
- const requireFromGjsify = createRequire(gjsifyIssuer);
52
- const relayIssuers = [];
53
- for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
54
- try {
55
- relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
56
- }
57
- catch {
58
- // polyfills package not in dep tree — relay won't cover it
59
- }
60
- }
61
- let pnpApi = null;
62
- try {
63
- // pnpapi has no npm package — it is a virtual CJS module injected by
64
- // Yarn PnP. `await import()` of a CJS module yields the ESM namespace
65
- // `{ default, "module.exports" }`, NOT the exports object — so
66
- // `mod.resolveRequest` is `undefined`. Unwrap `.default` (the CJS
67
- // exports) before use, falling back to the namespace itself for ESM
68
- // builds of pnpapi (none today, but defensive).
69
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
70
- // @ts-expect-error
71
- const mod = await import("pnpapi");
72
- pnpApi = (mod.default ?? mod);
73
- }
74
- catch {
75
- // Not in a PnP runtime (shouldn't happen since findPnpRoot passed)
76
- }
77
- return pnpPlugin({
78
- onResolve: async (args, { resolvedPath, error, watchFiles }) => {
79
- if (resolvedPath !== null) {
80
- return { namespace: "pnp", path: resolvedPath, watchFiles };
81
- }
82
- if (error?.pnpCode ===
83
- "UNDECLARED_DEPENDENCY") {
84
- if (pnpApi !== null) {
85
- // Try @gjsify/cli context first (covers @gjsify/* that are
86
- // direct deps of cli's own deps — unlikely but fast check).
87
- try {
88
- const rp = pnpApi.resolveRequest(args.path, gjsifyIssuer);
89
- if (rp !== null)
90
- return { namespace: "pnp", path: rp, watchFiles };
91
- }
92
- catch { }
93
- // Two-hop relay: resolve from node-polyfills / web-polyfills context
94
- // which have the individual @gjsify/* packages as direct deps.
95
- for (const relayIssuer of relayIssuers) {
96
- try {
97
- const rp = pnpApi.resolveRequest(args.path, relayIssuer);
98
- if (rp !== null)
99
- return { namespace: "pnp", path: rp, watchFiles };
100
- }
101
- catch { }
102
- }
103
- }
104
- // Fall through — bare aliases (abort-controller, fetch/register/*)
105
- // are handled by the gjsify alias plugin after this returns null,
106
- // then the re-resolved @gjsify/* path goes through this hook again.
107
- return null;
108
- }
109
- return {
110
- external: true,
111
- errors: error ? [{ text: error.message }] : [],
112
- watchFiles,
113
- };
114
- },
115
- });
116
- }
117
- catch {
118
- return null;
119
- }
20
+ async function buildPnpPlugin() {
21
+ return getPnpPlugin({
22
+ issuerUrl: import.meta.url,
23
+ transformContentsFactory: (build) => {
24
+ const bundleDir = getBundleDir(build);
25
+ return (args, contents) => rewriteContents(args, contents, bundleDir);
26
+ },
27
+ });
120
28
  }
121
29
  export class BuildAction {
122
30
  configData;
@@ -140,7 +48,7 @@ export class BuildAction {
140
48
  const moduleOutExt = library.module ? extname(library.module) : ".js";
141
49
  const mainOutExt = library.main ? extname(library.main) : ".js";
142
50
  const multipleBuilds = moduleOutdir && mainOutdir && moduleOutdir !== mainOutdir;
143
- const pnpPlugin = await getPnpPlugin();
51
+ const pnpPlugin = await buildPnpPlugin();
144
52
  const pnpPlugins = pnpPlugin ? [pnpPlugin] : [];
145
53
  const results = [];
146
54
  if (multipleBuilds) {
@@ -298,7 +206,7 @@ export class BuildAction {
298
206
  ...(aliases ? { aliases } : {}),
299
207
  };
300
208
  const { autoMode, extras } = this.parseGlobalsValue(globals);
301
- const pnpPlugin = await getPnpPlugin();
209
+ const pnpPlugin = await buildPnpPlugin();
302
210
  const pnpPlugins = pnpPlugin ? [pnpPlugin] : [];
303
211
  // --- Auto mode (with optional extras): iterative multi-pass build ---
304
212
  // The extras token is used for cases where the detector cannot
@@ -4,6 +4,8 @@ interface DlxOptions {
4
4
  binOrArg?: string;
5
5
  extraArgs?: string[];
6
6
  'cache-max-age': number;
7
+ reinstall: boolean;
8
+ frozen: boolean;
7
9
  verbose: boolean;
8
10
  registry?: string;
9
11
  }
@@ -37,6 +37,16 @@ export const dlxCommand = {
37
37
  description: 'Cache TTL in minutes. Defaults to 7 days. Use 0 to bypass cache.',
38
38
  type: 'number',
39
39
  default: 60 * 24 * 7,
40
+ })
41
+ .option('reinstall', {
42
+ description: 'Bypass the cache for this run (alias for --cache-max-age=0).',
43
+ type: 'boolean',
44
+ default: false,
45
+ })
46
+ .option('frozen', {
47
+ description: 'Use the project-local gjsify-lock.json verbatim — fail if missing or stale (no resolver pass).',
48
+ type: 'boolean',
49
+ default: false,
40
50
  })
41
51
  .option('verbose', {
42
52
  description: 'Verbose logging (passes --loglevel verbose to npm).',
@@ -49,10 +59,12 @@ export const dlxCommand = {
49
59
  }),
50
60
  handler: async (args) => {
51
61
  const parsed = parseSpec(args.spec);
62
+ const cacheMaxAge = args.reinstall ? 0 : args['cache-max-age'];
52
63
  const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
53
64
  verbose: args.verbose,
54
65
  registry: args.registry,
55
- cacheMaxAge: args['cache-max-age'],
66
+ cacheMaxAge,
67
+ frozen: args.frozen,
56
68
  });
57
69
  // Bin / args disambiguation:
58
70
  // gjsify dlx <pkg> → no bin, no args
@@ -86,6 +98,11 @@ async function ensurePkgDir(parsed, opts) {
86
98
  specs: [parsed.spec],
87
99
  verbose: opts.verbose,
88
100
  registry: opts.registry,
101
+ // Cache-prepare dirs are scoped per cache key, so writing a lockfile
102
+ // there gives us reproducibility for repeated `gjsify dlx <pkg>` calls
103
+ // and lets `--frozen` short-circuit the resolver entirely.
104
+ lockfile: true,
105
+ frozen: opts.frozen,
89
106
  });
90
107
  const liveTarget = symlinkSwap(cacheDir, prepareDir);
91
108
  return {
@@ -0,0 +1,2 @@
1
+ import type { InstallOptions } from "./install-backend.ts";
2
+ export declare function installPackagesNative(opts: InstallOptions): Promise<void>;
@@ -0,0 +1,299 @@
1
+ // Native install backend — GJS-runnable replacement for `npm install`.
2
+ //
3
+ // Pipeline: parse specs → resolve deps via @gjsify/npm-registry packuments and
4
+ // @gjsify/semver → download tarballs in parallel → extract into a flat
5
+ // node_modules/ via @gjsify/tar. Output layout matches `npm install` so the
6
+ // existing `runGjsBundle()` prebuild detection works without branching.
7
+ //
8
+ // Out of scope (deferred to Phase 4): lockfile, peerDependencies validation,
9
+ // lifecycle scripts, git/file specs.
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
13
+ import { Range, SemVer, maxSatisfying, } from "@gjsify/semver";
14
+ import { DEFAULT_REGISTRY, fetchPackument, fetchTarball, parseNpmrc, } from "@gjsify/npm-registry";
15
+ import { extractTarball } from "@gjsify/tar";
16
+ const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? "8") || 8;
17
+ const LOCKFILE_NAME = "gjsify-lock.json";
18
+ const LOCKFILE_VERSION = 1;
19
+ export async function installPackagesNative(opts) {
20
+ if (opts.specs.length === 0) {
21
+ throw new Error("installPackagesNative: empty specs list");
22
+ }
23
+ fs.mkdirSync(opts.prefix, { recursive: true });
24
+ const npmrc = await loadNpmrc(opts);
25
+ const log = makeLogger(opts.verbose ?? false);
26
+ const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
27
+ const existingLock = readLockfile(lockfilePath);
28
+ let nodes;
29
+ if (existingLock && (opts.frozen || lockfileMatchesRequest(existingLock, opts.specs))) {
30
+ log("install: using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
31
+ nodes = lockfileToNodes(existingLock);
32
+ }
33
+ else {
34
+ if (opts.frozen) {
35
+ throw new Error(`install: --frozen requested but ${lockfilePath} is missing or stale (specs differ)`);
36
+ }
37
+ log("install: resolving %d top-level spec(s) → %s", opts.specs.length, opts.prefix);
38
+ nodes = await resolveDeps(opts.specs, npmrc, log);
39
+ if (opts.lockfile) {
40
+ writeLockfile(lockfilePath, opts.specs, nodes);
41
+ log("install: wrote %s (%d entries)", LOCKFILE_NAME, nodes.length);
42
+ }
43
+ }
44
+ log("install: downloading %d tarball(s)", nodes.length);
45
+ await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
46
+ await linkBins(nodes, opts.prefix, log);
47
+ log("install: done");
48
+ }
49
+ async function resolveDeps(specs, npmrc, log) {
50
+ const packumentCache = new Map();
51
+ const fetchPkg = (name) => {
52
+ const cached = packumentCache.get(name);
53
+ if (cached)
54
+ return cached;
55
+ const fresh = fetchPackument(name, { npmrc });
56
+ packumentCache.set(name, fresh);
57
+ return fresh;
58
+ };
59
+ const resolved = new Map();
60
+ const queue = specs.map(parseSpec);
61
+ while (queue.length > 0) {
62
+ const spec = queue.shift();
63
+ if (resolved.has(spec.name)) {
64
+ // Single-version-per-name policy (npm v6 semantics). Phase 4 v2
65
+ // (when peer-dep validation lands) revisits this for duplication.
66
+ continue;
67
+ }
68
+ const packument = await fetchPkg(spec.name);
69
+ const version = pickVersion(packument, spec.range);
70
+ if (!version) {
71
+ throw new Error(`No version of ${spec.name} satisfies ${spec.range}`);
72
+ }
73
+ const v = packument.versions[version];
74
+ if (!v) {
75
+ throw new Error(`Packument for ${spec.name} promised ${version} but no entry exists`);
76
+ }
77
+ const node = {
78
+ name: spec.name,
79
+ version,
80
+ tarballUrl: v.dist.tarball,
81
+ integrity: v.dist.integrity,
82
+ dependencies: v.dependencies ?? {},
83
+ optionalDependencies: v.optionalDependencies ?? {},
84
+ bin: v.bin,
85
+ };
86
+ resolved.set(spec.name, node);
87
+ log("resolve: %s@%s ← %s", spec.name, version, spec.range);
88
+ for (const [depName, depRange] of Object.entries(node.dependencies)) {
89
+ if (!resolved.has(depName))
90
+ queue.push({ name: depName, range: depRange });
91
+ }
92
+ for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
93
+ if (!resolved.has(depName))
94
+ queue.push({ name: depName, range: depRange });
95
+ }
96
+ }
97
+ return Array.from(resolved.values());
98
+ }
99
+ function readLockfile(lockfilePath) {
100
+ if (!fs.existsSync(lockfilePath))
101
+ return null;
102
+ try {
103
+ const parsed = JSON.parse(fs.readFileSync(lockfilePath, "utf-8"));
104
+ if (parsed.lockfileVersion !== LOCKFILE_VERSION)
105
+ return null;
106
+ if (!parsed.packages || typeof parsed.packages !== "object")
107
+ return null;
108
+ return parsed;
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ function writeLockfile(lockfilePath, specs, nodes) {
115
+ const packages = {};
116
+ // Sort for deterministic output (diff-friendly).
117
+ const sorted = [...nodes].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
118
+ for (const node of sorted) {
119
+ packages[node.name] = {
120
+ version: node.version,
121
+ resolved: node.tarballUrl,
122
+ integrity: node.integrity,
123
+ dependencies: Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
124
+ bin: node.bin,
125
+ };
126
+ }
127
+ const lockfile = {
128
+ lockfileVersion: LOCKFILE_VERSION,
129
+ requested: [...specs],
130
+ packages,
131
+ };
132
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + "\n");
133
+ }
134
+ function lockfileToNodes(lockfile) {
135
+ return Object.entries(lockfile.packages).map(([name, entry]) => ({
136
+ name,
137
+ version: entry.version,
138
+ tarballUrl: entry.resolved,
139
+ integrity: entry.integrity,
140
+ dependencies: entry.dependencies ?? {},
141
+ optionalDependencies: {},
142
+ bin: entry.bin,
143
+ }));
144
+ }
145
+ function lockfileMatchesRequest(lockfile, specs) {
146
+ if (lockfile.requested.length !== specs.length)
147
+ return false;
148
+ const a = [...lockfile.requested].sort();
149
+ const b = [...specs].sort();
150
+ return a.every((v, i) => v === b[i]);
151
+ }
152
+ function parseSpec(raw) {
153
+ if (raw.startsWith("@")) {
154
+ const slash = raw.indexOf("/");
155
+ if (slash < 0)
156
+ throw new Error(`Invalid spec (scoped name without slash): ${raw}`);
157
+ const at = raw.indexOf("@", slash);
158
+ if (at < 0)
159
+ return { name: raw, range: "*" };
160
+ return { name: raw.slice(0, at), range: raw.slice(at + 1) || "*" };
161
+ }
162
+ const at = raw.indexOf("@");
163
+ if (at < 0)
164
+ return { name: raw, range: "*" };
165
+ return { name: raw.slice(0, at), range: raw.slice(at + 1) || "*" };
166
+ }
167
+ function pickVersion(packument, range) {
168
+ // dist-tag fast path: `latest`, `next`, ...
169
+ if (packument["dist-tags"][range])
170
+ return packument["dist-tags"][range];
171
+ // Validate range early so a typo fails loudly.
172
+ let parsedRange;
173
+ try {
174
+ parsedRange = new Range(range);
175
+ }
176
+ catch {
177
+ throw new Error(`Invalid version range for ${packument.name}: ${range}`);
178
+ }
179
+ const versions = Object.keys(packument.versions).filter((v) => {
180
+ try {
181
+ new SemVer(v);
182
+ return true;
183
+ }
184
+ catch {
185
+ return false;
186
+ }
187
+ });
188
+ return maxSatisfying(versions, parsedRange);
189
+ }
190
+ async function downloadAndExtractAll(nodes, prefix, npmrc, log) {
191
+ const queue = [...nodes];
192
+ const workers = [];
193
+ const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
194
+ for (let i = 0; i < concurrency; i++) {
195
+ workers.push(worker());
196
+ }
197
+ await Promise.all(workers);
198
+ async function worker() {
199
+ while (queue.length > 0) {
200
+ const node = queue.shift();
201
+ if (!node)
202
+ return;
203
+ const dest = path.join(prefix, "node_modules", node.name);
204
+ log("fetch: %s@%s ← %s", node.name, node.version, node.tarballUrl);
205
+ const bytes = await fetchTarball(node.tarballUrl, {
206
+ npmrc,
207
+ integrity: node.integrity,
208
+ });
209
+ fs.rmSync(dest, { recursive: true, force: true });
210
+ fs.mkdirSync(dest, { recursive: true });
211
+ await extractTarball(bytes, dest);
212
+ }
213
+ }
214
+ }
215
+ async function linkBins(nodes, prefix, log) {
216
+ const binDir = path.join(prefix, "node_modules", ".bin");
217
+ let created = 0;
218
+ for (const node of nodes) {
219
+ if (!node.bin)
220
+ continue;
221
+ const map = normalizeBin(node.name, node.bin);
222
+ if (map.size === 0)
223
+ continue;
224
+ fs.mkdirSync(binDir, { recursive: true });
225
+ for (const [binName, binTarget] of map) {
226
+ const targetAbs = path.join(prefix, "node_modules", node.name, binTarget);
227
+ if (!fs.existsSync(targetAbs))
228
+ continue;
229
+ try {
230
+ fs.chmodSync(targetAbs, 0o755);
231
+ }
232
+ catch {
233
+ /* best effort */
234
+ }
235
+ const linkPath = path.join(binDir, binName);
236
+ fs.rmSync(linkPath, { force: true });
237
+ const rel = path.relative(binDir, targetAbs);
238
+ try {
239
+ fs.symlinkSync(rel, linkPath);
240
+ created++;
241
+ }
242
+ catch {
243
+ fs.copyFileSync(targetAbs, linkPath);
244
+ fs.chmodSync(linkPath, 0o755);
245
+ created++;
246
+ }
247
+ }
248
+ }
249
+ if (created > 0)
250
+ log("bin: linked %d entry(ies) under .bin/", created);
251
+ }
252
+ function normalizeBin(pkgName, bin) {
253
+ const out = new Map();
254
+ if (typeof bin === "string") {
255
+ // String form is shorthand for `{ <last-segment-of-pkgName>: <bin> }`.
256
+ const baseName = pkgName.startsWith("@")
257
+ ? pkgName.slice(pkgName.indexOf("/") + 1)
258
+ : pkgName;
259
+ out.set(baseName, bin);
260
+ return out;
261
+ }
262
+ for (const [k, v] of Object.entries(bin))
263
+ out.set(k, v);
264
+ return out;
265
+ }
266
+ async function loadNpmrc(opts) {
267
+ const home = os.homedir();
268
+ const homeRc = path.join(home, ".npmrc");
269
+ let parsed = {
270
+ registry: opts.registry ?? DEFAULT_REGISTRY,
271
+ scopes: {},
272
+ authTokens: {},
273
+ basicAuth: {},
274
+ };
275
+ if (fs.existsSync(homeRc)) {
276
+ try {
277
+ parsed = parseNpmrc(fs.readFileSync(homeRc, "utf-8"));
278
+ }
279
+ catch (e) {
280
+ // Don't let a busted .npmrc prevent installs from anonymous registries.
281
+ console.warn(`gjsify install: ignoring malformed ${homeRc}: ${e.message}`);
282
+ }
283
+ }
284
+ if (opts.registry) {
285
+ parsed.registry = opts.registry;
286
+ }
287
+ return parsed;
288
+ }
289
+ function makeLogger(verbose) {
290
+ if (!verbose) {
291
+ return () => {
292
+ /* silent unless verbose */
293
+ };
294
+ }
295
+ return (fmt, ...args) => {
296
+ const msg = fmt.replace(/%s|%d/g, () => String(args.shift()));
297
+ process.stderr.write(`gjsify install: ${msg}\n`);
298
+ };
299
+ }
@@ -7,5 +7,13 @@ export interface InstallOptions {
7
7
  verbose?: boolean;
8
8
  /** Optional registry override (writes a temp `.npmrc` in prefix). */
9
9
  registry?: string;
10
+ /**
11
+ * Native backend only: write `<prefix>/gjsify-lock.json` after a successful
12
+ * resolve. When the file exists on next call AND `frozen: true`, the
13
+ * resolver is skipped and downloads use the pinned tarball URL + integrity.
14
+ */
15
+ lockfile?: boolean;
16
+ /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
17
+ frozen?: boolean;
10
18
  }
11
19
  export declare function installPackages(opts: InstallOptions): Promise<void>;
@@ -1,24 +1,27 @@
1
- // Install backend abstraction — Phase-4 seam.
1
+ // Install backend abstraction.
2
2
  //
3
- // Today: spawns `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`.
4
- // Future (Phase 4): a GJS-native resolver replaces this without changing
5
- // the public signature, switched via `GJSIFY_INSTALL_BACKEND=native|npm`.
3
+ // Default: native backend (resolves packuments via @gjsify/npm-registry,
4
+ // extracts tarballs via @gjsify/tar no Node, no npm required at runtime).
5
+ // Fallback: `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`,
6
+ // for parity with the legacy code path. Switched via
7
+ // `GJSIFY_INSTALL_BACKEND=native|npm`.
6
8
  //
7
- // Why npm and not pnpm/yarn? npm ships with Node so users already have it.
8
- // Adding a yarn/pnpm dep would defeat the purpose of `gjsify dlx` (which
9
- // itself is meant to ship binary-free GJS apps).
9
+ // `gjsify dlx` uses this seam installing under a cache prefix, with no
10
+ // package.json update to the user's project. The native backend matches that
11
+ // workflow without ever shelling out to Node.
10
12
  //
11
13
  // `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
12
14
  // already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
13
15
  import { spawn } from 'node:child_process';
14
16
  import { writeFileSync } from 'node:fs';
15
17
  import { join } from 'node:path';
16
- const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
18
+ const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
17
19
  export async function installPackages(opts) {
18
- if (DEFAULT_BACKEND === 'native') {
19
- throw new Error('GJSIFY_INSTALL_BACKEND=native is reserved for the Phase 4 GJS-native resolver — not yet implemented.');
20
+ if (DEFAULT_BACKEND === 'npm') {
21
+ return installViaNpm(opts);
20
22
  }
21
- return installViaNpm(opts);
23
+ const { installPackagesNative } = await import('./install-backend-native.js');
24
+ return installPackagesNative(opts);
22
25
  }
23
26
  async function installViaNpm({ prefix, specs, verbose, registry }) {
24
27
  if (specs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gjsify/cli",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "CLI for Gjsify",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -23,10 +23,14 @@
23
23
  "cli"
24
24
  ],
25
25
  "dependencies": {
26
- "@gjsify/create-app": "^0.3.6",
27
- "@gjsify/esbuild-plugin-gjsify": "^0.3.6",
28
- "@gjsify/node-polyfills": "^0.3.6",
29
- "@gjsify/web-polyfills": "^0.3.6",
26
+ "@gjsify/create-app": "^0.3.8",
27
+ "@gjsify/esbuild-plugin-gjsify": "^0.3.8",
28
+ "@gjsify/node-polyfills": "^0.3.8",
29
+ "@gjsify/npm-registry": "^0.3.8",
30
+ "@gjsify/resolve-npm": "^0.3.8",
31
+ "@gjsify/semver": "^0.3.8",
32
+ "@gjsify/tar": "^0.3.8",
33
+ "@gjsify/web-polyfills": "^0.3.8",
30
34
  "@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
31
35
  "cosmiconfig": "^9.0.1",
32
36
  "esbuild": "^0.28.0",
@@ -7,127 +7,32 @@ import {
7
7
  writeRegisterInjectFile,
8
8
  detectAutoGlobals,
9
9
  } from "@gjsify/esbuild-plugin-gjsify/globals";
10
- import { dirname, extname, join } from "node:path";
11
- import { fileURLToPath } from "node:url";
10
+ import { getBundleDir, rewriteContents } from "@gjsify/esbuild-plugin-gjsify";
11
+ import { getPnpPlugin } from "@gjsify/resolve-npm/pnp-relay";
12
+ import { dirname, extname } from "node:path";
12
13
  import { chmod, readFile, writeFile } from "node:fs/promises";
13
- import { existsSync } from "node:fs";
14
- import { createRequire } from "node:module";
15
14
 
16
15
  const GJS_SHEBANG = "#!/usr/bin/env -S gjs -m\n";
17
16
 
18
- /** Walk up from dir until .pnp.cjs is found; return its directory or null. */
19
- function findPnpRoot(dir: string): string | null {
20
- let current = dir;
21
- while (true) {
22
- if (existsSync(join(current, ".pnp.cjs"))) return current;
23
- const parent = dirname(current);
24
- if (parent === current) return null;
25
- current = parent;
26
- }
27
- }
28
-
29
17
  /**
30
- * If the current project uses Yarn PnP, return the official
31
- * @yarnpkg/esbuild-plugin-pnp plugin so esbuild can resolve
32
- * modules from zip archives without manual extraction.
18
+ * Resolve the gjsify-flavoured PnP plugin. Anchors the relay on this file's
19
+ * URL so transitive `@gjsify/*` polyfills (reached via @gjsify/cli's deps on
20
+ * @gjsify/{node,web}-polyfills) are resolvable for external consumers without
21
+ * each one having to be a direct devDep.
33
22
  *
34
- * Custom onResolve: when the project's PnP context throws
35
- * UNDECLARED_DEPENDENCY, retry via a two-hop relay:
36
- * 1. @gjsify/cli context (direct dep of the project using gjsify build)
37
- * 2. @gjsify/node-polyfills context (direct dep of @gjsify/cli, has all
38
- * node polyfills as direct deps including @gjsify/node-globals)
39
- * 3. @gjsify/web-polyfills context (direct dep of @gjsify/cli, has all
40
- * web polyfills as direct deps including @gjsify/fetch, @gjsify/abort-controller)
41
- * For bare specifiers that the gjsify alias plugin maps (e.g. `abort-controller`),
42
- * fall through so that plugin can handle the transformation first.
23
+ * Wires the @gjsify/esbuild-plugin-gjsify rewriter (`__filename`/`__dirname`
24
+ * injection for CJS code in node_modules) into the pnp plugin's onLoad —
25
+ * esbuild stops at the first matching onLoad, so the rewriter MUST run from
26
+ * inside the pnp plugin's onLoad rather than as a separate registration.
43
27
  */
44
- async function getPnpPlugin(): Promise<Plugin | null> {
45
- if (!findPnpRoot(process.cwd())) return null;
46
- try {
47
- const { pnpPlugin } = await import("@yarnpkg/esbuild-plugin-pnp");
48
-
49
- // gjsify's own file path @gjsify/cli has node-polyfills + web-polyfills
50
- // as direct deps, so we can resolve them as relay issuers from here.
51
- const gjsifyIssuer = fileURLToPath(import.meta.url);
52
-
53
- // Two-hop relay: node-polyfills and web-polyfills have all individual
54
- // @gjsify/* packages as direct deps. Resolving from their package.json
55
- // paths allows PnP to use them as issuers — sub-path imports
56
- // (`@gjsify/foo/register/bar`) then resolve through the polyfill's
57
- // dep graph. Resolve to package.json (always present, exports-agnostic)
58
- // rather than main/module (the polyfills meta packages have no main).
59
- const requireFromGjsify = createRequire(gjsifyIssuer);
60
- const relayIssuers: string[] = [];
61
- for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
62
- try {
63
- relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
64
- } catch {
65
- // polyfills package not in dep tree — relay won't cover it
66
- }
67
- }
68
-
69
- // pnpapi is a virtual module injected by Yarn PnP at runtime.
70
- type PnpApi = {
71
- resolveRequest: (req: string, issuer: string) => string | null;
72
- };
73
- let pnpApi: PnpApi | null = null;
74
- try {
75
- // pnpapi has no npm package — it is a virtual CJS module injected by
76
- // Yarn PnP. `await import()` of a CJS module yields the ESM namespace
77
- // `{ default, "module.exports" }`, NOT the exports object — so
78
- // `mod.resolveRequest` is `undefined`. Unwrap `.default` (the CJS
79
- // exports) before use, falling back to the namespace itself for ESM
80
- // builds of pnpapi (none today, but defensive).
81
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
82
- // @ts-expect-error
83
- const mod = await import("pnpapi");
84
- pnpApi = ((mod as { default?: PnpApi }).default ?? mod) as PnpApi;
85
- } catch {
86
- // Not in a PnP runtime (shouldn't happen since findPnpRoot passed)
87
- }
88
-
89
- return pnpPlugin({
90
- onResolve: async (args, { resolvedPath, error, watchFiles }) => {
91
- if (resolvedPath !== null) {
92
- return { namespace: "pnp", path: resolvedPath, watchFiles };
93
- }
94
- if (
95
- (error as { pnpCode?: string } | null)?.pnpCode ===
96
- "UNDECLARED_DEPENDENCY"
97
- ) {
98
- if (pnpApi !== null) {
99
- // Try @gjsify/cli context first (covers @gjsify/* that are
100
- // direct deps of cli's own deps — unlikely but fast check).
101
- try {
102
- const rp = pnpApi.resolveRequest(args.path, gjsifyIssuer);
103
- if (rp !== null)
104
- return { namespace: "pnp", path: rp, watchFiles };
105
- } catch {}
106
- // Two-hop relay: resolve from node-polyfills / web-polyfills context
107
- // which have the individual @gjsify/* packages as direct deps.
108
- for (const relayIssuer of relayIssuers) {
109
- try {
110
- const rp = pnpApi.resolveRequest(args.path, relayIssuer);
111
- if (rp !== null)
112
- return { namespace: "pnp", path: rp, watchFiles };
113
- } catch {}
114
- }
115
- }
116
- // Fall through — bare aliases (abort-controller, fetch/register/*)
117
- // are handled by the gjsify alias plugin after this returns null,
118
- // then the re-resolved @gjsify/* path goes through this hook again.
119
- return null;
120
- }
121
- return {
122
- external: true,
123
- errors: error ? [{ text: error.message }] : [],
124
- watchFiles,
125
- };
126
- },
127
- });
128
- } catch {
129
- return null;
130
- }
28
+ async function buildPnpPlugin(): Promise<Plugin | null> {
29
+ return getPnpPlugin({
30
+ issuerUrl: import.meta.url,
31
+ transformContentsFactory: (build) => {
32
+ const bundleDir = getBundleDir(build);
33
+ return (args, contents) => rewriteContents(args, contents, bundleDir);
34
+ },
35
+ });
131
36
  }
132
37
 
133
38
  export class BuildAction {
@@ -156,7 +61,7 @@ export class BuildAction {
156
61
  const multipleBuilds =
157
62
  moduleOutdir && mainOutdir && moduleOutdir !== mainOutdir;
158
63
 
159
- const pnpPlugin = await getPnpPlugin();
64
+ const pnpPlugin = await buildPnpPlugin();
160
65
  const pnpPlugins: Plugin[] = pnpPlugin ? [pnpPlugin] : [];
161
66
 
162
67
  const results: BuildResult[] = [];
@@ -368,7 +273,7 @@ export class BuildAction {
368
273
 
369
274
  const { autoMode, extras } = this.parseGlobalsValue(globals);
370
275
 
371
- const pnpPlugin = await getPnpPlugin();
276
+ const pnpPlugin = await buildPnpPlugin();
372
277
  const pnpPlugins: Plugin[] = pnpPlugin ? [pnpPlugin] : [];
373
278
 
374
279
  // --- Auto mode (with optional extras): iterative multi-pass build ---
@@ -30,6 +30,8 @@ interface DlxOptions {
30
30
  binOrArg?: string;
31
31
  extraArgs?: string[];
32
32
  'cache-max-age': number;
33
+ reinstall: boolean;
34
+ frozen: boolean;
33
35
  verbose: boolean;
34
36
  registry?: string;
35
37
  }
@@ -62,6 +64,18 @@ export const dlxCommand: Command<any, DlxOptions> = {
62
64
  type: 'number',
63
65
  default: 60 * 24 * 7,
64
66
  })
67
+ .option('reinstall', {
68
+ description:
69
+ 'Bypass the cache for this run (alias for --cache-max-age=0).',
70
+ type: 'boolean',
71
+ default: false,
72
+ })
73
+ .option('frozen', {
74
+ description:
75
+ 'Use the project-local gjsify-lock.json verbatim — fail if missing or stale (no resolver pass).',
76
+ type: 'boolean',
77
+ default: false,
78
+ })
65
79
  .option('verbose', {
66
80
  description: 'Verbose logging (passes --loglevel verbose to npm).',
67
81
  type: 'boolean',
@@ -74,10 +88,12 @@ export const dlxCommand: Command<any, DlxOptions> = {
74
88
  handler: async (args) => {
75
89
  const parsed = parseSpec(args.spec);
76
90
 
91
+ const cacheMaxAge = args.reinstall ? 0 : args['cache-max-age'];
77
92
  const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
78
93
  verbose: args.verbose,
79
94
  registry: args.registry,
80
- cacheMaxAge: args['cache-max-age'],
95
+ cacheMaxAge,
96
+ frozen: args.frozen,
81
97
  });
82
98
 
83
99
  // Bin / args disambiguation:
@@ -106,6 +122,7 @@ interface EnsureOpts {
106
122
  verbose: boolean;
107
123
  registry?: string;
108
124
  cacheMaxAge: number;
125
+ frozen: boolean;
109
126
  }
110
127
 
111
128
  async function ensurePkgDir(
@@ -133,6 +150,11 @@ async function ensurePkgDir(
133
150
  specs: [parsed.spec],
134
151
  verbose: opts.verbose,
135
152
  registry: opts.registry,
153
+ // Cache-prepare dirs are scoped per cache key, so writing a lockfile
154
+ // there gives us reproducibility for repeated `gjsify dlx <pkg>` calls
155
+ // and lets `--frozen` short-circuit the resolver entirely.
156
+ lockfile: true,
157
+ frozen: opts.frozen,
136
158
  });
137
159
 
138
160
  const liveTarget = symlinkSwap(cacheDir, prepareDir);
@@ -0,0 +1,363 @@
1
+ // Native install backend — GJS-runnable replacement for `npm install`.
2
+ //
3
+ // Pipeline: parse specs → resolve deps via @gjsify/npm-registry packuments and
4
+ // @gjsify/semver → download tarballs in parallel → extract into a flat
5
+ // node_modules/ via @gjsify/tar. Output layout matches `npm install` so the
6
+ // existing `runGjsBundle()` prebuild detection works without branching.
7
+ //
8
+ // Out of scope (deferred to Phase 4): lockfile, peerDependencies validation,
9
+ // lifecycle scripts, git/file specs.
10
+
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+ import * as os from "node:os";
14
+
15
+ import {
16
+ Range,
17
+ SemVer,
18
+ maxSatisfying,
19
+ } from "@gjsify/semver";
20
+ import {
21
+ DEFAULT_REGISTRY,
22
+ fetchPackument,
23
+ fetchTarball,
24
+ parseNpmrc,
25
+ type NpmrcConfig,
26
+ type Packument,
27
+ type PackumentVersion,
28
+ } from "@gjsify/npm-registry";
29
+ import { extractTarball } from "@gjsify/tar";
30
+
31
+ import type { InstallOptions } from "./install-backend.ts";
32
+
33
+ const DEFAULT_CONCURRENCY = Number(process.env.GJSIFY_INSTALL_CONCURRENCY ?? "8") || 8;
34
+
35
+ interface ParsedSpec {
36
+ name: string;
37
+ range: string;
38
+ }
39
+
40
+ interface ResolvedNode {
41
+ name: string;
42
+ version: string;
43
+ tarballUrl: string;
44
+ integrity?: string;
45
+ dependencies: Record<string, string>;
46
+ optionalDependencies: Record<string, string>;
47
+ bin?: string | Record<string, string>;
48
+ }
49
+
50
+ const LOCKFILE_NAME = "gjsify-lock.json";
51
+ const LOCKFILE_VERSION = 1;
52
+
53
+ interface LockfileEntry {
54
+ version: string;
55
+ resolved: string;
56
+ integrity?: string;
57
+ dependencies?: Record<string, string>;
58
+ bin?: string | Record<string, string>;
59
+ }
60
+
61
+ interface Lockfile {
62
+ lockfileVersion: number;
63
+ /** Top-level specs used to seed this lockfile (preserves user intent). */
64
+ requested: string[];
65
+ /** Pinned packages keyed by name. */
66
+ packages: Record<string, LockfileEntry>;
67
+ }
68
+
69
+ export async function installPackagesNative(opts: InstallOptions): Promise<void> {
70
+ if (opts.specs.length === 0) {
71
+ throw new Error("installPackagesNative: empty specs list");
72
+ }
73
+
74
+ fs.mkdirSync(opts.prefix, { recursive: true });
75
+ const npmrc = await loadNpmrc(opts);
76
+ const log = makeLogger(opts.verbose ?? false);
77
+
78
+ const lockfilePath = path.join(opts.prefix, LOCKFILE_NAME);
79
+ const existingLock = readLockfile(lockfilePath);
80
+
81
+ let nodes: ResolvedNode[];
82
+ if (existingLock && (opts.frozen || lockfileMatchesRequest(existingLock, opts.specs))) {
83
+ log("install: using lockfile (%d package(s))", Object.keys(existingLock.packages).length);
84
+ nodes = lockfileToNodes(existingLock);
85
+ } else {
86
+ if (opts.frozen) {
87
+ throw new Error(
88
+ `install: --frozen requested but ${lockfilePath} is missing or stale (specs differ)`,
89
+ );
90
+ }
91
+ log("install: resolving %d top-level spec(s) → %s", opts.specs.length, opts.prefix);
92
+ nodes = await resolveDeps(opts.specs, npmrc, log);
93
+ if (opts.lockfile) {
94
+ writeLockfile(lockfilePath, opts.specs, nodes);
95
+ log("install: wrote %s (%d entries)", LOCKFILE_NAME, nodes.length);
96
+ }
97
+ }
98
+
99
+ log("install: downloading %d tarball(s)", nodes.length);
100
+ await downloadAndExtractAll(nodes, opts.prefix, npmrc, log);
101
+ await linkBins(nodes, opts.prefix, log);
102
+ log("install: done");
103
+ }
104
+
105
+ async function resolveDeps(
106
+ specs: string[],
107
+ npmrc: NpmrcConfig,
108
+ log: Logger,
109
+ ): Promise<ResolvedNode[]> {
110
+ const packumentCache = new Map<string, Promise<Packument>>();
111
+ const fetchPkg = (name: string): Promise<Packument> => {
112
+ const cached = packumentCache.get(name);
113
+ if (cached) return cached;
114
+ const fresh = fetchPackument(name, { npmrc });
115
+ packumentCache.set(name, fresh);
116
+ return fresh;
117
+ };
118
+
119
+ const resolved = new Map<string, ResolvedNode>();
120
+ const queue: ParsedSpec[] = specs.map(parseSpec);
121
+
122
+ while (queue.length > 0) {
123
+ const spec = queue.shift() as ParsedSpec;
124
+ if (resolved.has(spec.name)) {
125
+ // Single-version-per-name policy (npm v6 semantics). Phase 4 v2
126
+ // (when peer-dep validation lands) revisits this for duplication.
127
+ continue;
128
+ }
129
+ const packument = await fetchPkg(spec.name);
130
+ const version = pickVersion(packument, spec.range);
131
+ if (!version) {
132
+ throw new Error(`No version of ${spec.name} satisfies ${spec.range}`);
133
+ }
134
+ const v = packument.versions[version];
135
+ if (!v) {
136
+ throw new Error(
137
+ `Packument for ${spec.name} promised ${version} but no entry exists`,
138
+ );
139
+ }
140
+ const node: ResolvedNode = {
141
+ name: spec.name,
142
+ version,
143
+ tarballUrl: v.dist.tarball,
144
+ integrity: v.dist.integrity,
145
+ dependencies: v.dependencies ?? {},
146
+ optionalDependencies: v.optionalDependencies ?? {},
147
+ bin: v.bin,
148
+ };
149
+ resolved.set(spec.name, node);
150
+ log("resolve: %s@%s ← %s", spec.name, version, spec.range);
151
+
152
+ for (const [depName, depRange] of Object.entries(node.dependencies)) {
153
+ if (!resolved.has(depName)) queue.push({ name: depName, range: depRange });
154
+ }
155
+ for (const [depName, depRange] of Object.entries(node.optionalDependencies)) {
156
+ if (!resolved.has(depName)) queue.push({ name: depName, range: depRange });
157
+ }
158
+ }
159
+ return Array.from(resolved.values());
160
+ }
161
+
162
+ function readLockfile(lockfilePath: string): Lockfile | null {
163
+ if (!fs.existsSync(lockfilePath)) return null;
164
+ try {
165
+ const parsed = JSON.parse(fs.readFileSync(lockfilePath, "utf-8")) as Lockfile;
166
+ if (parsed.lockfileVersion !== LOCKFILE_VERSION) return null;
167
+ if (!parsed.packages || typeof parsed.packages !== "object") return null;
168
+ return parsed;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ function writeLockfile(lockfilePath: string, specs: string[], nodes: ResolvedNode[]): void {
175
+ const packages: Record<string, LockfileEntry> = {};
176
+ // Sort for deterministic output (diff-friendly).
177
+ const sorted = [...nodes].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
178
+ for (const node of sorted) {
179
+ packages[node.name] = {
180
+ version: node.version,
181
+ resolved: node.tarballUrl,
182
+ integrity: node.integrity,
183
+ dependencies:
184
+ Object.keys(node.dependencies).length > 0 ? node.dependencies : undefined,
185
+ bin: node.bin,
186
+ };
187
+ }
188
+ const lockfile: Lockfile = {
189
+ lockfileVersion: LOCKFILE_VERSION,
190
+ requested: [...specs],
191
+ packages,
192
+ };
193
+ fs.writeFileSync(lockfilePath, JSON.stringify(lockfile, null, 2) + "\n");
194
+ }
195
+
196
+ function lockfileToNodes(lockfile: Lockfile): ResolvedNode[] {
197
+ return Object.entries(lockfile.packages).map(([name, entry]) => ({
198
+ name,
199
+ version: entry.version,
200
+ tarballUrl: entry.resolved,
201
+ integrity: entry.integrity,
202
+ dependencies: entry.dependencies ?? {},
203
+ optionalDependencies: {},
204
+ bin: entry.bin,
205
+ }));
206
+ }
207
+
208
+ function lockfileMatchesRequest(lockfile: Lockfile, specs: string[]): boolean {
209
+ if (lockfile.requested.length !== specs.length) return false;
210
+ const a = [...lockfile.requested].sort();
211
+ const b = [...specs].sort();
212
+ return a.every((v, i) => v === b[i]);
213
+ }
214
+
215
+ function parseSpec(raw: string): ParsedSpec {
216
+ if (raw.startsWith("@")) {
217
+ const slash = raw.indexOf("/");
218
+ if (slash < 0) throw new Error(`Invalid spec (scoped name without slash): ${raw}`);
219
+ const at = raw.indexOf("@", slash);
220
+ if (at < 0) return { name: raw, range: "*" };
221
+ return { name: raw.slice(0, at), range: raw.slice(at + 1) || "*" };
222
+ }
223
+ const at = raw.indexOf("@");
224
+ if (at < 0) return { name: raw, range: "*" };
225
+ return { name: raw.slice(0, at), range: raw.slice(at + 1) || "*" };
226
+ }
227
+
228
+ function pickVersion(packument: Packument, range: string): string | null {
229
+ // dist-tag fast path: `latest`, `next`, ...
230
+ if (packument["dist-tags"][range]) return packument["dist-tags"][range];
231
+
232
+ // Validate range early so a typo fails loudly.
233
+ let parsedRange: Range;
234
+ try {
235
+ parsedRange = new Range(range);
236
+ } catch {
237
+ throw new Error(`Invalid version range for ${packument.name}: ${range}`);
238
+ }
239
+
240
+ const versions = Object.keys(packument.versions).filter((v) => {
241
+ try {
242
+ new SemVer(v);
243
+ return true;
244
+ } catch {
245
+ return false;
246
+ }
247
+ });
248
+ return maxSatisfying(versions, parsedRange);
249
+ }
250
+
251
+ async function downloadAndExtractAll(
252
+ nodes: ResolvedNode[],
253
+ prefix: string,
254
+ npmrc: NpmrcConfig,
255
+ log: Logger,
256
+ ): Promise<void> {
257
+ const queue = [...nodes];
258
+ const workers: Array<Promise<void>> = [];
259
+ const concurrency = Math.max(1, Math.min(DEFAULT_CONCURRENCY, queue.length));
260
+ for (let i = 0; i < concurrency; i++) {
261
+ workers.push(worker());
262
+ }
263
+ await Promise.all(workers);
264
+
265
+ async function worker(): Promise<void> {
266
+ while (queue.length > 0) {
267
+ const node = queue.shift();
268
+ if (!node) return;
269
+ const dest = path.join(prefix, "node_modules", node.name);
270
+ log("fetch: %s@%s ← %s", node.name, node.version, node.tarballUrl);
271
+ const bytes = await fetchTarball(node.tarballUrl, {
272
+ npmrc,
273
+ integrity: node.integrity,
274
+ });
275
+ fs.rmSync(dest, { recursive: true, force: true });
276
+ fs.mkdirSync(dest, { recursive: true });
277
+ await extractTarball(bytes, dest);
278
+ }
279
+ }
280
+ }
281
+
282
+ async function linkBins(nodes: ResolvedNode[], prefix: string, log: Logger): Promise<void> {
283
+ const binDir = path.join(prefix, "node_modules", ".bin");
284
+ let created = 0;
285
+ for (const node of nodes) {
286
+ if (!node.bin) continue;
287
+ const map = normalizeBin(node.name, node.bin);
288
+ if (map.size === 0) continue;
289
+ fs.mkdirSync(binDir, { recursive: true });
290
+ for (const [binName, binTarget] of map) {
291
+ const targetAbs = path.join(prefix, "node_modules", node.name, binTarget);
292
+ if (!fs.existsSync(targetAbs)) continue;
293
+ try {
294
+ fs.chmodSync(targetAbs, 0o755);
295
+ } catch {
296
+ /* best effort */
297
+ }
298
+ const linkPath = path.join(binDir, binName);
299
+ fs.rmSync(linkPath, { force: true });
300
+ const rel = path.relative(binDir, targetAbs);
301
+ try {
302
+ fs.symlinkSync(rel, linkPath);
303
+ created++;
304
+ } catch {
305
+ fs.copyFileSync(targetAbs, linkPath);
306
+ fs.chmodSync(linkPath, 0o755);
307
+ created++;
308
+ }
309
+ }
310
+ }
311
+ if (created > 0) log("bin: linked %d entry(ies) under .bin/", created);
312
+ }
313
+
314
+ function normalizeBin(pkgName: string, bin: string | Record<string, string>): Map<string, string> {
315
+ const out = new Map<string, string>();
316
+ if (typeof bin === "string") {
317
+ // String form is shorthand for `{ <last-segment-of-pkgName>: <bin> }`.
318
+ const baseName = pkgName.startsWith("@")
319
+ ? pkgName.slice(pkgName.indexOf("/") + 1)
320
+ : pkgName;
321
+ out.set(baseName, bin);
322
+ return out;
323
+ }
324
+ for (const [k, v] of Object.entries(bin)) out.set(k, v);
325
+ return out;
326
+ }
327
+
328
+ async function loadNpmrc(opts: InstallOptions): Promise<NpmrcConfig> {
329
+ const home = os.homedir();
330
+ const homeRc = path.join(home, ".npmrc");
331
+ let parsed: NpmrcConfig = {
332
+ registry: opts.registry ?? DEFAULT_REGISTRY,
333
+ scopes: {},
334
+ authTokens: {},
335
+ basicAuth: {},
336
+ };
337
+ if (fs.existsSync(homeRc)) {
338
+ try {
339
+ parsed = parseNpmrc(fs.readFileSync(homeRc, "utf-8"));
340
+ } catch (e) {
341
+ // Don't let a busted .npmrc prevent installs from anonymous registries.
342
+ console.warn(`gjsify install: ignoring malformed ${homeRc}: ${(e as Error).message}`);
343
+ }
344
+ }
345
+ if (opts.registry) {
346
+ parsed.registry = opts.registry;
347
+ }
348
+ return parsed;
349
+ }
350
+
351
+ type Logger = (fmt: string, ...args: unknown[]) => void;
352
+
353
+ function makeLogger(verbose: boolean): Logger {
354
+ if (!verbose) {
355
+ return () => {
356
+ /* silent unless verbose */
357
+ };
358
+ }
359
+ return (fmt, ...args) => {
360
+ const msg = fmt.replace(/%s|%d/g, () => String(args.shift()));
361
+ process.stderr.write(`gjsify install: ${msg}\n`);
362
+ };
363
+ }
@@ -1,12 +1,14 @@
1
- // Install backend abstraction — Phase-4 seam.
1
+ // Install backend abstraction.
2
2
  //
3
- // Today: spawns `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`.
4
- // Future (Phase 4): a GJS-native resolver replaces this without changing
5
- // the public signature, switched via `GJSIFY_INSTALL_BACKEND=native|npm`.
3
+ // Default: native backend (resolves packuments via @gjsify/npm-registry,
4
+ // extracts tarballs via @gjsify/tar no Node, no npm required at runtime).
5
+ // Fallback: `npm install --no-package-lock --no-audit --no-fund --prefix <dir> <specs...>`,
6
+ // for parity with the legacy code path. Switched via
7
+ // `GJSIFY_INSTALL_BACKEND=native|npm`.
6
8
  //
7
- // Why npm and not pnpm/yarn? npm ships with Node so users already have it.
8
- // Adding a yarn/pnpm dep would defeat the purpose of `gjsify dlx` (which
9
- // itself is meant to ship binary-free GJS apps).
9
+ // `gjsify dlx` uses this seam installing under a cache prefix, with no
10
+ // package.json update to the user's project. The native backend matches that
11
+ // workflow without ever shelling out to Node.
10
12
  //
11
13
  // `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
12
14
  // already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
@@ -24,17 +26,24 @@ export interface InstallOptions {
24
26
  verbose?: boolean;
25
27
  /** Optional registry override (writes a temp `.npmrc` in prefix). */
26
28
  registry?: string;
29
+ /**
30
+ * Native backend only: write `<prefix>/gjsify-lock.json` after a successful
31
+ * resolve. When the file exists on next call AND `frozen: true`, the
32
+ * resolver is skipped and downloads use the pinned tarball URL + integrity.
33
+ */
34
+ lockfile?: boolean;
35
+ /** Use `<prefix>/gjsify-lock.json` as the source of truth — fail if missing. */
36
+ frozen?: boolean;
27
37
  }
28
38
 
29
- const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
39
+ const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
30
40
 
31
41
  export async function installPackages(opts: InstallOptions): Promise<void> {
32
- if (DEFAULT_BACKEND === 'native') {
33
- throw new Error(
34
- 'GJSIFY_INSTALL_BACKEND=native is reserved for the Phase 4 GJS-native resolver — not yet implemented.',
35
- );
42
+ if (DEFAULT_BACKEND === 'npm') {
43
+ return installViaNpm(opts);
36
44
  }
37
- return installViaNpm(opts);
45
+ const { installPackagesNative } = await import('./install-backend-native.js');
46
+ return installPackagesNative(opts);
38
47
  }
39
48
 
40
49
  async function installViaNpm({ prefix, specs, verbose, registry }: InstallOptions): Promise<void> {