@gjsify/cli 0.4.13 → 0.4.15

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.
Files changed (41) hide show
  1. package/dist/cli.gjs.mjs +86 -78
  2. package/lib/actions/barrels-generate.d.ts +31 -0
  3. package/lib/actions/barrels-generate.js +78 -0
  4. package/lib/actions/build.d.ts +11 -1
  5. package/lib/actions/build.js +79 -3
  6. package/lib/bundler-pick.d.ts +7 -0
  7. package/lib/bundler-pick.js +17 -0
  8. package/lib/commands/barrels.d.ts +15 -0
  9. package/lib/commands/barrels.js +103 -0
  10. package/lib/commands/build.js +8 -0
  11. package/lib/commands/fix.d.ts +9 -0
  12. package/lib/commands/fix.js +60 -0
  13. package/lib/commands/flatpak/diff.d.ts +12 -0
  14. package/lib/commands/flatpak/diff.js +165 -0
  15. package/lib/commands/flatpak/index.d.ts +4 -1
  16. package/lib/commands/flatpak/index.js +11 -5
  17. package/lib/commands/flatpak/init.d.ts +1 -0
  18. package/lib/commands/flatpak/init.js +39 -5
  19. package/lib/commands/flatpak/release.d.ts +13 -0
  20. package/lib/commands/flatpak/release.js +152 -0
  21. package/lib/commands/flatpak/sync-flathub.d.ts +26 -0
  22. package/lib/commands/flatpak/sync-flathub.js +311 -0
  23. package/lib/commands/format.d.ts +12 -0
  24. package/lib/commands/format.js +98 -0
  25. package/lib/commands/index.d.ts +6 -0
  26. package/lib/commands/index.js +6 -0
  27. package/lib/commands/install.js +11 -9
  28. package/lib/commands/lint.d.ts +9 -0
  29. package/lib/commands/lint.js +60 -0
  30. package/lib/commands/test.d.ts +12 -0
  31. package/lib/commands/test.js +206 -0
  32. package/lib/commands/upgrade.d.ts +13 -0
  33. package/lib/commands/upgrade.js +402 -0
  34. package/lib/index.js +7 -1
  35. package/lib/templates/biome.json.tmpl +79 -0
  36. package/lib/types/cli-build-options.d.ts +7 -0
  37. package/lib/types/config-data.d.ts +39 -0
  38. package/lib/utils/biome-resolve.d.ts +47 -0
  39. package/lib/utils/biome-resolve.js +204 -0
  40. package/package.json +16 -16
  41. package/showcases.json +14 -0
@@ -0,0 +1,31 @@
1
+ export type BarrelExtension = 'js' | 'ts' | 'none';
2
+ export interface BarrelsArgs {
3
+ /** Directories to scan. Each gets an `index.ts` re-exporting its sibling source files. */
4
+ paths: string[];
5
+ /** Import-specifier extension. `js`/`ts`/`none`. */
6
+ extension: BarrelExtension;
7
+ /** Resolve `paths` against this directory. Default: `process.cwd()`. */
8
+ baseDir: string;
9
+ /** Skip files whose names match any regex. */
10
+ exclude: RegExp[];
11
+ /** Header comment prepended to every generated file. */
12
+ header: string;
13
+ /** Omit trailing `;` on each export line. */
14
+ noSemicolon: boolean;
15
+ /** Use `'` quotes (default). When false, uses `"`. */
16
+ singleQuotes: boolean;
17
+ /** Report drift without writing. Returns drift count from generator. */
18
+ check: boolean;
19
+ /** Log each file scanned + written. */
20
+ verbose: boolean;
21
+ }
22
+ export declare const DEFAULT_BARRELS_HEADER = "// Auto-generated by `gjsify barrels` \u2014 do not edit by hand.";
23
+ export declare const DEFAULT_BARRELS_EXCLUDES: readonly string[];
24
+ /**
25
+ * Regenerate `index.ts` in every directory in `args.paths`.
26
+ *
27
+ * Returns the number of files that drifted from the canonical output —
28
+ * always 0 when `check` is false (drift is rewritten in-place); non-zero
29
+ * under `check: true` signals CI failure.
30
+ */
31
+ export declare function generateBarrels(args: BarrelsArgs): Promise<number>;
@@ -0,0 +1,78 @@
1
+ // `gjsify barrels` action — regenerate `index.ts` barrel files for one or
2
+ // more directories. Pure async generator, decoupled from yargs so it can
3
+ // be unit-tested in isolation.
4
+ //
5
+ // Adapted from beabee-communityrm/monorepo apps/dev-cli's `generate-index`
6
+ // action (refs/dev-cli — not vendored here): same separation of action vs.
7
+ // command module, same `types/`-dir auto-detection that emits
8
+ // `export type *`, same `export {};` fallback for empty directories,
9
+ // same sorted-output principle. Rewritten for gjsify's yargs CommandModule
10
+ // and `@gjsify/unit` test framework; no external deps. Replaces
11
+ // `barrelsby` (unmaintained since 2022) at consumer sites.
12
+ //
13
+ // Original: Copyright (c) Beabee Community Repo contributors. AGPL-3.0.
14
+ // Reimplemented for gjsify under the project's MIT license.
15
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
16
+ import { basename, extname, join, resolve } from 'node:path';
17
+ export const DEFAULT_BARRELS_HEADER = '// Auto-generated by `gjsify barrels` — do not edit by hand.';
18
+ export const DEFAULT_BARRELS_EXCLUDES = [
19
+ '\\.test\\.',
20
+ '\\.spec\\.',
21
+ '\\.test-data\\.',
22
+ ];
23
+ const SOURCE_FILE_RE = /\.(ts|tsx|mts|cts)$/;
24
+ /**
25
+ * Regenerate `index.ts` in every directory in `args.paths`.
26
+ *
27
+ * Returns the number of files that drifted from the canonical output —
28
+ * always 0 when `check` is false (drift is rewritten in-place); non-zero
29
+ * under `check: true` signals CI failure.
30
+ */
31
+ export async function generateBarrels(args) {
32
+ const quote = args.singleQuotes ? "'" : '"';
33
+ const semicolon = args.noSemicolon ? '' : ';';
34
+ const extSuffix = args.extension === 'none' ? '' : `.${args.extension}`;
35
+ let drift = 0;
36
+ for (const p of args.paths) {
37
+ const dir = resolve(args.baseDir, p);
38
+ const isTypesDir = basename(dir) === 'types';
39
+ let entries;
40
+ try {
41
+ entries = (await readdir(dir, { withFileTypes: true }))
42
+ .filter((e) => e.isFile())
43
+ .filter((e) => SOURCE_FILE_RE.test(e.name))
44
+ .filter((e) => e.name !== 'index.ts')
45
+ .filter((e) => !args.exclude.some((r) => r.test(e.name)))
46
+ .sort((a, b) => a.name.localeCompare(b.name));
47
+ }
48
+ catch (err) {
49
+ if (args.verbose)
50
+ console.error(`[gjsify barrels] skip ${dir}: ${err.message}`);
51
+ continue;
52
+ }
53
+ const lines = entries.map((e) => {
54
+ const stem = basename(e.name, extname(e.name));
55
+ const keyword = isTypesDir ? 'export type *' : 'export *';
56
+ return `${keyword} from ${quote}./${stem}${extSuffix}${quote}${semicolon}`;
57
+ });
58
+ const body = lines.length ? `${lines.join('\n')}\n` : 'export {};\n';
59
+ const out = `${args.header}\n\n${body}`;
60
+ const indexPath = join(dir, 'index.ts');
61
+ const previous = await readFile(indexPath, 'utf-8').catch(() => '');
62
+ if (previous === out) {
63
+ if (args.verbose)
64
+ console.log(`[gjsify barrels] up-to-date: ${indexPath}`);
65
+ continue;
66
+ }
67
+ if (args.check) {
68
+ console.error(`[gjsify barrels] drift: ${indexPath}`);
69
+ drift++;
70
+ }
71
+ else {
72
+ await writeFile(indexPath, out, 'utf-8');
73
+ if (args.verbose)
74
+ console.log(`[gjsify barrels] wrote: ${indexPath}`);
75
+ }
76
+ }
77
+ return drift;
78
+ }
@@ -35,9 +35,19 @@ export declare class BuildAction {
35
35
  */
36
36
  private applyShebang;
37
37
  /** Application mode */
38
- buildApp(app?: App): Promise<RolldownOutput[]>;
38
+ buildApp(app?: App, opts?: {
39
+ watch?: boolean;
40
+ }): Promise<RolldownOutput[]>;
41
+ /**
42
+ * Drive `rolldown.watch(...)`: rebuild on source change, apply the
43
+ * post-bundle shebang hook on each successful build, surface errors
44
+ * without exiting, clean up on SIGINT/SIGTERM. Resolves only when the
45
+ * watcher closes — keeps the CLI process alive across rebuilds.
46
+ */
47
+ private runWatchLoop;
39
48
  start(buildType?: {
40
49
  library?: boolean;
41
50
  app?: App;
51
+ watch?: boolean;
42
52
  }): Promise<RolldownOutput[]>;
43
53
  }
@@ -1,4 +1,4 @@
1
- import { runBundle, bundleToChunks } from "../bundler-pick.js";
1
+ import { runBundle, runWatch, bundleToChunks } from "../bundler-pick.js";
2
2
  import { gjsifyPlugin, textLoaderPlugin, resolveShebangLine } from "@gjsify/rolldown-plugin-gjsify";
3
3
  import { resolveUserPlugins } from "../utils/resolve-plugin-by-name.js";
4
4
  import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, } from "@gjsify/rolldown-plugin-gjsify/globals";
@@ -178,7 +178,7 @@ export class BuildAction {
178
178
  console.debug(`[gjsify] --shebang: wrote ${line} + chmod 0o755 to ${outfile}`);
179
179
  }
180
180
  /** Application mode */
181
- async buildApp(app = "gjs") {
181
+ async buildApp(app = "gjs", opts = {}) {
182
182
  const { verbose, typescript, exclude, library: pkg, aliases, excludeGlobals, } = this.configData;
183
183
  const userBundler = normalizeBundlerOptions(this.configData);
184
184
  const formatRaw = userBundler.output?.format ??
@@ -281,17 +281,93 @@ export class BuildAction {
281
281
  // Rolldown doesn't understand) would crash the build.
282
282
  plugins: [...pnpPlugins, ...userPlugins, ...cfg.plugins],
283
283
  };
284
+ if (opts.watch) {
285
+ await this.runWatchLoop(finalOpts, app, outfile, verbose);
286
+ return [];
287
+ }
284
288
  const writeResult = await runBundle(finalOpts);
285
289
  if (app === "gjs" && this.configData.shebang) {
286
290
  await this.applyShebang(outfile, verbose);
287
291
  }
288
292
  return [writeResult];
289
293
  }
294
+ /**
295
+ * Drive `rolldown.watch(...)`: rebuild on source change, apply the
296
+ * post-bundle shebang hook on each successful build, surface errors
297
+ * without exiting, clean up on SIGINT/SIGTERM. Resolves only when the
298
+ * watcher closes — keeps the CLI process alive across rebuilds.
299
+ */
300
+ async runWatchLoop(finalOpts, app, outfile, verbose) {
301
+ const watcher = await runWatch(finalOpts);
302
+ const closed = new Promise((resolve) => {
303
+ watcher.on("close", () => resolve());
304
+ });
305
+ let closing = false;
306
+ const shutdown = async () => {
307
+ if (closing)
308
+ return;
309
+ closing = true;
310
+ console.log("\n[gjsify build --watch] stopping watcher…");
311
+ try {
312
+ await watcher.close();
313
+ }
314
+ catch (err) {
315
+ console.error("[gjsify build --watch] watcher close error:", err);
316
+ }
317
+ };
318
+ process.on("SIGINT", shutdown);
319
+ process.on("SIGTERM", shutdown);
320
+ watcher.on("event", async (event) => {
321
+ switch (event.code) {
322
+ case "START":
323
+ if (verbose)
324
+ console.log("[gjsify build --watch] rebuild start");
325
+ break;
326
+ case "BUNDLE_START":
327
+ console.log("[gjsify build --watch] building…");
328
+ break;
329
+ case "BUNDLE_END":
330
+ console.log(`[gjsify build --watch] built in ${event.duration}ms`);
331
+ try {
332
+ if (app === "gjs" && this.configData.shebang) {
333
+ await this.applyShebang(outfile, verbose);
334
+ }
335
+ }
336
+ finally {
337
+ await event.result.close();
338
+ }
339
+ break;
340
+ case "END":
341
+ console.log("[gjsify build --watch] waiting for changes…");
342
+ break;
343
+ case "ERROR":
344
+ console.error("[gjsify build --watch] build failed:", event.error?.message ?? event.error);
345
+ if (verbose && event.error?.stack)
346
+ console.error(event.error.stack);
347
+ try {
348
+ await event.result.close();
349
+ }
350
+ catch {
351
+ // best-effort cleanup
352
+ }
353
+ break;
354
+ }
355
+ });
356
+ if (verbose) {
357
+ watcher.on("change", (id, change) => {
358
+ console.log(`[gjsify build --watch] ${change.event}: ${id}`);
359
+ });
360
+ }
361
+ await closed;
362
+ }
290
363
  async start(buildType = { app: "gjs" }) {
291
364
  if (buildType.library) {
365
+ if (buildType.watch) {
366
+ throw new Error("gjsify build: --watch is not supported with --library (library mode would emit watcher rebuilds for every produced format; use --app gjs|node|browser instead).");
367
+ }
292
368
  return await this.buildLibrary();
293
369
  }
294
- return await this.buildApp(buildType.app);
370
+ return await this.buildApp(buildType.app, { watch: buildType.watch });
295
371
  }
296
372
  }
297
373
  async function runOneLibraryBuild(args) {
@@ -65,6 +65,13 @@ export declare function bundleToChunks(input: {
65
65
  rolldownInput: import('rolldown').InputOptions;
66
66
  format: 'esm' | 'cjs' | 'iife';
67
67
  }): Promise<string[]>;
68
+ /**
69
+ * Watch source files and rebuild on change. Only npm rolldown supports
70
+ * this path — `@gjsify/rolldown-native` does not surface a watcher API yet.
71
+ * Returns the watcher; the caller registers `event` / `close` listeners
72
+ * and is responsible for invoking `watcher.close()` on shutdown.
73
+ */
74
+ export declare function runWatch(finalOpts: BundlerOptions): Promise<import('rolldown').RolldownWatcher>;
68
75
  /**
69
76
  * Run a bundle with the picked engine. Drop-in replacement for the
70
77
  * `rolldown(opts).write(opts.output)` flow used directly in build.ts.
@@ -86,6 +86,23 @@ export async function bundleToChunks(input) {
86
86
  await build.close();
87
87
  }
88
88
  }
89
+ /**
90
+ * Watch source files and rebuild on change. Only npm rolldown supports
91
+ * this path — `@gjsify/rolldown-native` does not surface a watcher API yet.
92
+ * Returns the watcher; the caller registers `event` / `close` listeners
93
+ * and is responsible for invoking `watcher.close()` on shutdown.
94
+ */
95
+ export async function runWatch(finalOpts) {
96
+ if (await shouldUseNative()) {
97
+ throw new Error('`gjsify build --watch` requires the npm `rolldown` engine. The native engine ' +
98
+ '(`@gjsify/rolldown-native`) does not expose a watcher API. Run the watch loop ' +
99
+ 'under Node (`node lib/index.js build … --watch`) or set `GJSIFY_BUNDLER=npm`.');
100
+ }
101
+ const specifier = 'rolldown';
102
+ const mod = (await import(/* @vite-ignore */ specifier));
103
+ const output = finalOpts.output ?? {};
104
+ return mod.watch({ ...finalOpts, output });
105
+ }
89
106
  /**
90
107
  * Run a bundle with the picked engine. Drop-in replacement for the
91
108
  * `rolldown(opts).write(opts.output)` flow used directly in build.ts.
@@ -0,0 +1,15 @@
1
+ import type { Command } from '../types/index.js';
2
+ import { type BarrelExtension } from '../actions/barrels-generate.js';
3
+ interface BarrelsOptions {
4
+ paths?: string[];
5
+ ext?: BarrelExtension;
6
+ baseDir?: string;
7
+ exclude?: string[];
8
+ header?: string;
9
+ semicolon?: boolean;
10
+ singleQuotes?: boolean;
11
+ check?: boolean;
12
+ verbose?: boolean;
13
+ }
14
+ export declare const barrelsCommand: Command<unknown, BarrelsOptions>;
15
+ export {};
@@ -0,0 +1,103 @@
1
+ // `gjsify barrels` — regenerate `index.ts` barrel files for a set of
2
+ // directories. Drop-in replacement for `barrelsby` (unmaintained 2022+).
3
+ //
4
+ // Examples:
5
+ // gjsify barrels src/systems src/components # regenerate two barrels
6
+ // gjsify barrels --check src/types # CI guard — exit 1 on drift
7
+ // gjsify barrels --ext js src/utils # NodeNext-style import-extensions
8
+ //
9
+ // Conventions:
10
+ // - A directory literally named `types/` emits `export type *` (type-only
11
+ // re-export) — avoids dragging value imports through type-only barrels.
12
+ // - An empty directory yields `export {};` so TypeScript still parses the
13
+ // file as a module.
14
+ // - Output is sorted by file name (locale-compare) — deterministic diffs.
15
+ // - `--check` exits non-zero on drift without writing (pre-commit / CI use).
16
+ import { DEFAULT_BARRELS_EXCLUDES, DEFAULT_BARRELS_HEADER, generateBarrels, } from '../actions/barrels-generate.js';
17
+ export const barrelsCommand = {
18
+ command: 'barrels [paths..]',
19
+ description: 'Regenerate `index.ts` barrel files for the given directories. Drop-in replacement for `barrelsby`.',
20
+ builder: (yargs) => {
21
+ return yargs
22
+ .positional('paths', {
23
+ description: 'Directories whose `index.ts` to (re)generate. May also be passed via `--paths`.',
24
+ type: 'string',
25
+ array: true,
26
+ })
27
+ .option('paths', {
28
+ alias: 'p',
29
+ description: 'Alternative to positional — repeatable list of directories.',
30
+ type: 'string',
31
+ array: true,
32
+ })
33
+ .option('ext', {
34
+ description: 'Import-specifier extension. Default: `none` (bundler-mode resolution).',
35
+ type: 'string',
36
+ choices: ['js', 'ts', 'none'],
37
+ default: 'none',
38
+ })
39
+ .option('base-dir', {
40
+ alias: 'b',
41
+ description: 'Resolve `paths` against this directory. Default: cwd.',
42
+ type: 'string',
43
+ })
44
+ .option('exclude', {
45
+ description: 'Regex(es) of file names to skip. Repeatable. Defaults: `\\.test\\.`, `\\.spec\\.`, `\\.test-data\\.`.',
46
+ type: 'string',
47
+ array: true,
48
+ })
49
+ .option('header', {
50
+ description: 'Header comment prepended to every generated file.',
51
+ type: 'string',
52
+ })
53
+ .option('semicolon', {
54
+ description: 'Emit trailing `;` on each export line. Negate with `--no-semicolon`. Default: omitted.',
55
+ type: 'boolean',
56
+ default: false,
57
+ })
58
+ .option('single-quotes', {
59
+ description: 'Use `\'` for import specifiers. Default: true. Pass `--no-single-quotes` for `"`.',
60
+ type: 'boolean',
61
+ default: true,
62
+ })
63
+ .option('check', {
64
+ description: 'Report drift without modifying files; exit non-zero if any barrel is stale.',
65
+ type: 'boolean',
66
+ default: false,
67
+ })
68
+ .option('verbose', {
69
+ description: 'Log each file scanned + written.',
70
+ type: 'boolean',
71
+ default: false,
72
+ });
73
+ },
74
+ handler: async (args) => {
75
+ // Both positional (`gjsify barrels src/a src/b`) and the named option
76
+ // (`--paths src/a --paths src/b`) land in `args.paths` since they share
77
+ // the same name. Yargs concatenates when both are present.
78
+ const paths = Array.from(new Set((args.paths ?? []).filter(Boolean)));
79
+ if (paths.length === 0) {
80
+ console.error('[gjsify barrels] no paths provided. Pass directories as positional arguments or via --paths.');
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ const excludePatterns = args.exclude?.length
85
+ ? args.exclude
86
+ : [...DEFAULT_BARRELS_EXCLUDES];
87
+ const drift = await generateBarrels({
88
+ paths,
89
+ extension: args.ext ?? 'none',
90
+ baseDir: args.baseDir ?? process.cwd(),
91
+ exclude: excludePatterns.map((src) => new RegExp(src)),
92
+ header: args.header ?? DEFAULT_BARRELS_HEADER,
93
+ noSemicolon: args.semicolon === false || args.semicolon === undefined,
94
+ singleQuotes: args.singleQuotes !== false,
95
+ check: args.check ?? false,
96
+ verbose: args.verbose ?? false,
97
+ });
98
+ if (args.check && drift > 0) {
99
+ console.error(`[gjsify barrels] ${drift} barrel file(s) drifted. Run without --check to fix.`);
100
+ process.exitCode = 1;
101
+ }
102
+ },
103
+ };
@@ -130,6 +130,13 @@ export const buildCommand = {
130
130
  description: "Comma-separated global identifiers to remove from auto-detection results. Use for false positives from dead browser-compat code whose polyfills require unavailable native libraries (e.g. --exclude-globals fetch,XMLHttpRequest).",
131
131
  type: 'string',
132
132
  normalize: true,
133
+ })
134
+ .option('watch', {
135
+ alias: 'w',
136
+ description: "Watch source files and rebuild on change. Logs each rebuild with duration; clean SIGINT shutdown. Only valid with --app gjs|node|browser (rejected with --library). Requires the npm `rolldown` engine — run under Node, not the GJS-bundled CLI.",
137
+ type: 'boolean',
138
+ normalize: true,
139
+ default: false,
133
140
  });
134
141
  },
135
142
  handler: async (args) => {
@@ -139,6 +146,7 @@ export const buildCommand = {
139
146
  await action.start({
140
147
  library: args.library,
141
148
  app: args.app,
149
+ watch: args.watch,
142
150
  });
143
151
  }
144
152
  };
@@ -0,0 +1,9 @@
1
+ import type { Command } from '../types/index.js';
2
+ interface FixOptions {
3
+ paths?: string[];
4
+ write?: boolean;
5
+ configPath?: string;
6
+ verbose?: boolean;
7
+ }
8
+ export declare const fixCommand: Command<unknown, FixOptions>;
9
+ export {};
@@ -0,0 +1,60 @@
1
+ // `gjsify fix` — runs biome's combined `check --write` mode.
2
+ //
3
+ // Equivalent to biome's `check` (format + safe-lint-fix + organize-imports).
4
+ // Default writes fixes in-place; pass `--no-write` to report-only.
5
+ // Naming: deliberately distinct from `gjsify check` (which verifies
6
+ // system dependencies).
7
+ import { resolve } from 'node:path';
8
+ import { BiomeNotFoundError, findBiomeConfig, printBiomeNotFound, runBiome, } from '../utils/biome-resolve.js';
9
+ export const fixCommand = {
10
+ command: 'fix [paths..]',
11
+ description: 'Run Biome check --write — format + safe-lint-fix + organize-imports in one pass.',
12
+ builder: (yargs) => {
13
+ return yargs
14
+ .positional('paths', {
15
+ description: 'Files or directories to fix. Default: `.`',
16
+ type: 'string',
17
+ array: true,
18
+ })
19
+ .option('write', {
20
+ description: 'Apply fixes in place (default: true). Pass --no-write to report only.',
21
+ type: 'boolean',
22
+ default: true,
23
+ })
24
+ .option('config-path', {
25
+ description: 'Path to a biome.json. Default: walks up from cwd to find one.',
26
+ type: 'string',
27
+ normalize: true,
28
+ })
29
+ .option('verbose', {
30
+ description: 'Echo the resolved biome binary + args before spawning.',
31
+ type: 'boolean',
32
+ default: false,
33
+ });
34
+ },
35
+ handler: async (args) => {
36
+ const cwd = process.cwd();
37
+ const paths = args.paths?.length
38
+ ? args.paths
39
+ : ['.'];
40
+ const biomeArgs = ['check'];
41
+ if (args.write !== false)
42
+ biomeArgs.push('--write');
43
+ const configPath = args.configPath ?? findBiomeConfig(cwd) ?? undefined;
44
+ if (configPath)
45
+ biomeArgs.push(`--config-path=${resolve(configPath, '..')}`);
46
+ biomeArgs.push(...paths);
47
+ try {
48
+ const code = await runBiome(biomeArgs, { cwd, verbose: args.verbose });
49
+ process.exitCode = code;
50
+ }
51
+ catch (err) {
52
+ if (err instanceof BiomeNotFoundError) {
53
+ printBiomeNotFound(err);
54
+ process.exitCode = 1;
55
+ return;
56
+ }
57
+ throw err;
58
+ }
59
+ },
60
+ };
@@ -0,0 +1,12 @@
1
+ import type { Command } from '../../types/index.js';
2
+ interface DiffOptions {
3
+ version?: string;
4
+ appId?: string;
5
+ flathubRepo?: string;
6
+ against?: string;
7
+ detail?: boolean;
8
+ sourceIndex?: number;
9
+ verbose?: boolean;
10
+ }
11
+ export declare const flatpakDiffCommand: Command<unknown, DiffOptions>;
12
+ export {};
@@ -0,0 +1,165 @@
1
+ // `gjsify flatpak diff` — report drift between the local config + git
2
+ // state and the per-app Flathub tracking-repo manifest.
3
+ //
4
+ // The most common failure mode is *version drift*: a release was cut
5
+ // locally (a new `git tag`), but the Flathub-repo manifest still pins
6
+ // the previous tag, so end-users on flatpak install never see the
7
+ // release. This command surfaces that drift before
8
+ // `gjsify flatpak sync-flathub` is needed (the symmetric command that
9
+ // fixes it).
10
+ //
11
+ // Workflow:
12
+ // 1. Resolve appId + flathub-repo (same precedence as sync-flathub)
13
+ // 2. Fetch the Flathub manifest (or read `--against <local>`)
14
+ // 3. Resolve the local latest git tag (`git describe --tags --abbrev=0`)
15
+ // 4. Compare module[0].sources's `tag` + `commit` to the local state
16
+ // 5. Exit 0 when in sync, 1 when there's drift, with a clear message
17
+ import { existsSync, readFileSync } from 'node:fs';
18
+ import { execFile } from 'node:child_process';
19
+ import { promisify } from 'node:util';
20
+ import { Config } from '../../config.js';
21
+ import { readPackageJson } from './utils.js';
22
+ const execFileAsync = promisify(execFile);
23
+ export const flatpakDiffCommand = {
24
+ command: 'diff',
25
+ description: 'Compare the per-app Flathub tracking-repo manifest against the local git state and report version / commit drift.',
26
+ builder: (yargs) => {
27
+ return yargs
28
+ .version(false)
29
+ .option('version', {
30
+ description: 'Local version to compare against. Default: `git describe --tags --abbrev=0` in cwd.',
31
+ type: 'string',
32
+ })
33
+ .option('app-id', {
34
+ description: 'Reverse-DNS app id. Default: `gjsify.flatpak.appId`.',
35
+ type: 'string',
36
+ })
37
+ .option('flathub-repo', {
38
+ description: 'Flathub tracking-repo (owner/name). Default: `gjsify.flatpak.flathubRepo` or `flathub/<appId>`.',
39
+ type: 'string',
40
+ })
41
+ .option('against', {
42
+ description: 'Read the Flathub manifest from a local file instead of fetching it. Useful in CI or offline.',
43
+ type: 'string',
44
+ })
45
+ .option('detail', {
46
+ description: 'Also print the full Flathub manifest source entry alongside the resolved local version.',
47
+ type: 'boolean',
48
+ default: false,
49
+ })
50
+ .option('source-index', {
51
+ description: 'Index into modules[0].sources[] to inspect (when the manifest has multiple sources).',
52
+ type: 'number',
53
+ })
54
+ .option('verbose', {
55
+ description: 'Echo fetch URL + resolved values.',
56
+ type: 'boolean',
57
+ default: false,
58
+ });
59
+ },
60
+ handler: async (args) => {
61
+ const cwd = process.cwd();
62
+ const cfg = new Config();
63
+ const configData = await cfg.forBuild({}).catch(() => ({}));
64
+ const flatpak = configData.flatpak ?? {};
65
+ const appId = args.appId ??
66
+ flatpak.appId ??
67
+ readPackageJson(cwd).name;
68
+ if (!appId) {
69
+ throw new Error('[gjsify flatpak diff] no app id available — pass --app-id or set gjsify.flatpak.appId.');
70
+ }
71
+ const flathubRepo = args.flathubRepo ??
72
+ flatpak.flathubRepo ??
73
+ `flathub/${appId}`;
74
+ const localVersion = args.version ?? (await resolveLatestTag(cwd, args.verbose));
75
+ const remoteSource = await loadFlathubSource({ appId, flathubRepo, against: args.against, verbose: args.verbose }, args.sourceIndex);
76
+ const remoteTag = remoteSource?.tag;
77
+ const remoteCommit = remoteSource?.commit;
78
+ console.log(`[gjsify flatpak diff] appId=${appId}`);
79
+ console.log(`[gjsify flatpak diff] flathubRepo=${flathubRepo}`);
80
+ console.log(`[gjsify flatpak diff] flathub: tag=${remoteTag ?? '(missing)'} commit=${remoteCommit ?? '(missing)'}`);
81
+ console.log(`[gjsify flatpak diff] local: tag=${localVersion ?? '(none)'}`);
82
+ if (args.detail && remoteSource) {
83
+ console.log('[gjsify flatpak diff] flathub manifest source:');
84
+ console.log(JSON.stringify(remoteSource, null, 2));
85
+ }
86
+ if (!localVersion) {
87
+ console.warn('[gjsify flatpak diff] no local git tag found — cut a release (`git tag vX.Y.Z`) ' +
88
+ 'or pass --version vX.Y.Z to compare against an explicit value.');
89
+ // Still surface remote state, but exit cleanly so this isn't a fatal CI step.
90
+ return;
91
+ }
92
+ if (!remoteTag) {
93
+ console.warn('[gjsify flatpak diff] flathub manifest has no `tag` field on the inspected source.');
94
+ process.exit(1);
95
+ }
96
+ if (remoteTag === localVersion) {
97
+ console.log(`✅ in sync (${localVersion})`);
98
+ return;
99
+ }
100
+ console.log(`❌ drift detected — flathub=${remoteTag} vs local=${localVersion}`);
101
+ console.log(` run \`gjsify flatpak sync-flathub --version ${localVersion}\` to update the Flathub manifest.`);
102
+ process.exit(1);
103
+ },
104
+ };
105
+ // ─── Internal helpers ────────────────────────────────────────────────────
106
+ async function loadFlathubSource(args, sourceIndex) {
107
+ let raw;
108
+ if (args.against) {
109
+ if (!existsSync(args.against)) {
110
+ throw new Error(`[gjsify flatpak diff] --against path ${args.against} does not exist`);
111
+ }
112
+ raw = readFileSync(args.against, 'utf-8');
113
+ }
114
+ else {
115
+ // Try main + master raw URLs on GitHub. Flathub repos historically use
116
+ // master; newer ones may use main. No XDG cache: fresh fetch each run.
117
+ raw = await fetchFlathubManifest(args.flathubRepo, args.appId, args.verbose);
118
+ }
119
+ let manifest;
120
+ try {
121
+ manifest = JSON.parse(raw);
122
+ }
123
+ catch (err) {
124
+ throw new Error(`[gjsify flatpak diff] failed to parse Flathub manifest as JSON: ${err.message}`);
125
+ }
126
+ const modules = manifest.modules ?? [];
127
+ const sources = modules[0]?.sources ?? [];
128
+ if (sources.length === 0)
129
+ return null;
130
+ const idx = sourceIndex ?? sources.findIndex((s) => s?.type === 'git');
131
+ if (idx < 0 || idx >= sources.length)
132
+ return null;
133
+ return sources[idx] ?? null;
134
+ }
135
+ async function fetchFlathubManifest(flathubRepo, appId, verbose) {
136
+ for (const branch of ['master', 'main']) {
137
+ const url = `https://raw.githubusercontent.com/${flathubRepo}/${branch}/${appId}.json`;
138
+ if (verbose)
139
+ console.log(`[gjsify flatpak diff] fetch ${url}`);
140
+ try {
141
+ const res = await fetch(url);
142
+ if (res.ok)
143
+ return await res.text();
144
+ if (verbose)
145
+ console.log(`[gjsify flatpak diff] ${branch} → HTTP ${res.status}`);
146
+ }
147
+ catch (err) {
148
+ if (verbose)
149
+ console.log(`[gjsify flatpak diff] ${branch} fetch error: ${err.message}`);
150
+ }
151
+ }
152
+ throw new Error(`[gjsify flatpak diff] could not fetch flathub manifest from ${flathubRepo} on master or main`);
153
+ }
154
+ async function resolveLatestTag(cwd, verbose) {
155
+ try {
156
+ const { stdout } = await execFileAsync('git', ['describe', '--tags', '--abbrev=0'], { cwd });
157
+ const tag = stdout.trim();
158
+ if (verbose)
159
+ console.log(`[gjsify flatpak diff] local latest tag → ${tag}`);
160
+ return tag || null;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }