@gjsify/cli 0.4.34 → 0.4.36

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.
@@ -26,6 +26,7 @@ import { promises as fs } from 'node:fs';
26
26
  import * as path from 'node:path';
27
27
  import { createRequire } from 'node:module';
28
28
  import { pathToFileURL } from 'node:url';
29
+ import { resolveNpmPackage } from './utils/resolve-npm-package.js';
29
30
  // npm `rolldown` is a Rust crate with platform-specific prebuilds; loading
30
31
  // it eagerly at module init pulls musl-detection code that does
31
32
  // `require('node:fs')` synchronously — fine on Node, but fatal under GJS
@@ -36,9 +37,41 @@ async function loadNpmRolldown() {
36
37
  // Indirect specifier so Rolldown's static-analysis doesn't try to
37
38
  // bundle the npm crate into a GJS target build.
38
39
  const specifier = 'rolldown';
39
- const mod = (await import(/* @vite-ignore */ specifier));
40
+ const target = resolveImportTargetForGjs(specifier);
41
+ const mod = (await import(/* @vite-ignore */ target));
40
42
  return mod.rolldown;
41
43
  }
44
+ /**
45
+ * Convert a bare npm specifier into something dynamic `import(...)` can
46
+ * load regardless of host runtime:
47
+ *
48
+ * - Node has a native node_modules resolver — return the specifier
49
+ * unchanged.
50
+ * - GJS's native ESM loader has no node_modules walker, so we resolve
51
+ * the specifier through multiple `createRequire` anchors (cwd,
52
+ * workspace root, bundle URL, parent-dir walk, `GJSIFY_NODE_PATH`)
53
+ * and dynamic-import the resulting `file://` URL.
54
+ *
55
+ * Falls back to the bare specifier when every anchor misses so the
56
+ * host runtime's loader surfaces its native error path instead of a
57
+ * silent synth from this helper.
58
+ *
59
+ * The bundle-URL anchor (`import.meta.url`) is critical for the case
60
+ * where the install lives next to the bundle but the user invokes
61
+ * `gjs -m <install>/dist/cli.gjs.mjs build …` from a completely
62
+ * unrelated cwd — without it, the createRequire walk anchored at the
63
+ * cwd's `node_modules` chain misses, and we'd throw `Module not found:
64
+ * rolldown` even though the package is present under the install dir.
65
+ */
66
+ function resolveImportTargetForGjs(specifier) {
67
+ const isGjs = typeof globalThis.imports?.gi !== 'undefined';
68
+ if (!isGjs)
69
+ return specifier;
70
+ const resolved = resolveNpmPackage(specifier, { bundleUrl: import.meta.url });
71
+ if (resolved)
72
+ return pathToFileURL(resolved).href;
73
+ return specifier;
74
+ }
42
75
  /**
43
76
  * In-memory bundle used by `--globals auto` for AST-driven detection.
44
77
  * Mirrors the shape of `AnalysisBundler` in
@@ -99,7 +132,8 @@ export async function runWatch(finalOpts) {
99
132
  'under Node (`node lib/index.js build … --watch`) or set `GJSIFY_BUNDLER=npm`.');
100
133
  }
101
134
  const specifier = 'rolldown';
102
- const mod = (await import(/* @vite-ignore */ specifier));
135
+ const target = resolveImportTargetForGjs(specifier);
136
+ const mod = (await import(/* @vite-ignore */ target));
103
137
  const output = finalOpts.output ?? {};
104
138
  return mod.watch({ ...finalOpts, output });
105
139
  }
@@ -170,8 +204,13 @@ async function tryLoadNative() {
170
204
  const specifier = '@gjsify/rolldown-native';
171
205
  let target = specifier;
172
206
  if (isGjs) {
173
- const require = createRequire(import.meta.url);
174
- const resolved = require.resolve(specifier);
207
+ // Same multi-anchor resolution as `loadNpmRolldown` —
208
+ // when the bundle is invoked from a cwd outside the
209
+ // install dir, anchoring solely at `import.meta.url`
210
+ // misses node_modules layouts where the user's cwd
211
+ // or the workspace root carries the package instead.
212
+ const resolved = resolveNpmPackage(specifier, { bundleUrl: import.meta.url }) ??
213
+ createRequire(import.meta.url).resolve(specifier);
175
214
  target = pathToFileURL(resolved).href;
176
215
  }
177
216
  const mod = (await import(/* @vite-ignore */ target));
@@ -0,0 +1,10 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface AffectedOptions {
3
+ base?: string;
4
+ head?: string;
5
+ format: 'text' | 'json' | 'globs' | 'github-actions';
6
+ 'changed-from-stdin': boolean;
7
+ cwd?: string;
8
+ }
9
+ export declare const affectedCommand: Command<unknown, AffectedOptions>;
10
+ export {};
@@ -0,0 +1,303 @@
1
+ // `gjsify affected --base <sha> [--head <ref>] [--format=...]`
2
+ //
3
+ // Diffs the working tree against `<base>` and emits the set of workspaces
4
+ // that are AFFECTED by the change set — i.e. seeds plus everything that
5
+ // transitively depends on them. CI uses the output as the `--include`
6
+ // filter for `gjsify foreach test`, so a typical single-package PR ends
7
+ // up running the touched workspace + its downstream consumers instead of
8
+ // the whole monorepo.
9
+ //
10
+ // Classifier table (first-match wins) handles the cases that aren't a
11
+ // straightforward "file lives in workspace X":
12
+ //
13
+ // 1. GLOBAL_TRIGGERS — change touches infra the classifier itself
14
+ // depends on (`@gjsify/workspace`, `@gjsify/cli`, the bundler
15
+ // plugins, the lockfile, root tsconfig / package.json, this very
16
+ // workflow file). Emits `global=true` → CI must run the full
17
+ // suite. We can't trust the closure when the algorithm that
18
+ // computes it just changed.
19
+ //
20
+ // 2. IGNORE — pure-docs / website / refs/ submodule / unrelated
21
+ // workflow files. Discard; do not contribute to seeds.
22
+ //
23
+ // 3. TEST_ONLY — every changed file under ONE workspace is a spec
24
+ // file, e2e fixture, or integration test. Seed = that workspace
25
+ // but SKIP the closure expansion (downstream consumers don't care
26
+ // about test code changes).
27
+ //
28
+ // 4. CODE (default) — `workspacesForChangedFiles` maps file → ws,
29
+ // then `affectedClosure` walks reverse-dep edges.
30
+ //
31
+ // Integration tests are gated separately: they run when any workspace
32
+ // in the closure appears as a `dependencies` entry of any
33
+ // `@gjsify/integration-*` workspace, OR on a `globalTrigger`. Same for
34
+ // e2e: any infra change OR explicit `tests/e2e/**` touch turns the e2e
35
+ // gate on.
36
+ //
37
+ // Output formats:
38
+ //
39
+ // --format=text (default) human-readable summary
40
+ // --format=json { global, workspaces[], runIntegration, runE2E, skipAll, reason }
41
+ // --format=globs one `@gjsify/<name>` per line
42
+ // --format=github-actions $GITHUB_OUTPUT key=value lines
43
+ //
44
+ // `--changed-from-stdin` skips `git diff` entirely and reads a newline-
45
+ // separated list of paths from stdin. Useful for local debugging and
46
+ // for the spec suite.
47
+ import { spawnSync } from 'node:child_process';
48
+ import { readFileSync } from 'node:fs';
49
+ import { discoverWorkspaces, buildDependencyGraph, buildReverseDependencyGraph, affectedClosure, workspacesForChangedFiles, } from '@gjsify/workspace';
50
+ import { findWorkspaceRoot } from '../utils/workspace-root.js';
51
+ export const affectedCommand = {
52
+ command: 'affected',
53
+ description: 'Classify changed files against the workspace tree and print the set of workspaces affected (seeds + transitive dependents). Designed for CI to gate `gjsify foreach test --include …` so unrelated workspaces are not re-tested on every PR.',
54
+ builder: (yargs) => yargs
55
+ .option('base', {
56
+ description: 'Diff base. Default: `origin/main`. Resolved via `git rev-parse`. On a PR set this to `${{ github.event.pull_request.base.sha }}`.',
57
+ type: 'string',
58
+ })
59
+ .option('head', {
60
+ description: 'Diff head. Default: `HEAD`.',
61
+ type: 'string',
62
+ default: 'HEAD',
63
+ })
64
+ .option('format', {
65
+ description: 'Output shape.',
66
+ choices: ['text', 'json', 'globs', 'github-actions'],
67
+ default: 'text',
68
+ })
69
+ .option('changed-from-stdin', {
70
+ description: 'Skip `git diff`. Read a newline-separated list of repo-relative paths from stdin instead. Lets callers — tests, ad-hoc scripts — control the input exactly.',
71
+ type: 'boolean',
72
+ default: false,
73
+ })
74
+ .option('cwd', {
75
+ description: 'Workspace root. Default: discovered from `process.cwd()`.',
76
+ type: 'string',
77
+ }),
78
+ handler: async (args) => {
79
+ const rootDir = args.cwd ?? findWorkspaceRoot(process.cwd()) ?? process.cwd();
80
+ const workspaces = discoverWorkspaces(rootDir, { includeRoot: true });
81
+ const changedFiles = args['changed-from-stdin']
82
+ ? readStdinLines()
83
+ : runGitDiff(rootDir, args.base ?? 'origin/main', args.head ?? 'HEAD');
84
+ const result = classifyAndExpand(workspaces, rootDir, changedFiles);
85
+ emit(args.format, result);
86
+ },
87
+ };
88
+ /** Patterns that force a full run. First-match wins; order is intentional. */
89
+ const GLOBAL_TRIGGERS = [
90
+ // The classifier itself + everything in its plumbing.
91
+ /^packages\/infra\/workspace\//,
92
+ /^packages\/infra\/cli\//,
93
+ /^packages\/infra\/rolldown-plugin-gjsify\//,
94
+ /^packages\/infra\/resolve-npm\//,
95
+ // Cross-cutting dep + lockfile + root config.
96
+ /^gjsify-lock\.json$/,
97
+ /^package\.json$/,
98
+ /^tsconfig[^/]*\.json$/,
99
+ // The workflow file itself — a job-shape change is invisible until
100
+ // the workflow re-runs, so a path-filtered job can't safely apply
101
+ // the new shape to an in-flight PR.
102
+ /^\.github\/workflows\/main\.yml$/,
103
+ /^scripts\/audit-runtimes\.mjs$/,
104
+ ];
105
+ /** Patterns that contribute no seed and don't force a full run. */
106
+ const IGNORE = [
107
+ /\.md$/i,
108
+ /^refs\//,
109
+ /^website\//,
110
+ /^docs\//,
111
+ /^\.github\/workflows\/(deploy-docs|commitlint|release|audit-runtimes|prebuilds)\.yml$/,
112
+ /^\.githooks\//,
113
+ /^LICENSE/,
114
+ /^\.gitignore$/,
115
+ /^\.gjsify-[^/]*\.md$/,
116
+ /^STATUS\.md$/,
117
+ /^CHANGELOG\.md$/,
118
+ /^AGENTS\.md$/,
119
+ /^CLAUDE\.md$/,
120
+ /^README\.md$/,
121
+ ];
122
+ /** Patterns that suggest a test-only change. */
123
+ const TEST_PATHS = [/\.spec\.[mc]?[tj]sx?$/, /^tests\/(e2e|integration)\//];
124
+ function classifyAndExpand(workspaces, rootDir, changedFiles) {
125
+ const files = changedFiles.map((f) => f.replace(/\\/g, '/')).filter((f) => f.length > 0);
126
+ if (files.length === 0) {
127
+ return {
128
+ global: false,
129
+ reason: 'empty-diff',
130
+ workspaces: [],
131
+ runE2E: false,
132
+ runIntegration: false,
133
+ skipAll: true,
134
+ };
135
+ }
136
+ // Global triggers short-circuit immediately.
137
+ for (const f of files) {
138
+ for (const re of GLOBAL_TRIGGERS) {
139
+ if (re.test(f)) {
140
+ return {
141
+ global: true,
142
+ reason: `global-trigger ${re.source} matched ${f}`,
143
+ workspaces: workspaces.map((w) => w.name),
144
+ runE2E: true,
145
+ runIntegration: true,
146
+ skipAll: false,
147
+ };
148
+ }
149
+ }
150
+ }
151
+ // Drop ignored files; collect the remainder.
152
+ const remaining = [];
153
+ for (const f of files) {
154
+ if (IGNORE.some((re) => re.test(f)))
155
+ continue;
156
+ remaining.push(f);
157
+ }
158
+ if (remaining.length === 0) {
159
+ return {
160
+ global: false,
161
+ reason: 'ignored-only',
162
+ workspaces: [],
163
+ runE2E: false,
164
+ runIntegration: false,
165
+ skipAll: true,
166
+ };
167
+ }
168
+ // Map files → workspaces. Files outside any workspace stay in `unmatched`.
169
+ const { matched, unmatched } = workspacesForChangedFiles(workspaces, rootDir, remaining);
170
+ // Unmatched-but-not-ignored files are suspicious enough to fall back to
171
+ // the conservative "full run" path. Examples: a new top-level dotfile,
172
+ // a script in `scripts/` we haven't carved out, a refs/-adjacent file.
173
+ if (unmatched.length > 0) {
174
+ return {
175
+ global: true,
176
+ reason: `unmatched files (${unmatched.length}): ${unmatched.slice(0, 3).join(', ')}${unmatched.length > 3 ? '…' : ''}`,
177
+ workspaces: workspaces.map((w) => w.name),
178
+ runE2E: true,
179
+ runIntegration: true,
180
+ skipAll: false,
181
+ };
182
+ }
183
+ // TEST_ONLY shortcut: every remaining file is a spec / e2e / integration
184
+ // path, all under ONE workspace. Skip the closure expansion — test code
185
+ // has no downstream consumers.
186
+ const testOnly = remaining.every((f) => TEST_PATHS.some((re) => re.test(f)));
187
+ if (testOnly && matched.size === 1) {
188
+ const only = [...matched][0];
189
+ // E2E or integration test-only changes still need their own job.
190
+ const touchedE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
191
+ const touchedIntegration = remaining.some((f) => f.startsWith('tests/integration/'));
192
+ return {
193
+ global: false,
194
+ reason: `test-only (${remaining.length} file(s) in ${only})`,
195
+ workspaces: [only],
196
+ runE2E: touchedE2E,
197
+ runIntegration: touchedIntegration || isIntegrationWorkspace(only),
198
+ skipAll: false,
199
+ };
200
+ }
201
+ // Default: closure walk.
202
+ const reverse = buildReverseDependencyGraph(workspaces, { includeDev: true });
203
+ const closure = affectedClosure(reverse, [...matched]);
204
+ // Integration suites whose forward deps overlap with the closure also
205
+ // need to run. We walk the forward graph and pull any integration ws
206
+ // that depends on something inside the closure.
207
+ const forward = buildDependencyGraph(workspaces, { includeDev: true });
208
+ let runIntegration = false;
209
+ for (const [from, deps] of forward.edges) {
210
+ if (!isIntegrationWorkspace(from))
211
+ continue;
212
+ for (const dep of deps) {
213
+ if (closure.has(dep)) {
214
+ closure.add(from);
215
+ runIntegration = true;
216
+ break;
217
+ }
218
+ }
219
+ }
220
+ const runE2E = remaining.some((f) => f.startsWith('tests/e2e/'));
221
+ return {
222
+ global: false,
223
+ reason: `closure (${closure.size} ws from ${matched.size} seed(s))`,
224
+ workspaces: [...closure].sort(),
225
+ runE2E,
226
+ runIntegration,
227
+ skipAll: false,
228
+ };
229
+ }
230
+ function isIntegrationWorkspace(name) {
231
+ return name.startsWith('@gjsify/integration-');
232
+ }
233
+ // ─── git diff + stdin ──────────────────────────────────────────────────────
234
+ function runGitDiff(cwd, base, head) {
235
+ // `git diff --name-only base...head` lists changed paths on `head`
236
+ // relative to the merge-base. That matches what GitHub PR diffs show
237
+ // and survives stacked PRs without picking up commits from base.
238
+ const r = spawnSync('git', ['diff', '--name-only', `${base}...${head}`], {
239
+ cwd,
240
+ encoding: 'utf8',
241
+ });
242
+ if (r.status !== 0) {
243
+ // Surface a clear error — caller (CI) will fall back to full run
244
+ // via `continue-on-error: true` on the classify step.
245
+ process.stderr.write(`gjsify affected: git diff failed (${r.status}): ${r.stderr.trim()}\n`);
246
+ process.exit(2);
247
+ }
248
+ return r.stdout.split('\n').filter(Boolean);
249
+ }
250
+ function readStdinLines() {
251
+ const data = readFileSync(0, 'utf8');
252
+ return data.split('\n').map((s) => s.trim()).filter(Boolean);
253
+ }
254
+ // ─── Output ────────────────────────────────────────────────────────────────
255
+ function emit(format, r) {
256
+ if (format === 'json') {
257
+ process.stdout.write(JSON.stringify(r) + '\n');
258
+ return;
259
+ }
260
+ if (format === 'globs') {
261
+ for (const name of r.workspaces)
262
+ process.stdout.write(`${name}\n`);
263
+ return;
264
+ }
265
+ if (format === 'github-actions') {
266
+ const includeArgs = r.global
267
+ ? ''
268
+ : r.workspaces.map((n) => `--include '${escSingleQuote(n)}'`).join(' ');
269
+ const out = process.env.GITHUB_OUTPUT;
270
+ const lines = [
271
+ `skip-all=${r.skipAll}`,
272
+ `global=${r.global}`,
273
+ `include-args=${includeArgs}`,
274
+ `run-integration=${r.runIntegration}`,
275
+ `run-e2e=${r.runE2E}`,
276
+ `reason=${r.reason}`,
277
+ ];
278
+ if (out) {
279
+ // GitHub Actions: append to $GITHUB_OUTPUT.
280
+ const { appendFileSync } = require('node:fs');
281
+ for (const l of lines)
282
+ appendFileSync(out, `${l}\n`);
283
+ }
284
+ else {
285
+ for (const l of lines)
286
+ process.stdout.write(`${l}\n`);
287
+ }
288
+ return;
289
+ }
290
+ // text (default)
291
+ process.stdout.write(`affected:\n`);
292
+ process.stdout.write(` reason: ${r.reason}\n`);
293
+ process.stdout.write(` global: ${r.global}\n`);
294
+ process.stdout.write(` skip-all: ${r.skipAll}\n`);
295
+ process.stdout.write(` run-integration: ${r.runIntegration}\n`);
296
+ process.stdout.write(` run-e2e: ${r.runE2E}\n`);
297
+ process.stdout.write(` workspaces (${r.workspaces.length}):\n`);
298
+ for (const name of r.workspaces)
299
+ process.stdout.write(` - ${name}\n`);
300
+ }
301
+ function escSingleQuote(s) {
302
+ return s.replace(/'/g, `'\\''`);
303
+ }
@@ -41,7 +41,7 @@ export const buildCommand = {
41
41
  .option('app', {
42
42
  description: 'Use this if you want to build an application, the platform node is usually only used for tests',
43
43
  type: 'string',
44
- choices: ['gjs', 'node', 'browser'],
44
+ choices: ['gjs', 'node', 'browser', 'nativescript'],
45
45
  normalize: true,
46
46
  default: 'gjs',
47
47
  })
@@ -14,7 +14,7 @@ import { runGjsBundle } from '../utils/run-gjs.js';
14
14
  import { parseSpec } from '../utils/parse-spec.js';
15
15
  import { resolveGjsEntry } from '../utils/resolve-gjs-entry.js';
16
16
  import { cacheDirFor, createCacheKey, getValidCachedPkg, makePrepareDir, resolveInstalledPkgDir, symlinkSwap, } from '../utils/dlx-cache.js';
17
- import { installPackages } from '../utils/install-backend.js';
17
+ import { installPackages, makeProgressReporter } from '../utils/install-backend.js';
18
18
  export const dlxCommand = {
19
19
  command: 'dlx <spec> [binOrArg] [extraArgs..]',
20
20
  description: 'Run the GJS bundle of an npm-published package without installing it locally.',
@@ -108,6 +108,12 @@ async function ensurePkgDir(parsed, opts) {
108
108
  };
109
109
  }
110
110
  const prepareDir = makePrepareDir(cacheDir);
111
+ // First-run dlx (cache miss) downloads & extracts the package + its tree.
112
+ // For `npx @gjsify/cli showcase excalibur-jelly-jumper` and similar first-
113
+ // contact entry points the user otherwise sees no feedback for 10+ seconds
114
+ // (cold packument fetch + tarball extract + prebuild detection); the
115
+ // progress reporter renders a live bar via stderr.
116
+ const progress = makeProgressReporter({ enabled: !opts.verbose });
111
117
  await installPackages({
112
118
  prefix: prepareDir,
113
119
  specs: [parsed.spec],
@@ -118,6 +124,7 @@ async function ensurePkgDir(parsed, opts) {
118
124
  // and lets `--frozen` short-circuit the resolver entirely.
119
125
  lockfile: true,
120
126
  frozen: opts.frozen,
127
+ progress,
121
128
  });
122
129
  const liveTarget = symlinkSwap(cacheDir, prepareDir);
123
130
  return {
@@ -76,6 +76,14 @@ export function renderDesktop(inputs) {
76
76
  const f = inputs.flatpak;
77
77
  const categoriesLine = (f.categories ?? ['Utility']).join(';') + ';';
78
78
  const keywordsLine = f.keywords?.length ? `Keywords=${f.keywords.join(';')};\n` : '';
79
+ // `MimeType=` is sourced from `gjsify.flatpak.provides.mimetypes` —
80
+ // the same field already populates `<mediatype>` entries in the
81
+ // MetaInfo XML, so callers configure both with one knob. Typical
82
+ // entries: `x-scheme-handler/<scheme>` for URL-scheme handlers
83
+ // (Flatpak portal: chats / browsers can launch the app via a
84
+ // custom URL), or `application/<mime>` for file-type handlers.
85
+ const mimetypes = f.provides?.mimetypes ?? [];
86
+ const mimetypesLine = mimetypes.length ? `MimeType=${mimetypes.join(';')};\n` : '';
79
87
  return substitute(loadDesktopTemplate(), {
80
88
  NAME: inputs.name,
81
89
  SUMMARY: f.summary ?? inputs.name,
@@ -83,6 +91,7 @@ export function renderDesktop(inputs) {
83
91
  APP_ID: inputs.appId,
84
92
  CATEGORIES_LINE: categoriesLine,
85
93
  KEYWORDS_LINE: keywordsLine,
94
+ MIMETYPES_LINE: mimetypesLine,
86
95
  });
87
96
  }
88
97
  /** Render the flathub.json policy file. */
@@ -16,6 +16,7 @@ export * from './foreach.js';
16
16
  export * from './workspace.js';
17
17
  export * from './pack.js';
18
18
  export * from './publish.js';
19
+ export * from './whoami.js';
19
20
  export * from './self-update.js';
20
21
  export * from './generate-installer.js';
21
22
  export * from './uninstall.js';
@@ -24,3 +25,5 @@ export * from './lint.js';
24
25
  export * from './fix.js';
25
26
  export * from './upgrade.js';
26
27
  export * from './barrels.js';
28
+ export * from './tsc.js';
29
+ export * from './affected.js';
@@ -16,6 +16,7 @@ export * from './foreach.js';
16
16
  export * from './workspace.js';
17
17
  export * from './pack.js';
18
18
  export * from './publish.js';
19
+ export * from './whoami.js';
19
20
  export * from './self-update.js';
20
21
  export * from './generate-installer.js';
21
22
  export * from './uninstall.js';
@@ -24,3 +25,5 @@ export * from './lint.js';
24
25
  export * from './fix.js';
25
26
  export * from './upgrade.js';
26
27
  export * from './barrels.js';
28
+ export * from './tsc.js';
29
+ export * from './affected.js';
@@ -7,7 +7,10 @@ interface InstallOptions {
7
7
  'save-optional'?: boolean;
8
8
  immutable?: boolean;
9
9
  verbose: boolean;
10
+ quiet?: boolean;
11
+ progress?: boolean;
10
12
  backend?: 'native' | 'npm';
13
+ timeout: number;
11
14
  }
12
15
  export declare const installCommand: Command<unknown, InstallOptions>;
13
16
  export {};