@gjsify/cli 0.3.4 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/actions/build.js +6 -3
- package/lib/commands/build.js +2 -3
- package/lib/commands/dlx.d.ts +11 -0
- package/lib/commands/dlx.js +117 -0
- package/lib/commands/index.d.ts +1 -0
- package/lib/commands/index.js +1 -0
- package/lib/index.js +2 -1
- package/lib/utils/dlx-cache.d.ts +34 -0
- package/lib/utils/dlx-cache.js +120 -0
- package/lib/utils/install-backend.d.ts +11 -0
- package/lib/utils/install-backend.js +57 -0
- package/lib/utils/parse-spec.d.ts +20 -0
- package/lib/utils/parse-spec.js +37 -0
- package/lib/utils/resolve-gjs-entry.d.ts +7 -0
- package/lib/utils/resolve-gjs-entry.js +65 -0
- package/package.json +10 -10
- package/src/actions/build.ts +6 -3
- package/src/commands/build.ts +2 -3
- package/src/commands/dlx.ts +173 -0
- package/src/commands/index.ts +2 -1
- package/src/index.ts +2 -0
- package/src/utils/dlx-cache.ts +135 -0
- package/src/utils/install-backend.ts +79 -0
- package/src/utils/parse-spec.ts +48 -0
- package/src/utils/resolve-gjs-entry.ts +96 -0
package/lib/actions/build.js
CHANGED
|
@@ -43,13 +43,16 @@ async function getPnpPlugin() {
|
|
|
43
43
|
// as direct deps, so we can resolve them as relay issuers from here.
|
|
44
44
|
const gjsifyIssuer = fileURLToPath(import.meta.url);
|
|
45
45
|
// Two-hop relay: node-polyfills and web-polyfills have all individual
|
|
46
|
-
// @gjsify/* packages as direct deps. Resolving from their
|
|
47
|
-
// PnP to
|
|
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).
|
|
48
51
|
const requireFromGjsify = createRequire(gjsifyIssuer);
|
|
49
52
|
const relayIssuers = [];
|
|
50
53
|
for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
|
|
51
54
|
try {
|
|
52
|
-
relayIssuers.push(requireFromGjsify.resolve(pkg));
|
|
55
|
+
relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
|
|
53
56
|
}
|
|
54
57
|
catch {
|
|
55
58
|
// polyfills package not in dep tree — relay won't cover it
|
package/lib/commands/build.js
CHANGED
|
@@ -93,10 +93,9 @@ export const buildCommand = {
|
|
|
93
93
|
default: 'auto'
|
|
94
94
|
})
|
|
95
95
|
.option('shebang', {
|
|
96
|
-
description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile.",
|
|
96
|
+
description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile. Default: false (use --shebang to enable, or set `shebang: true` in `.gjsifyrc.js`).",
|
|
97
97
|
type: 'boolean',
|
|
98
|
-
normalize: true
|
|
99
|
-
default: false
|
|
98
|
+
normalize: true
|
|
100
99
|
})
|
|
101
100
|
.option('external', {
|
|
102
101
|
description: "Module names that should NOT be bundled. Repeat the flag or pass a comma-separated list (e.g. --external typedoc,prettier). Globs are forwarded to esbuild as-is. See https://esbuild.github.io/api/#external",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from '../types/index.js';
|
|
2
|
+
interface DlxOptions {
|
|
3
|
+
spec: string;
|
|
4
|
+
binOrArg?: string;
|
|
5
|
+
extraArgs?: string[];
|
|
6
|
+
'cache-max-age': number;
|
|
7
|
+
verbose: boolean;
|
|
8
|
+
registry?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const dlxCommand: Command<any, DlxOptions>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// `gjsify dlx <package> [bin] [-- args...]` — runs the GJS bundle of an
|
|
2
|
+
// npm-published package without persisting it in the user's project.
|
|
3
|
+
//
|
|
4
|
+
// Cardinal rule: dlx is a **GJS-bundle runner**, not a generic bin runner.
|
|
5
|
+
// It always invokes `gjs -m <bundle>` via the existing `runGjsBundle()` util.
|
|
6
|
+
// Packages without a GJS entry (no `gjsify.main`/`gjsify.bin`, no fallback
|
|
7
|
+
// `main`) fail loudly.
|
|
8
|
+
//
|
|
9
|
+
// Cache: $XDG_CACHE_HOME/gjsify/dlx/<sha256>/ with TTL (default 7d, override
|
|
10
|
+
// via --cache-max-age=<minutes>). Cache hit on second run skips `npm install`
|
|
11
|
+
// entirely. Layout + atomic-swap pattern adapted from pnpm's dlx implementation
|
|
12
|
+
// (refs/pnpm/exec/commands/src/dlx.ts).
|
|
13
|
+
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
14
|
+
import { parseSpec } from '../utils/parse-spec.js';
|
|
15
|
+
import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
|
|
16
|
+
import { cacheDirFor, createCacheKey, getValidCachedPkg, makePrepareDir, resolveInstalledPkgDir, symlinkSwap, } from '../utils/dlx-cache.js';
|
|
17
|
+
import { installPackages } from '../utils/install-backend.js';
|
|
18
|
+
export const dlxCommand = {
|
|
19
|
+
command: 'dlx <spec> [binOrArg] [extraArgs..]',
|
|
20
|
+
description: 'Run the GJS bundle of an npm-published package without installing it locally.',
|
|
21
|
+
builder: (yargs) => yargs
|
|
22
|
+
.positional('spec', {
|
|
23
|
+
description: 'Package spec (`name`, `name@version`, `@scope/name@spec`, or local path).',
|
|
24
|
+
type: 'string',
|
|
25
|
+
demandOption: true,
|
|
26
|
+
})
|
|
27
|
+
.positional('binOrArg', {
|
|
28
|
+
description: 'Optional bin name when the package defines `gjsify.bin` with multiple entries; otherwise treated as the first argument forwarded to the bundle.',
|
|
29
|
+
type: 'string',
|
|
30
|
+
})
|
|
31
|
+
.positional('extraArgs', {
|
|
32
|
+
description: 'Extra args forwarded to `gjs -m <bundle>`.',
|
|
33
|
+
type: 'string',
|
|
34
|
+
array: true,
|
|
35
|
+
})
|
|
36
|
+
.option('cache-max-age', {
|
|
37
|
+
description: 'Cache TTL in minutes. Defaults to 7 days. Use 0 to bypass cache.',
|
|
38
|
+
type: 'number',
|
|
39
|
+
default: 60 * 24 * 7,
|
|
40
|
+
})
|
|
41
|
+
.option('verbose', {
|
|
42
|
+
description: 'Verbose logging (passes --loglevel verbose to npm).',
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
default: false,
|
|
45
|
+
})
|
|
46
|
+
.option('registry', {
|
|
47
|
+
description: 'Registry URL override.',
|
|
48
|
+
type: 'string',
|
|
49
|
+
}),
|
|
50
|
+
handler: async (args) => {
|
|
51
|
+
const parsed = parseSpec(args.spec);
|
|
52
|
+
const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
|
|
53
|
+
verbose: args.verbose,
|
|
54
|
+
registry: args.registry,
|
|
55
|
+
cacheMaxAge: args['cache-max-age'],
|
|
56
|
+
});
|
|
57
|
+
// Bin / args disambiguation:
|
|
58
|
+
// gjsify dlx <pkg> → no bin, no args
|
|
59
|
+
// gjsify dlx <pkg> mybin → bin if package has gjsify.bin[mybin], else arg
|
|
60
|
+
// gjsify dlx <pkg> mybin -- arg1 arg2 → bin + extra args
|
|
61
|
+
// gjsify dlx <pkg> -- arg1 arg2 → no bin, extra args
|
|
62
|
+
const { binName, extraArgs } = splitBinAndArgs(pkgDir, args.binOrArg, args.extraArgs ?? []);
|
|
63
|
+
const entry = resolveGjsEntry(pkgDir, binName);
|
|
64
|
+
if (entry.fromFallback) {
|
|
65
|
+
console.warn(`[gjsify dlx] package "${cachedPkgName ?? parsed.kind}" has no \`gjsify\` field — falling back to package.json#main. Add \`gjsify.main\` to silence.`);
|
|
66
|
+
}
|
|
67
|
+
await runGjsBundle(entry.bundlePath, extraArgs);
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
async function ensurePkgDir(parsed, opts) {
|
|
71
|
+
if (parsed.kind === 'local') {
|
|
72
|
+
return { pkgDir: parsed.path, cachedPkgName: null };
|
|
73
|
+
}
|
|
74
|
+
const cacheKey = createCacheKey({ packages: [parsed.spec] });
|
|
75
|
+
const cacheDir = cacheDirFor(cacheKey);
|
|
76
|
+
const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
|
|
77
|
+
if (cached) {
|
|
78
|
+
return {
|
|
79
|
+
pkgDir: resolveInstalledPkgDir(cached, parsed.name),
|
|
80
|
+
cachedPkgName: parsed.name,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const prepareDir = makePrepareDir(cacheDir);
|
|
84
|
+
await installPackages({
|
|
85
|
+
prefix: prepareDir,
|
|
86
|
+
specs: [parsed.spec],
|
|
87
|
+
verbose: opts.verbose,
|
|
88
|
+
registry: opts.registry,
|
|
89
|
+
});
|
|
90
|
+
const liveTarget = symlinkSwap(cacheDir, prepareDir);
|
|
91
|
+
return {
|
|
92
|
+
pkgDir: resolveInstalledPkgDir(liveTarget, parsed.name),
|
|
93
|
+
cachedPkgName: parsed.name,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
97
|
+
import { join } from 'node:path';
|
|
98
|
+
function splitBinAndArgs(pkgDir, binOrArg, extraArgs) {
|
|
99
|
+
if (!binOrArg) {
|
|
100
|
+
return { binName: null, extraArgs };
|
|
101
|
+
}
|
|
102
|
+
const pkgJsonPath = join(pkgDir, 'package.json');
|
|
103
|
+
if (existsSync(pkgJsonPath)) {
|
|
104
|
+
try {
|
|
105
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
106
|
+
const bins = pkg.gjsify?.bin;
|
|
107
|
+
if (bins && Object.prototype.hasOwnProperty.call(bins, binOrArg)) {
|
|
108
|
+
return { binName: binOrArg, extraArgs };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// Fall through to treating as an arg.
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Not a known bin — treat the positional as the first argv to the bundle.
|
|
116
|
+
return { binName: null, extraArgs: [binOrArg, ...extraArgs] };
|
|
117
|
+
}
|
package/lib/commands/index.d.ts
CHANGED
package/lib/commands/index.js
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import yargs from 'yargs';
|
|
3
3
|
import { hideBin } from 'yargs/helpers';
|
|
4
|
-
import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, } from './commands/index.js';
|
|
4
|
+
import { buildCommand as build, runCommand as run, infoCommand as info, checkCommand as check, showcaseCommand as showcase, createCommand as create, gresourceCommand as gresource, gettextCommand as gettext, dlxCommand as dlx, } from './commands/index.js';
|
|
5
5
|
import { APP_NAME } from './constants.js';
|
|
6
6
|
void yargs(hideBin(process.argv))
|
|
7
7
|
.scriptName(APP_NAME)
|
|
@@ -9,6 +9,7 @@ void yargs(hideBin(process.argv))
|
|
|
9
9
|
.command(create.command, create.description, create.builder, create.handler)
|
|
10
10
|
.command(build.command, build.description, build.builder, build.handler)
|
|
11
11
|
.command(run.command, run.description, run.builder, run.handler)
|
|
12
|
+
.command(dlx.command, dlx.description, dlx.builder, dlx.handler)
|
|
12
13
|
.command(info.command, info.description, info.builder, info.handler)
|
|
13
14
|
.command(check.command, check.description, check.builder, check.handler)
|
|
14
15
|
.command(showcase.command, showcase.description, showcase.builder, showcase.handler)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
interface CacheKeyOpts {
|
|
2
|
+
packages: string[];
|
|
3
|
+
registries?: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
/** Stable, sorted JSON hash of inputs. */
|
|
6
|
+
export declare function createCacheKey(opts: CacheKeyOpts): string;
|
|
7
|
+
/** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
|
|
8
|
+
export declare function dlxCacheRoot(): string;
|
|
9
|
+
/** Per-key cache directory: <root>/<sha>. */
|
|
10
|
+
export declare function cacheDirFor(cacheKey: string): string;
|
|
11
|
+
/** A fresh prepare directory under the per-key cache, named timestamp-pid. */
|
|
12
|
+
export declare function makePrepareDir(cacheDir: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
|
|
15
|
+
* realpath. Returns undefined when the link doesn't exist, isn't a symlink,
|
|
16
|
+
* has been removed, or has expired.
|
|
17
|
+
*/
|
|
18
|
+
export declare function getValidCachedPkg(cacheDir: string, maxAgeMinutes?: number): string | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
|
|
21
|
+
*
|
|
22
|
+
* Strategy:
|
|
23
|
+
* 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
|
|
24
|
+
* 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
|
|
25
|
+
*
|
|
26
|
+
* Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
|
|
27
|
+
* — a parallel process won, return its realpath.
|
|
28
|
+
*/
|
|
29
|
+
export declare function symlinkSwap(cacheDir: string, prepareDir: string): string;
|
|
30
|
+
/** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
|
|
31
|
+
export declare function cleanupStalePrepareDirs(cacheDir: string, _maxAgeMinutes?: number): void;
|
|
32
|
+
/** Resolve absolute path to the installed package's directory inside cache. */
|
|
33
|
+
export declare function resolveInstalledPkgDir(cachedRoot: string, pkgName: string): string;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, symlinkSync } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import { join, resolve } from 'node:path';
|
|
12
|
+
const ONE_MINUTE_MS = 60_000;
|
|
13
|
+
const DEFAULT_TTL_MIN = 60 * 24 * 7; // 7 days
|
|
14
|
+
function lexCompare(a, b) {
|
|
15
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
16
|
+
}
|
|
17
|
+
/** Stable, sorted JSON hash of inputs. */
|
|
18
|
+
export function createCacheKey(opts) {
|
|
19
|
+
const sortedPkgs = [...opts.packages].sort(lexCompare);
|
|
20
|
+
const sortedRegs = Object.entries(opts.registries ?? {}).sort(([a], [b]) => lexCompare(a, b));
|
|
21
|
+
const payload = JSON.stringify([sortedPkgs, sortedRegs]);
|
|
22
|
+
return createHash('sha256').update(payload).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
/** $XDG_CACHE_HOME/gjsify/dlx — created if missing. */
|
|
25
|
+
export function dlxCacheRoot() {
|
|
26
|
+
const xdg = process.env.XDG_CACHE_HOME;
|
|
27
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.cache');
|
|
28
|
+
const root = join(base, 'gjsify', 'dlx');
|
|
29
|
+
mkdirSync(root, { recursive: true });
|
|
30
|
+
return root;
|
|
31
|
+
}
|
|
32
|
+
/** Per-key cache directory: <root>/<sha>. */
|
|
33
|
+
export function cacheDirFor(cacheKey) {
|
|
34
|
+
const dir = join(dlxCacheRoot(), cacheKey);
|
|
35
|
+
mkdirSync(dir, { recursive: true });
|
|
36
|
+
return dir;
|
|
37
|
+
}
|
|
38
|
+
/** A fresh prepare directory under the per-key cache, named timestamp-pid. */
|
|
39
|
+
export function makePrepareDir(cacheDir) {
|
|
40
|
+
const name = `${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
41
|
+
const dir = join(cacheDir, name);
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
return dir;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* If <cacheDir>/pkg points to a target whose mtime + maxAge < now, return its
|
|
47
|
+
* realpath. Returns undefined when the link doesn't exist, isn't a symlink,
|
|
48
|
+
* has been removed, or has expired.
|
|
49
|
+
*/
|
|
50
|
+
export function getValidCachedPkg(cacheDir, maxAgeMinutes = DEFAULT_TTL_MIN) {
|
|
51
|
+
const linkPath = join(cacheDir, 'pkg');
|
|
52
|
+
let stats;
|
|
53
|
+
try {
|
|
54
|
+
stats = lstatSync(linkPath);
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
if (err.code === 'ENOENT')
|
|
58
|
+
return undefined;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
if (!stats.isSymbolicLink())
|
|
62
|
+
return undefined;
|
|
63
|
+
let target;
|
|
64
|
+
try {
|
|
65
|
+
target = realpathSync(linkPath);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
const ageMs = Date.now() - stats.mtime.getTime();
|
|
71
|
+
return ageMs <= maxAgeMinutes * ONE_MINUTE_MS ? target : undefined;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Atomically swap `<cacheDir>/pkg` to point at `prepareDir`.
|
|
75
|
+
*
|
|
76
|
+
* Strategy:
|
|
77
|
+
* 1. Create new symlink `<cacheDir>/pkg-<rand>` → prepareDir.
|
|
78
|
+
* 2. `rename(pkg-<rand>, pkg)` — POSIX guarantees rename-over-existing is atomic.
|
|
79
|
+
*
|
|
80
|
+
* Returns the realpath of the new live target. EBUSY/EEXIST indicates a race
|
|
81
|
+
* — a parallel process won, return its realpath.
|
|
82
|
+
*/
|
|
83
|
+
export function symlinkSwap(cacheDir, prepareDir) {
|
|
84
|
+
const linkPath = join(cacheDir, 'pkg');
|
|
85
|
+
const tmpName = `pkg.tmp-${Date.now().toString(16)}-${process.pid.toString(16)}`;
|
|
86
|
+
const tmpLink = join(cacheDir, tmpName);
|
|
87
|
+
try {
|
|
88
|
+
symlinkSync(prepareDir, tmpLink, 'dir');
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
// If we cannot even create the tmp link, give up.
|
|
92
|
+
throw err;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
renameSync(tmpLink, linkPath);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
const code = err.code;
|
|
99
|
+
if (code === 'EBUSY' || code === 'EPERM' || code === 'EEXIST') {
|
|
100
|
+
// Race lost — clean up our tmp and use whoever won.
|
|
101
|
+
try {
|
|
102
|
+
rmSync(tmpLink);
|
|
103
|
+
}
|
|
104
|
+
catch { }
|
|
105
|
+
return realpathSync(linkPath);
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
return realpathSync(linkPath);
|
|
110
|
+
}
|
|
111
|
+
/** Clean up `<cacheDir>/<oldPrepareDir>` siblings older than `maxAgeMinutes`. */
|
|
112
|
+
export function cleanupStalePrepareDirs(cacheDir, _maxAgeMinutes = DEFAULT_TTL_MIN) {
|
|
113
|
+
// Out of scope for Phase 1 — pnpm has the same TODO. Leaving a stub so
|
|
114
|
+
// call sites already exist when we do implement it.
|
|
115
|
+
void cacheDir;
|
|
116
|
+
}
|
|
117
|
+
/** Resolve absolute path to the installed package's directory inside cache. */
|
|
118
|
+
export function resolveInstalledPkgDir(cachedRoot, pkgName) {
|
|
119
|
+
return resolve(cachedRoot, 'node_modules', pkgName);
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface InstallOptions {
|
|
2
|
+
/** Directory to install into (npm `--prefix`). Created by caller. */
|
|
3
|
+
prefix: string;
|
|
4
|
+
/** npm-resolvable specs: `name`, `name@version`, `git+https://...`, tarball URL, ... */
|
|
5
|
+
specs: string[];
|
|
6
|
+
/** Verbose logging passes through `--loglevel verbose`. */
|
|
7
|
+
verbose?: boolean;
|
|
8
|
+
/** Optional registry override (writes a temp `.npmrc` in prefix). */
|
|
9
|
+
registry?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function installPackages(opts: InstallOptions): Promise<void>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Install backend abstraction — Phase-4 seam.
|
|
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`.
|
|
6
|
+
//
|
|
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).
|
|
10
|
+
//
|
|
11
|
+
// `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
|
|
12
|
+
// already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { writeFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
|
|
17
|
+
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
|
+
}
|
|
21
|
+
return installViaNpm(opts);
|
|
22
|
+
}
|
|
23
|
+
async function installViaNpm({ prefix, specs, verbose, registry }) {
|
|
24
|
+
if (specs.length === 0) {
|
|
25
|
+
throw new Error('installPackages: empty specs list');
|
|
26
|
+
}
|
|
27
|
+
// Seed an empty package.json so npm doesn't walk up from prefix and pick
|
|
28
|
+
// up the user's project metadata. Cosmetic name/version only.
|
|
29
|
+
writeFileSync(join(prefix, 'package.json'), JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2));
|
|
30
|
+
if (registry) {
|
|
31
|
+
writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
|
|
32
|
+
}
|
|
33
|
+
const args = [
|
|
34
|
+
'install',
|
|
35
|
+
'--no-package-lock',
|
|
36
|
+
'--no-audit',
|
|
37
|
+
'--no-fund',
|
|
38
|
+
'--prefix', prefix,
|
|
39
|
+
...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
|
|
40
|
+
...specs,
|
|
41
|
+
];
|
|
42
|
+
await new Promise((resolve, reject) => {
|
|
43
|
+
const child = spawn('npm', args, { stdio: 'inherit' });
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
if (code === 0)
|
|
46
|
+
resolve();
|
|
47
|
+
else
|
|
48
|
+
reject(new Error(`npm install exited with code ${code}`));
|
|
49
|
+
});
|
|
50
|
+
child.on('error', (err) => {
|
|
51
|
+
const msg = err.code === 'ENOENT'
|
|
52
|
+
? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
|
|
53
|
+
: `npm install failed: ${err.message}`;
|
|
54
|
+
reject(new Error(msg));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type ParsedSpec = {
|
|
2
|
+
kind: 'local';
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'registry';
|
|
6
|
+
name: string;
|
|
7
|
+
version?: string;
|
|
8
|
+
spec: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Parse a CLI input into either a local-path or an npm-registry spec.
|
|
12
|
+
*
|
|
13
|
+
* Local: starts with `./`, `../`, `/`, or is an existing directory path.
|
|
14
|
+
* Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
|
|
15
|
+
*
|
|
16
|
+
* The full spec string is preserved on the registry case so it can be passed
|
|
17
|
+
* verbatim to `npm install` (which already understands dist-tags, ranges,
|
|
18
|
+
* git URIs, tarball URLs, etc. — we don't re-parse those).
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseSpec(input: string): ParsedSpec;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Parse a `gjsify dlx` package spec — distinguishes local paths from npm specs.
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
4
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
5
|
+
/**
|
|
6
|
+
* Parse a CLI input into either a local-path or an npm-registry spec.
|
|
7
|
+
*
|
|
8
|
+
* Local: starts with `./`, `../`, `/`, or is an existing directory path.
|
|
9
|
+
* Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
|
|
10
|
+
*
|
|
11
|
+
* The full spec string is preserved on the registry case so it can be passed
|
|
12
|
+
* verbatim to `npm install` (which already understands dist-tags, ranges,
|
|
13
|
+
* git URIs, tarball URLs, etc. — we don't re-parse those).
|
|
14
|
+
*/
|
|
15
|
+
export function parseSpec(input) {
|
|
16
|
+
if (!input)
|
|
17
|
+
throw new Error('dlx: empty package spec');
|
|
18
|
+
if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input)) {
|
|
19
|
+
return { kind: 'local', path: resolve(input) };
|
|
20
|
+
}
|
|
21
|
+
if (existsSync(input)) {
|
|
22
|
+
return { kind: 'local', path: resolve(input) };
|
|
23
|
+
}
|
|
24
|
+
// Registry spec: split off the version after the LAST `@` that isn't the
|
|
25
|
+
// leading scope separator.
|
|
26
|
+
let name = input;
|
|
27
|
+
let version;
|
|
28
|
+
const lastAt = input.lastIndexOf('@');
|
|
29
|
+
if (lastAt > 0) {
|
|
30
|
+
name = input.slice(0, lastAt);
|
|
31
|
+
version = input.slice(lastAt + 1);
|
|
32
|
+
}
|
|
33
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
34
|
+
throw new Error(`dlx: invalid package name "${name}"`);
|
|
35
|
+
}
|
|
36
|
+
return { kind: 'registry', name, version, spec: input };
|
|
37
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Resolve the GJS entry point of an installed package.
|
|
2
|
+
//
|
|
3
|
+
// Per the `gjsify` field convention (see CLI reference):
|
|
4
|
+
//
|
|
5
|
+
// {
|
|
6
|
+
// "gjsify": {
|
|
7
|
+
// "main": "dist/gjs.js",
|
|
8
|
+
// "bin": { "name-a": "dist/a.js", "name-b": "dist/b.js" }
|
|
9
|
+
// }
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// Resolution order:
|
|
13
|
+
// 1. user-supplied bin name + `gjsify.bin[name]` → that path
|
|
14
|
+
// 2. single-entry `gjsify.bin` → the only path
|
|
15
|
+
// 3. `gjsify.main` → that path
|
|
16
|
+
// 4. fallback: `package.json#main` → that path (advisory warning)
|
|
17
|
+
// 5. otherwise: hard-fail with a fix hint
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { join, resolve } from 'node:path';
|
|
21
|
+
export function resolveGjsEntry(pkgDir, binName) {
|
|
22
|
+
const pkgJsonPath = join(pkgDir, 'package.json');
|
|
23
|
+
if (!existsSync(pkgJsonPath)) {
|
|
24
|
+
throw new Error(`dlx: no package.json found at ${pkgDir}`);
|
|
25
|
+
}
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8'));
|
|
27
|
+
const gjsifyBin = pkg.gjsify?.bin;
|
|
28
|
+
const gjsifyMain = pkg.gjsify?.main;
|
|
29
|
+
const fallbackMain = pkg.main;
|
|
30
|
+
let entry;
|
|
31
|
+
let resolvedBin = null;
|
|
32
|
+
let fromFallback = false;
|
|
33
|
+
if (binName !== null) {
|
|
34
|
+
if (!gjsifyBin || !gjsifyBin[binName]) {
|
|
35
|
+
const known = gjsifyBin ? Object.keys(gjsifyBin).join(', ') : '(none)';
|
|
36
|
+
throw new Error(`dlx: package "${pkg.name ?? pkgDir}" has no GJS bin named "${binName}" — known: ${known}`);
|
|
37
|
+
}
|
|
38
|
+
entry = gjsifyBin[binName];
|
|
39
|
+
resolvedBin = binName;
|
|
40
|
+
}
|
|
41
|
+
else if (gjsifyBin && Object.keys(gjsifyBin).length === 1) {
|
|
42
|
+
const onlyBin = Object.keys(gjsifyBin)[0];
|
|
43
|
+
entry = gjsifyBin[onlyBin];
|
|
44
|
+
resolvedBin = onlyBin;
|
|
45
|
+
}
|
|
46
|
+
else if (gjsifyMain) {
|
|
47
|
+
entry = gjsifyMain;
|
|
48
|
+
}
|
|
49
|
+
else if (fallbackMain) {
|
|
50
|
+
entry = fallbackMain;
|
|
51
|
+
fromFallback = true;
|
|
52
|
+
}
|
|
53
|
+
if (gjsifyBin && Object.keys(gjsifyBin).length > 1 && binName === null) {
|
|
54
|
+
const names = Object.keys(gjsifyBin).join(', ');
|
|
55
|
+
throw new Error(`dlx: package "${pkg.name ?? pkgDir}" defines multiple GJS bins — pass one of: ${names}`);
|
|
56
|
+
}
|
|
57
|
+
if (!entry) {
|
|
58
|
+
throw new Error(`dlx: package "${pkg.name ?? pkgDir}" has no GJS entry — set \`gjsify.main\` (or \`gjsify.bin\`) in its package.json`);
|
|
59
|
+
}
|
|
60
|
+
const bundlePath = resolve(pkgDir, entry);
|
|
61
|
+
if (!existsSync(bundlePath)) {
|
|
62
|
+
throw new Error(`dlx: GJS entry not found: ${bundlePath}`);
|
|
63
|
+
}
|
|
64
|
+
return { bundlePath, binName: resolvedBin, fromFallback };
|
|
65
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gjsify/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.5",
|
|
4
4
|
"description": "CLI for Gjsify",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"cli"
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@gjsify/create-app": "^0.3.
|
|
27
|
-
"@gjsify/esbuild-plugin-gjsify": "^0.3.
|
|
28
|
-
"@gjsify/example-dom-canvas2d-fireworks": "^0.3.
|
|
29
|
-
"@gjsify/example-dom-excalibur-jelly-jumper": "^0.3.
|
|
30
|
-
"@gjsify/example-dom-three-geometry-teapot": "^0.3.
|
|
31
|
-
"@gjsify/example-dom-three-postprocessing-pixel": "^0.3.
|
|
32
|
-
"@gjsify/example-node-express-webserver": "^0.3.
|
|
33
|
-
"@gjsify/node-polyfills": "^0.3.
|
|
34
|
-
"@gjsify/web-polyfills": "^0.3.
|
|
26
|
+
"@gjsify/create-app": "^0.3.5",
|
|
27
|
+
"@gjsify/esbuild-plugin-gjsify": "^0.3.5",
|
|
28
|
+
"@gjsify/example-dom-canvas2d-fireworks": "^0.3.5",
|
|
29
|
+
"@gjsify/example-dom-excalibur-jelly-jumper": "^0.3.5",
|
|
30
|
+
"@gjsify/example-dom-three-geometry-teapot": "^0.3.5",
|
|
31
|
+
"@gjsify/example-dom-three-postprocessing-pixel": "^0.3.5",
|
|
32
|
+
"@gjsify/example-node-express-webserver": "^0.3.5",
|
|
33
|
+
"@gjsify/node-polyfills": "^0.3.5",
|
|
34
|
+
"@gjsify/web-polyfills": "^0.3.5",
|
|
35
35
|
"@yarnpkg/esbuild-plugin-pnp": "^3.0.0-rc.15",
|
|
36
36
|
"cosmiconfig": "^9.0.1",
|
|
37
37
|
"esbuild": "^0.28.0",
|
package/src/actions/build.ts
CHANGED
|
@@ -51,13 +51,16 @@ async function getPnpPlugin(): Promise<Plugin | null> {
|
|
|
51
51
|
const gjsifyIssuer = fileURLToPath(import.meta.url);
|
|
52
52
|
|
|
53
53
|
// Two-hop relay: node-polyfills and web-polyfills have all individual
|
|
54
|
-
// @gjsify/* packages as direct deps. Resolving from their
|
|
55
|
-
// PnP to
|
|
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).
|
|
56
59
|
const requireFromGjsify = createRequire(gjsifyIssuer);
|
|
57
60
|
const relayIssuers: string[] = [];
|
|
58
61
|
for (const pkg of ["@gjsify/node-polyfills", "@gjsify/web-polyfills"]) {
|
|
59
62
|
try {
|
|
60
|
-
relayIssuers.push(requireFromGjsify.resolve(pkg));
|
|
63
|
+
relayIssuers.push(requireFromGjsify.resolve(`${pkg}/package.json`));
|
|
61
64
|
} catch {
|
|
62
65
|
// polyfills package not in dep tree — relay won't cover it
|
|
63
66
|
}
|
package/src/commands/build.ts
CHANGED
|
@@ -95,10 +95,9 @@ export const buildCommand: Command<any, CliBuildOptions> = {
|
|
|
95
95
|
default: 'auto'
|
|
96
96
|
})
|
|
97
97
|
.option('shebang', {
|
|
98
|
-
description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile.",
|
|
98
|
+
description: "Prepend a `#!/usr/bin/env -S gjs -m` shebang to the output and mark it executable (chmod 755). Only applies to GJS app builds with a single --outfile. Default: false (use --shebang to enable, or set `shebang: true` in `.gjsifyrc.js`).",
|
|
99
99
|
type: 'boolean',
|
|
100
|
-
normalize: true
|
|
101
|
-
default: false
|
|
100
|
+
normalize: true
|
|
102
101
|
})
|
|
103
102
|
.option('external', {
|
|
104
103
|
description: "Module names that should NOT be bundled. Repeat the flag or pass a comma-separated list (e.g. --external typedoc,prettier). Globs are forwarded to esbuild as-is. See https://esbuild.github.io/api/#external",
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// `gjsify dlx <package> [bin] [-- args...]` — runs the GJS bundle of an
|
|
2
|
+
// npm-published package without persisting it in the user's project.
|
|
3
|
+
//
|
|
4
|
+
// Cardinal rule: dlx is a **GJS-bundle runner**, not a generic bin runner.
|
|
5
|
+
// It always invokes `gjs -m <bundle>` via the existing `runGjsBundle()` util.
|
|
6
|
+
// Packages without a GJS entry (no `gjsify.main`/`gjsify.bin`, no fallback
|
|
7
|
+
// `main`) fail loudly.
|
|
8
|
+
//
|
|
9
|
+
// Cache: $XDG_CACHE_HOME/gjsify/dlx/<sha256>/ with TTL (default 7d, override
|
|
10
|
+
// via --cache-max-age=<minutes>). Cache hit on second run skips `npm install`
|
|
11
|
+
// entirely. Layout + atomic-swap pattern adapted from pnpm's dlx implementation
|
|
12
|
+
// (refs/pnpm/exec/commands/src/dlx.ts).
|
|
13
|
+
|
|
14
|
+
import type { Command } from '../types/index.js';
|
|
15
|
+
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
16
|
+
import { parseSpec, type ParsedSpec } from '../utils/parse-spec.js';
|
|
17
|
+
import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
|
|
18
|
+
import {
|
|
19
|
+
cacheDirFor,
|
|
20
|
+
createCacheKey,
|
|
21
|
+
getValidCachedPkg,
|
|
22
|
+
makePrepareDir,
|
|
23
|
+
resolveInstalledPkgDir,
|
|
24
|
+
symlinkSwap,
|
|
25
|
+
} from '../utils/dlx-cache.js';
|
|
26
|
+
import { installPackages } from '../utils/install-backend.js';
|
|
27
|
+
|
|
28
|
+
interface DlxOptions {
|
|
29
|
+
spec: string;
|
|
30
|
+
binOrArg?: string;
|
|
31
|
+
extraArgs?: string[];
|
|
32
|
+
'cache-max-age': number;
|
|
33
|
+
verbose: boolean;
|
|
34
|
+
registry?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const dlxCommand: Command<any, DlxOptions> = {
|
|
38
|
+
command: 'dlx <spec> [binOrArg] [extraArgs..]',
|
|
39
|
+
description:
|
|
40
|
+
'Run the GJS bundle of an npm-published package without installing it locally.',
|
|
41
|
+
builder: (yargs) =>
|
|
42
|
+
yargs
|
|
43
|
+
.positional('spec', {
|
|
44
|
+
description:
|
|
45
|
+
'Package spec (`name`, `name@version`, `@scope/name@spec`, or local path).',
|
|
46
|
+
type: 'string',
|
|
47
|
+
demandOption: true,
|
|
48
|
+
})
|
|
49
|
+
.positional('binOrArg', {
|
|
50
|
+
description:
|
|
51
|
+
'Optional bin name when the package defines `gjsify.bin` with multiple entries; otherwise treated as the first argument forwarded to the bundle.',
|
|
52
|
+
type: 'string',
|
|
53
|
+
})
|
|
54
|
+
.positional('extraArgs', {
|
|
55
|
+
description: 'Extra args forwarded to `gjs -m <bundle>`.',
|
|
56
|
+
type: 'string',
|
|
57
|
+
array: true,
|
|
58
|
+
})
|
|
59
|
+
.option('cache-max-age', {
|
|
60
|
+
description:
|
|
61
|
+
'Cache TTL in minutes. Defaults to 7 days. Use 0 to bypass cache.',
|
|
62
|
+
type: 'number',
|
|
63
|
+
default: 60 * 24 * 7,
|
|
64
|
+
})
|
|
65
|
+
.option('verbose', {
|
|
66
|
+
description: 'Verbose logging (passes --loglevel verbose to npm).',
|
|
67
|
+
type: 'boolean',
|
|
68
|
+
default: false,
|
|
69
|
+
})
|
|
70
|
+
.option('registry', {
|
|
71
|
+
description: 'Registry URL override.',
|
|
72
|
+
type: 'string',
|
|
73
|
+
}),
|
|
74
|
+
handler: async (args) => {
|
|
75
|
+
const parsed = parseSpec(args.spec);
|
|
76
|
+
|
|
77
|
+
const { pkgDir, cachedPkgName } = await ensurePkgDir(parsed, {
|
|
78
|
+
verbose: args.verbose,
|
|
79
|
+
registry: args.registry,
|
|
80
|
+
cacheMaxAge: args['cache-max-age'],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Bin / args disambiguation:
|
|
84
|
+
// gjsify dlx <pkg> → no bin, no args
|
|
85
|
+
// gjsify dlx <pkg> mybin → bin if package has gjsify.bin[mybin], else arg
|
|
86
|
+
// gjsify dlx <pkg> mybin -- arg1 arg2 → bin + extra args
|
|
87
|
+
// gjsify dlx <pkg> -- arg1 arg2 → no bin, extra args
|
|
88
|
+
const { binName, extraArgs } = splitBinAndArgs(
|
|
89
|
+
pkgDir,
|
|
90
|
+
args.binOrArg,
|
|
91
|
+
args.extraArgs ?? [],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const entry = resolveGjsEntry(pkgDir, binName);
|
|
95
|
+
if (entry.fromFallback) {
|
|
96
|
+
console.warn(
|
|
97
|
+
`[gjsify dlx] package "${cachedPkgName ?? parsed.kind}" has no \`gjsify\` field — falling back to package.json#main. Add \`gjsify.main\` to silence.`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await runGjsBundle(entry.bundlePath, extraArgs);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
interface EnsureOpts {
|
|
106
|
+
verbose: boolean;
|
|
107
|
+
registry?: string;
|
|
108
|
+
cacheMaxAge: number;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function ensurePkgDir(
|
|
112
|
+
parsed: ParsedSpec,
|
|
113
|
+
opts: EnsureOpts,
|
|
114
|
+
): Promise<{ pkgDir: string; cachedPkgName: string | null }> {
|
|
115
|
+
if (parsed.kind === 'local') {
|
|
116
|
+
return { pkgDir: parsed.path, cachedPkgName: null };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const cacheKey = createCacheKey({ packages: [parsed.spec] });
|
|
120
|
+
const cacheDir = cacheDirFor(cacheKey);
|
|
121
|
+
|
|
122
|
+
const cached = opts.cacheMaxAge > 0 ? getValidCachedPkg(cacheDir, opts.cacheMaxAge) : undefined;
|
|
123
|
+
if (cached) {
|
|
124
|
+
return {
|
|
125
|
+
pkgDir: resolveInstalledPkgDir(cached, parsed.name),
|
|
126
|
+
cachedPkgName: parsed.name,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const prepareDir = makePrepareDir(cacheDir);
|
|
131
|
+
await installPackages({
|
|
132
|
+
prefix: prepareDir,
|
|
133
|
+
specs: [parsed.spec],
|
|
134
|
+
verbose: opts.verbose,
|
|
135
|
+
registry: opts.registry,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const liveTarget = symlinkSwap(cacheDir, prepareDir);
|
|
139
|
+
return {
|
|
140
|
+
pkgDir: resolveInstalledPkgDir(liveTarget, parsed.name),
|
|
141
|
+
cachedPkgName: parsed.name,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
146
|
+
import { join } from 'node:path';
|
|
147
|
+
|
|
148
|
+
function splitBinAndArgs(
|
|
149
|
+
pkgDir: string,
|
|
150
|
+
binOrArg: string | undefined,
|
|
151
|
+
extraArgs: string[],
|
|
152
|
+
): { binName: string | null; extraArgs: string[] } {
|
|
153
|
+
if (!binOrArg) {
|
|
154
|
+
return { binName: null, extraArgs };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pkgJsonPath = join(pkgDir, 'package.json');
|
|
158
|
+
if (existsSync(pkgJsonPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as {
|
|
161
|
+
gjsify?: { bin?: Record<string, string> };
|
|
162
|
+
};
|
|
163
|
+
const bins = pkg.gjsify?.bin;
|
|
164
|
+
if (bins && Object.prototype.hasOwnProperty.call(bins, binOrArg)) {
|
|
165
|
+
return { binName: binOrArg, extraArgs };
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// Fall through to treating as an arg.
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// Not a known bin — treat the positional as the first argv to the bundle.
|
|
172
|
+
return { binName: null, extraArgs: [binOrArg, ...extraArgs] };
|
|
173
|
+
}
|
package/src/commands/index.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
createCommand as create,
|
|
12
12
|
gresourceCommand as gresource,
|
|
13
13
|
gettextCommand as gettext,
|
|
14
|
+
dlxCommand as dlx,
|
|
14
15
|
} from './commands/index.js'
|
|
15
16
|
import { APP_NAME } from './constants.js'
|
|
16
17
|
|
|
@@ -20,6 +21,7 @@ void yargs(hideBin(process.argv))
|
|
|
20
21
|
.command(create.command, create.description, create.builder, create.handler)
|
|
21
22
|
.command(build.command, build.description, build.builder, build.handler)
|
|
22
23
|
.command(run.command, run.description, run.builder, run.handler)
|
|
24
|
+
.command(dlx.command, dlx.description, dlx.builder, dlx.handler)
|
|
23
25
|
.command(info.command, info.description, info.builder, info.handler)
|
|
24
26
|
.command(check.command, check.description, check.builder, check.handler)
|
|
25
27
|
.command(showcase.command, showcase.description, showcase.builder, showcase.handler)
|
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Install backend abstraction — Phase-4 seam.
|
|
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`.
|
|
6
|
+
//
|
|
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).
|
|
10
|
+
//
|
|
11
|
+
// `--no-package-lock` keeps the cache prepare dir hermetic; the cache key
|
|
12
|
+
// already covers reproducibility. `--no-audit --no-fund` cuts ~5s off cold runs.
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
export interface InstallOptions {
|
|
19
|
+
/** Directory to install into (npm `--prefix`). Created by caller. */
|
|
20
|
+
prefix: string;
|
|
21
|
+
/** npm-resolvable specs: `name`, `name@version`, `git+https://...`, tarball URL, ... */
|
|
22
|
+
specs: string[];
|
|
23
|
+
/** Verbose logging passes through `--loglevel verbose`. */
|
|
24
|
+
verbose?: boolean;
|
|
25
|
+
/** Optional registry override (writes a temp `.npmrc` in prefix). */
|
|
26
|
+
registry?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DEFAULT_BACKEND = process.env.GJSIFY_INSTALL_BACKEND ?? 'npm';
|
|
30
|
+
|
|
31
|
+
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
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return installViaNpm(opts);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function installViaNpm({ prefix, specs, verbose, registry }: InstallOptions): Promise<void> {
|
|
41
|
+
if (specs.length === 0) {
|
|
42
|
+
throw new Error('installPackages: empty specs list');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Seed an empty package.json so npm doesn't walk up from prefix and pick
|
|
46
|
+
// up the user's project metadata. Cosmetic name/version only.
|
|
47
|
+
writeFileSync(
|
|
48
|
+
join(prefix, 'package.json'),
|
|
49
|
+
JSON.stringify({ name: 'gjsify-dlx-cache', version: '0.0.0', private: true }, null, 2),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (registry) {
|
|
53
|
+
writeFileSync(join(prefix, '.npmrc'), `registry=${registry}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const args = [
|
|
57
|
+
'install',
|
|
58
|
+
'--no-package-lock',
|
|
59
|
+
'--no-audit',
|
|
60
|
+
'--no-fund',
|
|
61
|
+
'--prefix', prefix,
|
|
62
|
+
...(verbose ? ['--loglevel', 'verbose'] : ['--loglevel', 'warn']),
|
|
63
|
+
...specs,
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
await new Promise<void>((resolve, reject) => {
|
|
67
|
+
const child = spawn('npm', args, { stdio: 'inherit' });
|
|
68
|
+
child.on('close', (code) => {
|
|
69
|
+
if (code === 0) resolve();
|
|
70
|
+
else reject(new Error(`npm install exited with code ${code}`));
|
|
71
|
+
});
|
|
72
|
+
child.on('error', (err) => {
|
|
73
|
+
const msg = (err as NodeJS.ErrnoException).code === 'ENOENT'
|
|
74
|
+
? 'npm not found on PATH — install Node.js or set GJSIFY_INSTALL_BACKEND=native (not yet supported)'
|
|
75
|
+
: `npm install failed: ${err.message}`;
|
|
76
|
+
reject(new Error(msg));
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Parse a `gjsify dlx` package spec — distinguishes local paths from npm specs.
|
|
2
|
+
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export type ParsedSpec =
|
|
7
|
+
| { kind: 'local'; path: string }
|
|
8
|
+
| { kind: 'registry'; name: string; version?: string; spec: string };
|
|
9
|
+
|
|
10
|
+
const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse a CLI input into either a local-path or an npm-registry spec.
|
|
14
|
+
*
|
|
15
|
+
* Local: starts with `./`, `../`, `/`, or is an existing directory path.
|
|
16
|
+
* Registry: `<name>` | `<name>@<version>` | `@scope/<name>` | `@scope/<name>@<version>`
|
|
17
|
+
*
|
|
18
|
+
* The full spec string is preserved on the registry case so it can be passed
|
|
19
|
+
* verbatim to `npm install` (which already understands dist-tags, ranges,
|
|
20
|
+
* git URIs, tarball URLs, etc. — we don't re-parse those).
|
|
21
|
+
*/
|
|
22
|
+
export function parseSpec(input: string): ParsedSpec {
|
|
23
|
+
if (!input) throw new Error('dlx: empty package spec');
|
|
24
|
+
|
|
25
|
+
if (input.startsWith('./') || input.startsWith('../') || isAbsolute(input)) {
|
|
26
|
+
return { kind: 'local', path: resolve(input) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (existsSync(input)) {
|
|
30
|
+
return { kind: 'local', path: resolve(input) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Registry spec: split off the version after the LAST `@` that isn't the
|
|
34
|
+
// leading scope separator.
|
|
35
|
+
let name = input;
|
|
36
|
+
let version: string | undefined;
|
|
37
|
+
const lastAt = input.lastIndexOf('@');
|
|
38
|
+
if (lastAt > 0) {
|
|
39
|
+
name = input.slice(0, lastAt);
|
|
40
|
+
version = input.slice(lastAt + 1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (!NPM_NAME_RE.test(name)) {
|
|
44
|
+
throw new Error(`dlx: invalid package name "${name}"`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { kind: 'registry', name, version, spec: input };
|
|
48
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Resolve the GJS entry point of an installed package.
|
|
2
|
+
//
|
|
3
|
+
// Per the `gjsify` field convention (see CLI reference):
|
|
4
|
+
//
|
|
5
|
+
// {
|
|
6
|
+
// "gjsify": {
|
|
7
|
+
// "main": "dist/gjs.js",
|
|
8
|
+
// "bin": { "name-a": "dist/a.js", "name-b": "dist/b.js" }
|
|
9
|
+
// }
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// Resolution order:
|
|
13
|
+
// 1. user-supplied bin name + `gjsify.bin[name]` → that path
|
|
14
|
+
// 2. single-entry `gjsify.bin` → the only path
|
|
15
|
+
// 3. `gjsify.main` → that path
|
|
16
|
+
// 4. fallback: `package.json#main` → that path (advisory warning)
|
|
17
|
+
// 5. otherwise: hard-fail with a fix hint
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { existsSync } from 'node:fs';
|
|
21
|
+
import { join, resolve } from 'node:path';
|
|
22
|
+
|
|
23
|
+
interface ResolvedEntry {
|
|
24
|
+
bundlePath: string;
|
|
25
|
+
binName: string | null;
|
|
26
|
+
fromFallback: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PackageJson {
|
|
30
|
+
name?: string;
|
|
31
|
+
main?: string;
|
|
32
|
+
gjsify?: {
|
|
33
|
+
main?: string;
|
|
34
|
+
bin?: Record<string, string>;
|
|
35
|
+
prebuilds?: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resolveGjsEntry(
|
|
40
|
+
pkgDir: string,
|
|
41
|
+
binName: string | null,
|
|
42
|
+
): ResolvedEntry {
|
|
43
|
+
const pkgJsonPath = join(pkgDir, 'package.json');
|
|
44
|
+
if (!existsSync(pkgJsonPath)) {
|
|
45
|
+
throw new Error(`dlx: no package.json found at ${pkgDir}`);
|
|
46
|
+
}
|
|
47
|
+
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as PackageJson;
|
|
48
|
+
|
|
49
|
+
const gjsifyBin = pkg.gjsify?.bin;
|
|
50
|
+
const gjsifyMain = pkg.gjsify?.main;
|
|
51
|
+
const fallbackMain = pkg.main;
|
|
52
|
+
|
|
53
|
+
let entry: string | undefined;
|
|
54
|
+
let resolvedBin: string | null = null;
|
|
55
|
+
let fromFallback = false;
|
|
56
|
+
|
|
57
|
+
if (binName !== null) {
|
|
58
|
+
if (!gjsifyBin || !gjsifyBin[binName]) {
|
|
59
|
+
const known = gjsifyBin ? Object.keys(gjsifyBin).join(', ') : '(none)';
|
|
60
|
+
throw new Error(
|
|
61
|
+
`dlx: package "${pkg.name ?? pkgDir}" has no GJS bin named "${binName}" — known: ${known}`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
entry = gjsifyBin[binName];
|
|
65
|
+
resolvedBin = binName;
|
|
66
|
+
} else if (gjsifyBin && Object.keys(gjsifyBin).length === 1) {
|
|
67
|
+
const onlyBin = Object.keys(gjsifyBin)[0];
|
|
68
|
+
entry = gjsifyBin[onlyBin];
|
|
69
|
+
resolvedBin = onlyBin;
|
|
70
|
+
} else if (gjsifyMain) {
|
|
71
|
+
entry = gjsifyMain;
|
|
72
|
+
} else if (fallbackMain) {
|
|
73
|
+
entry = fallbackMain;
|
|
74
|
+
fromFallback = true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (gjsifyBin && Object.keys(gjsifyBin).length > 1 && binName === null) {
|
|
78
|
+
const names = Object.keys(gjsifyBin).join(', ');
|
|
79
|
+
throw new Error(
|
|
80
|
+
`dlx: package "${pkg.name ?? pkgDir}" defines multiple GJS bins — pass one of: ${names}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!entry) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`dlx: package "${pkg.name ?? pkgDir}" has no GJS entry — set \`gjsify.main\` (or \`gjsify.bin\`) in its package.json`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const bundlePath = resolve(pkgDir, entry);
|
|
91
|
+
if (!existsSync(bundlePath)) {
|
|
92
|
+
throw new Error(`dlx: GJS entry not found: ${bundlePath}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { bundlePath, binName: resolvedBin, fromFallback };
|
|
96
|
+
}
|