@gjsify/cli 0.3.20 → 0.4.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/dist/cli.gjs.mjs +798 -0
- package/lib/actions/build.js +4 -17
- package/lib/bundler-pick.d.ts +79 -0
- package/lib/bundler-pick.js +428 -0
- package/lib/commands/foreach.d.ts +16 -0
- package/lib/commands/foreach.js +268 -0
- package/lib/commands/index.d.ts +2 -0
- package/lib/commands/index.js +2 -0
- package/lib/commands/install.d.ts +1 -0
- package/lib/commands/install.js +222 -26
- package/lib/commands/run.d.ts +1 -1
- package/lib/commands/run.js +133 -20
- package/lib/commands/workspace.d.ts +8 -0
- package/lib/commands/workspace.js +69 -0
- package/lib/config.js +26 -0
- package/lib/index.js +11 -3
- package/lib/types/config-data.d.ts +10 -1
- package/lib/utils/install-backend-native.d.ts +5 -1
- package/lib/utils/install-backend-native.js +88 -11
- package/lib/utils/install-backend.d.ts +11 -1
- package/lib/utils/install-backend.js +4 -2
- package/lib/utils/pkg-json-edit.d.ts +47 -0
- package/lib/utils/pkg-json-edit.js +108 -0
- package/package.json +36 -12
- package/src/actions/build.ts +0 -431
- package/src/actions/index.ts +0 -1
- package/src/commands/build.ts +0 -146
- package/src/commands/check.ts +0 -87
- package/src/commands/create.ts +0 -63
- package/src/commands/dlx.ts +0 -195
- package/src/commands/flatpak/build.ts +0 -225
- package/src/commands/flatpak/ci.ts +0 -173
- package/src/commands/flatpak/deps.ts +0 -120
- package/src/commands/flatpak/index.ts +0 -53
- package/src/commands/flatpak/init.ts +0 -191
- package/src/commands/flatpak/utils.ts +0 -76
- package/src/commands/gettext.ts +0 -258
- package/src/commands/gresource.ts +0 -97
- package/src/commands/gsettings.ts +0 -87
- package/src/commands/index.ts +0 -12
- package/src/commands/info.ts +0 -70
- package/src/commands/install.ts +0 -195
- package/src/commands/run.ts +0 -33
- package/src/commands/showcase.ts +0 -149
- package/src/config.ts +0 -289
- package/src/constants.ts +0 -1
- package/src/index.ts +0 -37
- package/src/types/cli-build-options.ts +0 -100
- package/src/types/command.ts +0 -10
- package/src/types/config-data-library.ts +0 -5
- package/src/types/config-data-typescript.ts +0 -6
- package/src/types/config-data.ts +0 -225
- package/src/types/cosmiconfig-result.ts +0 -5
- package/src/types/index.ts +0 -6
- package/src/utils/check-system-deps.ts +0 -480
- package/src/utils/detect-native-packages.ts +0 -153
- package/src/utils/discover-showcases.ts +0 -75
- package/src/utils/dlx-cache.ts +0 -135
- package/src/utils/install-backend-native.ts +0 -363
- package/src/utils/install-backend.ts +0 -88
- package/src/utils/install-global.ts +0 -182
- package/src/utils/normalize-bundler-options.ts +0 -129
- package/src/utils/parse-spec.ts +0 -48
- package/src/utils/resolve-gjs-entry.ts +0 -96
- package/src/utils/resolve-plugin-by-name.ts +0 -106
- package/src/utils/run-gjs.ts +0 -90
- package/tsconfig.json +0 -16
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// Static discovery of showcase packages from `showcases.json`.
|
|
2
|
-
//
|
|
3
|
-
// Earlier versions read showcases from the CLI's own `package.json#dependencies`
|
|
4
|
-
// — every showcase had to be a direct CLI dependency. That made the CLI tarball
|
|
5
|
-
// blow up with each new showcase and required a CLI rebuild to publish a new
|
|
6
|
-
// one. Static manifest decouples both: the CLI reads the manifest at runtime,
|
|
7
|
-
// `gjsify showcase <name>` delegates to `gjsify dlx <package>`.
|
|
8
|
-
|
|
9
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
-
import { dirname, join } from 'node:path';
|
|
11
|
-
import { fileURLToPath } from 'node:url';
|
|
12
|
-
|
|
13
|
-
export interface ShowcaseInfo {
|
|
14
|
-
/** Short name, e.g. "three-postprocessing-pixel" */
|
|
15
|
-
name: string;
|
|
16
|
-
/** Full npm package name, e.g. "@gjsify/example-dom-three-postprocessing-pixel" */
|
|
17
|
-
packageName: string;
|
|
18
|
-
/** Category: "dom" or "node" */
|
|
19
|
-
category: string;
|
|
20
|
-
/** Description for the list view */
|
|
21
|
-
description: string;
|
|
22
|
-
/** Whether the showcase needs the gwebgl native prebuild. */
|
|
23
|
-
needsWebgl: boolean;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
interface ManifestEntry {
|
|
27
|
-
name: string;
|
|
28
|
-
package: string;
|
|
29
|
-
category: string;
|
|
30
|
-
description?: string;
|
|
31
|
-
needsWebgl?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Manifest {
|
|
35
|
-
showcases: ManifestEntry[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function manifestPath(): string {
|
|
39
|
-
// `showcases.json` lives at the package root: ../../showcases.json from lib/utils/.
|
|
40
|
-
return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'showcases.json');
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Read the curated showcase list from `showcases.json`. Returns showcases
|
|
45
|
-
* sorted by category then name. An empty list (or missing manifest) yields
|
|
46
|
-
* an empty array — `gjsify showcase` then prints the empty-state message.
|
|
47
|
-
*/
|
|
48
|
-
export function discoverShowcases(): ShowcaseInfo[] {
|
|
49
|
-
const path = manifestPath();
|
|
50
|
-
if (!existsSync(path)) return [];
|
|
51
|
-
|
|
52
|
-
let manifest: Manifest;
|
|
53
|
-
try {
|
|
54
|
-
manifest = JSON.parse(readFileSync(path, 'utf-8')) as Manifest;
|
|
55
|
-
} catch {
|
|
56
|
-
return [];
|
|
57
|
-
}
|
|
58
|
-
if (!Array.isArray(manifest.showcases)) return [];
|
|
59
|
-
|
|
60
|
-
const showcases: ShowcaseInfo[] = manifest.showcases.map((e) => ({
|
|
61
|
-
name: e.name,
|
|
62
|
-
packageName: e.package,
|
|
63
|
-
category: e.category,
|
|
64
|
-
description: e.description ?? '',
|
|
65
|
-
needsWebgl: Boolean(e.needsWebgl),
|
|
66
|
-
}));
|
|
67
|
-
|
|
68
|
-
showcases.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name));
|
|
69
|
-
return showcases;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Find a single showcase by short name. */
|
|
73
|
-
export function findShowcase(name: string): ShowcaseInfo | undefined {
|
|
74
|
-
return discoverShowcases().find((e) => e.name === name);
|
|
75
|
-
}
|
package/src/utils/dlx-cache.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
// Cache for `gjsify dlx` — content-addressable, atomic, parallel-safe.
|
|
2
|
-
//
|
|
3
|
-
// Pattern adapted from refs/pnpm/exec/commands/src/dlx.ts:
|
|
4
|
-
// - cache key = sha256 over sorted [packages, registries]
|
|
5
|
-
// - cache layout: <root>/<sha>/{pkg,timestamp-pid}/
|
|
6
|
-
// - prepare into a fresh temp dir, then atomically swap a `pkg` symlink
|
|
7
|
-
// - TTL via lstat mtime + maxAgeMinutes (default 7 days)
|
|
8
|
-
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
10
|
-
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync, type Stats } from 'node:fs';
|
|
11
|
-
import { homedir } from 'node:os';
|
|
12
|
-
import { join, resolve } from 'node:path';
|
|
13
|
-
|
|
14
|
-
const ONE_MINUTE_MS = 60_000;
|
|
15
|
-
const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
|
|
16
|
-
|
|
17
|
-
function lexCompare(a: string, b: string): number {
|
|
18
|
-
return a < b ? -1 : a > b ? 1 : 0;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface CacheKeyOpts {
|
|
22
|
-
packages: string[];
|
|
23
|
-
registries?: Record<string, string>;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Stable, sorted JSON hash of inputs. */
|
|
27
|
-
export function createCacheKey(opts: CacheKeyOpts): string {
|
|
28
|
-
const sortedPkgs = [...opts.packages].sort(lexCompare);
|
|
29
|
-
const sortedRegs = Object.entries(opts.registries ?? {}).sort(([a], [b]) => lexCompare(a, b));
|
|
30
|
-
const payload = JSON.stringify([sortedPkgs, sortedRegs]);
|
|
31
|
-
return createHash('sha256').update(payload).digest('hex');
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
|
|
35
|
-
export function dlxCacheRoot(): string {
|
|
36
|
-
const xdg = process.env.XDG_CACHE_HOME;
|
|
37
|
-
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
|
|
38
|
-
const root = join(base, 'gjsify', 'dlx');
|
|
39
|
-
mkdirSync(root, { recursive: true });
|
|
40
|
-
return root;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Per-key cache directory: <root>/<sha>. */
|
|
44
|
-
export function cacheDirFor(cacheKey: string): string {
|
|
45
|
-
const dir = join(dlxCacheRoot(), cacheKey);
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
|
-
return dir;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** A fresh prepare directory under the per-key cache, named timestamp-pid. */
|
|
51
|
-
export function makePrepareDir(cacheDir: string): string {
|
|
52
|
-
const name = `${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
53
|
-
const dir = join(cacheDir, name);
|
|
54
|
-
mkdirSync(dir, { recursive: true });
|
|
55
|
-
return dir;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
|
|
60
|
-
* realpath. Returns undefined when the link doesn't exist, isn't a symlink,
|
|
61
|
-
* has been removed, or has expired.
|
|
62
|
-
*/
|
|
63
|
-
export function getValidCachedPkg(
|
|
64
|
-
cacheDir: string,
|
|
65
|
-
maxAgeMinutes: number = DEFAULT_TTL_MIN,
|
|
66
|
-
): string | undefined {
|
|
67
|
-
const linkPath = join(cacheDir, 'pkg');
|
|
68
|
-
let stats: Stats;
|
|
69
|
-
try {
|
|
70
|
-
stats = lstatSync(linkPath);
|
|
71
|
-
} catch (err) {
|
|
72
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
|
|
73
|
-
throw err;
|
|
74
|
-
}
|
|
75
|
-
if (!stats.isSymbolicLink()) return undefined;
|
|
76
|
-
|
|
77
|
-
let target: string;
|
|
78
|
-
try {
|
|
79
|
-
target = realpathSync(linkPath);
|
|
80
|
-
} catch {
|
|
81
|
-
return undefined;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const ageMs = Date.now() - stats.mtime.getTime();
|
|
85
|
-
return ageMs <= maxAgeMinutes * ONE_MINUTE_MS ? target : undefined;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
|
|
90
|
-
*
|
|
91
|
-
* Strategy:
|
|
92
|
-
* 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
|
|
93
|
-
* 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
|
|
94
|
-
*
|
|
95
|
-
* Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
|
|
96
|
-
* — a parallel process won, return its realpath.
|
|
97
|
-
*/
|
|
98
|
-
export function symlinkSwap(cacheDir: string, prepareDir: string): string {
|
|
99
|
-
const linkPath = join(cacheDir, 'pkg');
|
|
100
|
-
const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
101
|
-
const tmpLink = join(cacheDir, tmpName);
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
symlinkSync(prepareDir, tmpLink, 'dir');
|
|
105
|
-
} catch (err) {
|
|
106
|
-
// If we cannot even create the tmp link, give up.
|
|
107
|
-
throw err;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
try {
|
|
111
|
-
renameSync(tmpLink, linkPath);
|
|
112
|
-
} catch (err) {
|
|
113
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
114
|
-
if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
|
|
115
|
-
// Race lost — clean up our tmp and use whoever won.
|
|
116
|
-
try { rmSync(tmpLink); } catch {}
|
|
117
|
-
return realpathSync(linkPath);
|
|
118
|
-
}
|
|
119
|
-
throw err;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return realpathSync(linkPath);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
|
|
126
|
-
export function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes: number = DEFAULT_TTL_MIN): void {
|
|
127
|
-
// Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
|
|
128
|
-
// call sites already exist when we do implement it.
|
|
129
|
-
void cacheDir;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** Resolve absolute path to the installed package's directory inside cache. */
|
|
133
|
-
export function resolveInstalledPkgDir(cachedRoot: string, pkgName: string): string {
|
|
134
|
-
return resolve(cachedRoot, 'node_modules', pkgName);
|
|
135
|
-
}
|
|
@@ -1,363 +0,0 @@
|
|
|
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,88 +0,0 @@
|
|
|
1
|
-
// Install backend abstraction.
|
|
2
|
-
//
|
|
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`.
|
|
8
|
-
//
|
|
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.
|
|
12
|
-
//
|
|
13
|
-
// `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
|
|
14
|
-
// already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
|
|
15
|
-
|
|
16
|
-
import { spawn } from 'node:child_process';
|
|
17
|
-
import { writeFileSync } from 'node:fs';
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
|
|
20
|
-
export interface InstallOptions {
|
|
21
|
-
/** Directory to install into (npm `--prefix`). Created by caller. */
|
|
22
|
-
prefix: string;
|
|
23
|
-
/** npm-resolvable specs: `name`, `name@version`, `git+https://...`, tarball URL, ... */
|
|
24
|
-
specs: string[];
|
|
25
|
-
/** Verbose logging passes through `--loglevel verbose`. */
|
|
26
|
-
verbose?: boolean;
|
|
27
|
-
/** Optional registry override (writes a temp `.npmrc` in prefix). */
|
|
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;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'native';
|
|
40
|
-
|
|
41
|
-
export async function installPackages(opts: InstallOptions): Promise<void> {
|
|
42
|
-
if (DEFAULT_BACKEND === 'npm') {
|
|
43
|
-
return installViaNpm(opts);
|
|
44
|
-
}
|
|
45
|
-
const { installPackagesNative } = await import('./install-backend-native.js');
|
|
46
|
-
return installPackagesNative(opts);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function installViaNpm({ prefix, specs, verbose, registry }: InstallOptions): Promise<void> {
|
|
50
|
-
if (specs.length === 0) {
|
|
51
|
-
throw new Error('installPackages: empty specs list');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Seed an empty package.json so npm doesn't walk up from prefix and pick
|
|
55
|
-
// up the user's project metadata. Cosmetic name/version only.
|
|
56
|
-
writeFileSync(
|
|
57
|
-
join(prefix, 'package.json'),
|
|
58
|
-
JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2),
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
if (registry) {
|
|
62
|
-
writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const args = [
|
|
66
|
-
'install',
|
|
67
|
-
'--no-package-lock',
|
|
68
|
-
'--no-audit',
|
|
69
|
-
'--no-fund',
|
|
70
|
-
'--prefix', prefix,
|
|
71
|
-
...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
|
|
72
|
-
...specs,
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
await new Promise<void>((resolve, reject) => {
|
|
76
|
-
const child = spawn('npm', args, { stdio: 'inherit' });
|
|
77
|
-
child.on('close', (code) => {
|
|
78
|
-
if (code === 0) resolve();
|
|
79
|
-
else reject(new Error(`npm install exited with code ${code}`));
|
|
80
|
-
});
|
|
81
|
-
child.on('error', (err) => {
|
|
82
|
-
const msg = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
83
|
-
? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
|
|
84
|
-
: `npm install failed: ${err.message}`;
|
|
85
|
-
reject(new Error(msg));
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
}
|