@gjsify/cli 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.gjs.mjs +29 -23
- package/lib/actions/build.d.ts +11 -1
- package/lib/actions/build.js +79 -3
- package/lib/bundler-pick.d.ts +7 -0
- package/lib/bundler-pick.js +17 -0
- package/lib/commands/build.js +8 -0
- package/lib/commands/fix.d.ts +9 -0
- package/lib/commands/fix.js +60 -0
- package/lib/commands/flatpak/diff.d.ts +12 -0
- package/lib/commands/flatpak/diff.js +165 -0
- package/lib/commands/flatpak/index.d.ts +4 -1
- package/lib/commands/flatpak/index.js +11 -5
- package/lib/commands/flatpak/init.d.ts +1 -0
- package/lib/commands/flatpak/init.js +41 -7
- package/lib/commands/flatpak/release.d.ts +13 -0
- package/lib/commands/flatpak/release.js +152 -0
- package/lib/commands/flatpak/sync-flathub.d.ts +26 -0
- package/lib/commands/flatpak/sync-flathub.js +311 -0
- package/lib/commands/format.d.ts +12 -0
- package/lib/commands/format.js +98 -0
- package/lib/commands/index.d.ts +5 -0
- package/lib/commands/index.js +5 -0
- package/lib/commands/lint.d.ts +9 -0
- package/lib/commands/lint.js +60 -0
- package/lib/commands/test.d.ts +12 -0
- package/lib/commands/test.js +206 -0
- package/lib/commands/upgrade.d.ts +13 -0
- package/lib/commands/upgrade.js +402 -0
- package/lib/index.js +6 -1
- package/lib/templates/biome.json.tmpl +79 -0
- package/lib/types/cli-build-options.d.ts +7 -0
- package/lib/types/config-data.d.ts +47 -0
- package/lib/utils/biome-resolve.d.ts +47 -0
- package/lib/utils/biome-resolve.js +204 -0
- package/package.json +16 -16
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// `gjsify lint` — wraps biome's `lint` mode.
|
|
2
|
+
//
|
|
3
|
+
// Sibling of `gjsify format`. Spawns biome from node_modules directly
|
|
4
|
+
// (no Node launcher). Default behaviour: report-only. Pass `--write`
|
|
5
|
+
// for biome's safe-fix mode, or use `gjsify fix` for the combined
|
|
6
|
+
// format + safe-lint-fix + organize-imports surface.
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { BiomeNotFoundError, findBiomeConfig, printBiomeNotFound, runBiome, } from '../utils/biome-resolve.js';
|
|
9
|
+
export const lintCommand = {
|
|
10
|
+
command: 'lint [paths..]',
|
|
11
|
+
description: 'Run Biome lint diagnostics (native binary spawn — no Node launcher).',
|
|
12
|
+
builder: (yargs) => {
|
|
13
|
+
return yargs
|
|
14
|
+
.positional('paths', {
|
|
15
|
+
description: 'Files or directories to lint. Default: `.`',
|
|
16
|
+
type: 'string',
|
|
17
|
+
array: true,
|
|
18
|
+
})
|
|
19
|
+
.option('write', {
|
|
20
|
+
description: 'Apply safe lint fixes in place.',
|
|
21
|
+
type: 'boolean',
|
|
22
|
+
default: false,
|
|
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 = ['lint'];
|
|
41
|
+
if (args.write)
|
|
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
|
+
type Runtime = 'gjs' | 'node';
|
|
3
|
+
interface TestOptions {
|
|
4
|
+
runtime?: Runtime | 'all';
|
|
5
|
+
entry?: string;
|
|
6
|
+
outdir?: string;
|
|
7
|
+
rebuild?: boolean;
|
|
8
|
+
build?: boolean;
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const testCommand: Command<unknown, TestOptions>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// `gjsify test` — build + run + aggregate per-runtime test suite.
|
|
2
|
+
//
|
|
3
|
+
// Eliminates the `build:test:{gjs,node}` + `test:{gjs,node}` + `test`
|
|
4
|
+
// script boilerplate that ~110 workspace packages repeat. Each package
|
|
5
|
+
// just needs `src/test.mts` aggregating its `@gjsify/unit` suites; this
|
|
6
|
+
// command builds it for GJS + Node and runs each output, aggregating
|
|
7
|
+
// exit codes.
|
|
8
|
+
import { existsSync, statSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { join, dirname, resolve, relative } from 'node:path';
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { Config } from '../config.js';
|
|
12
|
+
import { BuildAction } from '../actions/build.js';
|
|
13
|
+
import { runGjsBundle } from '../utils/run-gjs.js';
|
|
14
|
+
export const testCommand = {
|
|
15
|
+
command: 'test',
|
|
16
|
+
description: 'Build + run the package’s `src/test.mts` suite on GJS and Node and aggregate the results. Replaces the per-package `build:test:{gjs,node}` + `test:{gjs,node}` script boilerplate.',
|
|
17
|
+
builder: (yargs) => {
|
|
18
|
+
return yargs
|
|
19
|
+
.option('runtime', {
|
|
20
|
+
description: 'Target runtime. Default: both.',
|
|
21
|
+
type: 'string',
|
|
22
|
+
choices: ['gjs', 'node', 'all'],
|
|
23
|
+
default: 'all',
|
|
24
|
+
})
|
|
25
|
+
.option('entry', {
|
|
26
|
+
description: 'Path to the test entry. Default: `src/test.mts` (or `gjsify.test.entry`).',
|
|
27
|
+
type: 'string',
|
|
28
|
+
normalize: true,
|
|
29
|
+
})
|
|
30
|
+
.option('outdir', {
|
|
31
|
+
description: 'Output directory for the built test bundles. Default: `dist/`.',
|
|
32
|
+
type: 'string',
|
|
33
|
+
normalize: true,
|
|
34
|
+
})
|
|
35
|
+
.option('rebuild', {
|
|
36
|
+
description: 'Always rebuild the test bundles, even when they look up-to-date.',
|
|
37
|
+
type: 'boolean',
|
|
38
|
+
default: false,
|
|
39
|
+
})
|
|
40
|
+
.option('build', {
|
|
41
|
+
description: 'Build before running. Default: true (use --no-build to skip when bundles already exist).',
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
default: true,
|
|
44
|
+
})
|
|
45
|
+
.option('verbose', {
|
|
46
|
+
description: 'Print resolved entry/outdir + per-step timing.',
|
|
47
|
+
type: 'boolean',
|
|
48
|
+
default: false,
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
handler: async (args) => {
|
|
52
|
+
const cwd = process.cwd();
|
|
53
|
+
// Resolve config: gjsify.test.{entry,outdir,runtimes}.
|
|
54
|
+
const cfg = new Config();
|
|
55
|
+
const configData = await cfg.forBuild({}).catch(() => ({}));
|
|
56
|
+
const testCfg = configData.test ?? {};
|
|
57
|
+
const entry = resolve(cwd, args.entry ?? testCfg.entry ?? 'src/test.mts');
|
|
58
|
+
const outdir = resolve(cwd, args.outdir ?? testCfg.outdir ?? 'dist');
|
|
59
|
+
if (!existsSync(entry)) {
|
|
60
|
+
console.error(`[gjsify test] no test entry at ${relative(cwd, entry)} — ` +
|
|
61
|
+
`add an \`src/test.mts\` that aggregates your \`@gjsify/unit\` suites, ` +
|
|
62
|
+
`or set \`gjsify.test.entry\` in package.json.`);
|
|
63
|
+
process.exit(1);
|
|
64
|
+
}
|
|
65
|
+
const requested = args.runtime === 'gjs'
|
|
66
|
+
? ['gjs']
|
|
67
|
+
: args.runtime === 'node'
|
|
68
|
+
? ['node']
|
|
69
|
+
: (testCfg.runtimes && testCfg.runtimes.length > 0 ? testCfg.runtimes : ['gjs', 'node']);
|
|
70
|
+
const results = [];
|
|
71
|
+
for (const runtime of requested) {
|
|
72
|
+
const outfile = join(outdir, `test.${runtime}.mjs`);
|
|
73
|
+
// Build stage (skip if --no-build OR (not --rebuild AND outfile fresher than src)).
|
|
74
|
+
if (args.build !== false) {
|
|
75
|
+
const needsBuild = args.rebuild || !isFresh(outfile, entry, cwd);
|
|
76
|
+
if (needsBuild) {
|
|
77
|
+
const buildStart = Date.now();
|
|
78
|
+
if (args.verbose) {
|
|
79
|
+
console.log(`[gjsify test] building → ${relative(cwd, outfile)} (—app ${runtime})`);
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
await buildTestBundle(entry, outfile, runtime, args.verbose);
|
|
83
|
+
if (args.verbose) {
|
|
84
|
+
console.log(`[gjsify test] built ${runtime} in ${Date.now() - buildStart}ms`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(`[gjsify test] build failed for ${runtime}:`, err.message);
|
|
89
|
+
results.push({ runtime, ok: false, durationMs: 0, error: 'build failed' });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
else if (args.verbose) {
|
|
94
|
+
console.log(`[gjsify test] ${runtime}: bundle is up-to-date — skipping build (use --rebuild to force)`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else if (!existsSync(outfile)) {
|
|
98
|
+
console.error(`[gjsify test] --no-build but ${relative(cwd, outfile)} doesn't exist. ` +
|
|
99
|
+
`Build first or drop --no-build.`);
|
|
100
|
+
results.push({ runtime, ok: false, durationMs: 0, error: 'no bundle' });
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
// Run stage.
|
|
104
|
+
const runStart = Date.now();
|
|
105
|
+
try {
|
|
106
|
+
await runTestBundle(outfile, runtime);
|
|
107
|
+
results.push({ runtime, ok: true, durationMs: Date.now() - runStart });
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
results.push({
|
|
111
|
+
runtime,
|
|
112
|
+
ok: false,
|
|
113
|
+
durationMs: Date.now() - runStart,
|
|
114
|
+
error: err.message,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Summary + aggregate exit.
|
|
119
|
+
const summary = results
|
|
120
|
+
.map((r) => `${r.ok ? '✅' : '❌'} ${r.runtime} (${r.durationMs}ms)${r.error ? ` — ${r.error}` : ''}`)
|
|
121
|
+
.join(' ');
|
|
122
|
+
console.log(`[gjsify test] ${summary}`);
|
|
123
|
+
const anyFailed = results.some((r) => !r.ok);
|
|
124
|
+
if (anyFailed) {
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
/** Build a single test bundle in-process via `BuildAction`. */
|
|
130
|
+
async function buildTestBundle(entry, outfile, runtime, verbose) {
|
|
131
|
+
const config = new Config();
|
|
132
|
+
// forBuild's interactive prompts are skipped because we pass through
|
|
133
|
+
// ArgumentsCamelCase shape with only the relevant fields.
|
|
134
|
+
const configData = await config.forBuild({
|
|
135
|
+
entryPoints: [entry],
|
|
136
|
+
outfile,
|
|
137
|
+
app: runtime,
|
|
138
|
+
verbose: verbose ?? false,
|
|
139
|
+
logLevel: 'warning',
|
|
140
|
+
exclude: [],
|
|
141
|
+
});
|
|
142
|
+
// Override bundler entry-input so gjsify.test.entry doesn't fight with
|
|
143
|
+
// gjsify.bundler.input. The build action picks `output.file` straight from
|
|
144
|
+
// the merged config; we set it explicitly here so package.json#main /
|
|
145
|
+
// bundler.output.file from the surrounding project don't redirect the
|
|
146
|
+
// bundle elsewhere.
|
|
147
|
+
configData.library = { ...(configData.library ?? {}) };
|
|
148
|
+
configData.bundler = {
|
|
149
|
+
...(configData.bundler ?? {}),
|
|
150
|
+
input: [entry],
|
|
151
|
+
output: { ...(configData.bundler?.output ?? {}), file: outfile },
|
|
152
|
+
};
|
|
153
|
+
const action = new BuildAction(configData);
|
|
154
|
+
await action.start({ app: runtime, library: false });
|
|
155
|
+
}
|
|
156
|
+
/** Run a single test bundle and reject on non-zero exit. */
|
|
157
|
+
async function runTestBundle(outfile, runtime) {
|
|
158
|
+
if (runtime === 'gjs') {
|
|
159
|
+
await runGjsBundle(outfile);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await new Promise((resolvePromise, reject) => {
|
|
163
|
+
const child = spawn('node', [outfile], { stdio: 'inherit' });
|
|
164
|
+
child.on('error', reject);
|
|
165
|
+
child.on('exit', (code) => {
|
|
166
|
+
if (code === 0)
|
|
167
|
+
resolvePromise();
|
|
168
|
+
else
|
|
169
|
+
reject(new Error(`node exited with code ${code}`));
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** True when `outfile` exists and is newer than every `.ts`/`.mts` file under the entry's directory tree. */
|
|
174
|
+
function isFresh(outfile, entry, cwd) {
|
|
175
|
+
if (!existsSync(outfile))
|
|
176
|
+
return false;
|
|
177
|
+
const outMtime = statSync(outfile).mtimeMs;
|
|
178
|
+
const srcRoot = dirname(entry);
|
|
179
|
+
// Conservative: walk the src tree once. If the package has no `src/`,
|
|
180
|
+
// fall back to entry-only check.
|
|
181
|
+
try {
|
|
182
|
+
const newest = newestMtimeUnder(existsSync(srcRoot) ? srcRoot : entry);
|
|
183
|
+
return outMtime >= newest;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// On any FS error, force rebuild to stay safe.
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
void cwd;
|
|
190
|
+
}
|
|
191
|
+
function newestMtimeUnder(path) {
|
|
192
|
+
const st = statSync(path);
|
|
193
|
+
if (st.isFile())
|
|
194
|
+
return st.mtimeMs;
|
|
195
|
+
let max = st.mtimeMs;
|
|
196
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
197
|
+
if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'lib' || entry.name.startsWith('.')) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const child = join(path, entry.name);
|
|
201
|
+
const m = newestMtimeUnder(child);
|
|
202
|
+
if (m > max)
|
|
203
|
+
max = m;
|
|
204
|
+
}
|
|
205
|
+
return max;
|
|
206
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Command } from "../types/index.js";
|
|
2
|
+
interface UpgradeOptions {
|
|
3
|
+
latest?: boolean;
|
|
4
|
+
minor?: boolean;
|
|
5
|
+
patch?: boolean;
|
|
6
|
+
filter?: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
cwd?: string;
|
|
9
|
+
verbose?: boolean;
|
|
10
|
+
yes?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const upgradeCommand: Command<unknown, UpgradeOptions>;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// `gjsify upgrade` — drop-in replacement for `yarn upgrade-interactive`
|
|
2
|
+
// and `npx npm-check-updates`. Two modes:
|
|
3
|
+
//
|
|
4
|
+
// 1. Interactive (default): show outdated packages, prompt user to
|
|
5
|
+
// select which ones to update (space-separated indices or `a` for
|
|
6
|
+
// all), then write the new ranges to `package.json`.
|
|
7
|
+
//
|
|
8
|
+
// 2. Non-interactive (`--latest` / `--minor` / `--patch` / `--filter`):
|
|
9
|
+
// bump matching packages automatically without prompting.
|
|
10
|
+
//
|
|
11
|
+
// Workspace-aware: `workspace:^` / `workspace:~` / `workspace:*` ranges
|
|
12
|
+
// are skipped — those are the gjsify monorepo internal links and `gjsify
|
|
13
|
+
// install` resolves them locally. Only external npm specs get checked
|
|
14
|
+
// against the registry.
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { createInterface } from "node:readline/promises";
|
|
19
|
+
import { parse } from "@gjsify/semver";
|
|
20
|
+
import { DEFAULT_REGISTRY, fetchPackument, parseNpmrc, } from "@gjsify/npm-registry";
|
|
21
|
+
export const upgradeCommand = {
|
|
22
|
+
command: "upgrade",
|
|
23
|
+
description: "Check the npm registry for newer versions of declared dependencies and update package.json. Interactive by default; `--latest` / `--minor` / `--patch` switch to non-interactive bulk-update mode.",
|
|
24
|
+
builder: (yargs) => {
|
|
25
|
+
return yargs
|
|
26
|
+
.option("latest", {
|
|
27
|
+
description: "Non-interactive: bump every dependency to its latest version (allows major).",
|
|
28
|
+
type: "boolean",
|
|
29
|
+
default: false,
|
|
30
|
+
})
|
|
31
|
+
.option("minor", {
|
|
32
|
+
description: "Non-interactive: bump every dependency to the latest within the same major (semver-minor + semver-patch).",
|
|
33
|
+
type: "boolean",
|
|
34
|
+
default: false,
|
|
35
|
+
})
|
|
36
|
+
.option("patch", {
|
|
37
|
+
description: "Non-interactive: bump every dependency to the latest within the same minor (semver-patch only).",
|
|
38
|
+
type: "boolean",
|
|
39
|
+
default: false,
|
|
40
|
+
})
|
|
41
|
+
.option("filter", {
|
|
42
|
+
description: "Only consider packages whose name matches this substring (case-insensitive). Repeatable; comma-separated values are split.",
|
|
43
|
+
type: "string",
|
|
44
|
+
})
|
|
45
|
+
.option("dry-run", {
|
|
46
|
+
description: "Print the upgrade plan without writing package.json.",
|
|
47
|
+
type: "boolean",
|
|
48
|
+
default: false,
|
|
49
|
+
})
|
|
50
|
+
.option("cwd", {
|
|
51
|
+
description: "Project directory. Default: process.cwd().",
|
|
52
|
+
type: "string",
|
|
53
|
+
})
|
|
54
|
+
.option("yes", {
|
|
55
|
+
alias: "y",
|
|
56
|
+
description: "Interactive mode: select all without prompting.",
|
|
57
|
+
type: "boolean",
|
|
58
|
+
default: false,
|
|
59
|
+
})
|
|
60
|
+
.option("verbose", {
|
|
61
|
+
description: "Print extra resolution details.",
|
|
62
|
+
type: "boolean",
|
|
63
|
+
default: false,
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
handler: async (args) => {
|
|
67
|
+
const cwd = resolve(args.cwd ?? process.cwd());
|
|
68
|
+
const pkgJsonPath = join(cwd, "package.json");
|
|
69
|
+
if (!existsSync(pkgJsonPath)) {
|
|
70
|
+
throw new Error(`[gjsify upgrade] no package.json at ${pkgJsonPath}`);
|
|
71
|
+
}
|
|
72
|
+
const rawPkg = readFileSync(pkgJsonPath, "utf-8");
|
|
73
|
+
const pkg = JSON.parse(rawPkg);
|
|
74
|
+
const filters = args.filter
|
|
75
|
+
? args.filter
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((s) => s.trim().toLowerCase())
|
|
78
|
+
.filter(Boolean)
|
|
79
|
+
: [];
|
|
80
|
+
const entries = collectExternalDeps(pkg, filters);
|
|
81
|
+
if (entries.length === 0) {
|
|
82
|
+
console.log("[gjsify upgrade] no external npm dependencies to check.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const npmrc = await loadNpmrcLight(cwd);
|
|
86
|
+
const mode = args.latest
|
|
87
|
+
? "latest"
|
|
88
|
+
: args.minor
|
|
89
|
+
? "minor"
|
|
90
|
+
: args.patch
|
|
91
|
+
? "patch"
|
|
92
|
+
: "interactive";
|
|
93
|
+
console.log(`[gjsify upgrade] checking ${entries.length} dependencies against ${npmrc.registry}…`);
|
|
94
|
+
const candidates = await resolveCandidates(entries, npmrc, args.verbose ?? false, mode);
|
|
95
|
+
if (candidates.length === 0) {
|
|
96
|
+
console.log("✅ all dependencies are up to date");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
printTable(candidates);
|
|
100
|
+
let selected;
|
|
101
|
+
if (mode === "interactive" && !args.yes) {
|
|
102
|
+
selected = await promptSelection(candidates);
|
|
103
|
+
}
|
|
104
|
+
else if (args.yes && mode === "interactive") {
|
|
105
|
+
console.log("[gjsify upgrade] -y / --yes: selecting all");
|
|
106
|
+
selected = candidates;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
selected = candidates;
|
|
110
|
+
}
|
|
111
|
+
if (selected.length === 0) {
|
|
112
|
+
console.log("[gjsify upgrade] nothing selected; package.json unchanged.");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (args.dryRun) {
|
|
116
|
+
console.log(`[gjsify upgrade] --dry-run: would update ${selected.length} dependencies (no write).`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
writePackageJson(pkgJsonPath, rawPkg, pkg, selected);
|
|
120
|
+
console.log(`✏️ updated ${selected.length} dependencies in ${pkgJsonPath}. Run \`gjsify install\` to apply.`);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
// ─── Resolution ─────────────────────────────────────────────────────────
|
|
124
|
+
const DEP_FIELDS = [
|
|
125
|
+
"dependencies",
|
|
126
|
+
"devDependencies",
|
|
127
|
+
"optionalDependencies",
|
|
128
|
+
"peerDependencies",
|
|
129
|
+
];
|
|
130
|
+
function collectExternalDeps(pkg, filters) {
|
|
131
|
+
const out = [];
|
|
132
|
+
for (const field of DEP_FIELDS) {
|
|
133
|
+
const map = pkg[field];
|
|
134
|
+
if (!map || typeof map !== "object")
|
|
135
|
+
continue;
|
|
136
|
+
for (const [name, raw] of Object.entries(map)) {
|
|
137
|
+
if (typeof raw !== "string")
|
|
138
|
+
continue;
|
|
139
|
+
if (filters.length && !filters.some((f) => name.toLowerCase().includes(f))) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// Skip workspace-protocol + file: + link: + git: + http(s): specs.
|
|
143
|
+
if (raw.startsWith("workspace:") ||
|
|
144
|
+
raw.startsWith("file:") ||
|
|
145
|
+
raw.startsWith("link:") ||
|
|
146
|
+
raw.startsWith("git+") ||
|
|
147
|
+
raw.startsWith("git:") ||
|
|
148
|
+
raw.startsWith("http") ||
|
|
149
|
+
raw.startsWith("npm:") || // e.g. `foo: npm:@scope/foo@^1`
|
|
150
|
+
raw === "*" ||
|
|
151
|
+
raw === "latest") {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const { prefix, version } = splitRange(raw);
|
|
155
|
+
out.push({
|
|
156
|
+
name,
|
|
157
|
+
field,
|
|
158
|
+
currentRange: raw,
|
|
159
|
+
currentVersion: version,
|
|
160
|
+
prefix,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Split `^1.2.3` → { prefix: "^", version: "1.2.3" }. Honors `~`, `>=`,
|
|
168
|
+
* `>`, `<=`, `<`, `=`. Defaults to "" prefix when the range is just a
|
|
169
|
+
* literal version.
|
|
170
|
+
*/
|
|
171
|
+
function splitRange(range) {
|
|
172
|
+
const m = range.match(/^(\^|~|>=|<=|>|<|=)?\s*([0-9].*)$/);
|
|
173
|
+
if (!m)
|
|
174
|
+
return { prefix: "", version: null };
|
|
175
|
+
const prefix = m[1] ?? "";
|
|
176
|
+
const version = m[2]?.split(/\s|[|&,]/)[0] ?? null; // strip range modifiers (`||`, ` - `, etc.)
|
|
177
|
+
const parsed = version ? parse(version) : null;
|
|
178
|
+
return { prefix, version: parsed?.version ?? null };
|
|
179
|
+
}
|
|
180
|
+
async function resolveCandidates(entries, npmrc, verbose, mode) {
|
|
181
|
+
const results = [];
|
|
182
|
+
// Parallel fetch with a small concurrency cap.
|
|
183
|
+
const cap = 8;
|
|
184
|
+
let cursor = 0;
|
|
185
|
+
async function worker() {
|
|
186
|
+
for (;;) {
|
|
187
|
+
const i = cursor++;
|
|
188
|
+
if (i >= entries.length)
|
|
189
|
+
return;
|
|
190
|
+
const entry = entries[i];
|
|
191
|
+
try {
|
|
192
|
+
const packument = await fetchPackument(entry.name, { npmrc });
|
|
193
|
+
const latest = packument["dist-tags"]?.latest;
|
|
194
|
+
if (!latest) {
|
|
195
|
+
if (verbose)
|
|
196
|
+
console.warn(` ${entry.name}: no dist-tags.latest, skipping`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!entry.currentVersion) {
|
|
200
|
+
if (verbose)
|
|
201
|
+
console.warn(` ${entry.name}: unable to parse current range "${entry.currentRange}"`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
const diff = classifyDiff(entry.currentVersion, latest);
|
|
205
|
+
if (diff === "none")
|
|
206
|
+
continue;
|
|
207
|
+
if (mode === "minor" && diff === "major")
|
|
208
|
+
continue;
|
|
209
|
+
if (mode === "patch" && (diff === "major" || diff === "minor"))
|
|
210
|
+
continue;
|
|
211
|
+
results.push({
|
|
212
|
+
...entry,
|
|
213
|
+
latestVersion: latest,
|
|
214
|
+
diff,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
if (verbose)
|
|
219
|
+
console.warn(` ${entry.name}: fetch failed (${err.message})`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
void packumentToString;
|
|
223
|
+
}
|
|
224
|
+
await Promise.all(Array.from({ length: cap }, () => worker()));
|
|
225
|
+
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
226
|
+
return results;
|
|
227
|
+
}
|
|
228
|
+
function classifyDiff(current, latest) {
|
|
229
|
+
const c = parse(current);
|
|
230
|
+
const l = parse(latest);
|
|
231
|
+
if (!c || !l)
|
|
232
|
+
return "none";
|
|
233
|
+
if (c.major !== l.major)
|
|
234
|
+
return l.major > c.major ? "major" : "none";
|
|
235
|
+
if (c.minor !== l.minor)
|
|
236
|
+
return l.minor > c.minor ? "minor" : "none";
|
|
237
|
+
if (c.patch !== l.patch)
|
|
238
|
+
return l.patch > c.patch ? "patch" : "none";
|
|
239
|
+
if ((c.prerelease ?? []).join(".") !== (l.prerelease ?? []).join("."))
|
|
240
|
+
return "prerelease";
|
|
241
|
+
return "none";
|
|
242
|
+
}
|
|
243
|
+
// ─── Output / Interaction ──────────────────────────────────────────────
|
|
244
|
+
const ANSI = {
|
|
245
|
+
reset: "\x1b[0m",
|
|
246
|
+
bold: "\x1b[1m",
|
|
247
|
+
dim: "\x1b[2m",
|
|
248
|
+
red: "\x1b[31m",
|
|
249
|
+
yellow: "\x1b[33m",
|
|
250
|
+
green: "\x1b[32m",
|
|
251
|
+
cyan: "\x1b[36m",
|
|
252
|
+
};
|
|
253
|
+
function colorForDiff(diff) {
|
|
254
|
+
switch (diff) {
|
|
255
|
+
case "major":
|
|
256
|
+
return ANSI.red;
|
|
257
|
+
case "minor":
|
|
258
|
+
return ANSI.yellow;
|
|
259
|
+
case "patch":
|
|
260
|
+
return ANSI.green;
|
|
261
|
+
case "prerelease":
|
|
262
|
+
return ANSI.cyan;
|
|
263
|
+
default:
|
|
264
|
+
return "";
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function printTable(candidates) {
|
|
268
|
+
const nameW = Math.max(...candidates.map((c) => c.name.length), 4);
|
|
269
|
+
const curW = Math.max(...candidates.map((c) => c.currentRange.length), 7);
|
|
270
|
+
const newW = Math.max(...candidates.map((c) => c.latestVersion.length), 6);
|
|
271
|
+
const idxW = String(candidates.length).length + 2;
|
|
272
|
+
const head = " ".repeat(idxW) +
|
|
273
|
+
ANSI.bold +
|
|
274
|
+
"name".padEnd(nameW) +
|
|
275
|
+
" " +
|
|
276
|
+
"current".padEnd(curW) +
|
|
277
|
+
" " +
|
|
278
|
+
"latest".padEnd(newW) +
|
|
279
|
+
" " +
|
|
280
|
+
"kind" +
|
|
281
|
+
ANSI.reset;
|
|
282
|
+
console.log(head);
|
|
283
|
+
console.log(" ".repeat(idxW) + ANSI.dim + "─".repeat(nameW + curW + newW + 12) + ANSI.reset);
|
|
284
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
285
|
+
const c = candidates[i];
|
|
286
|
+
const idx = `${i + 1}.`.padEnd(idxW);
|
|
287
|
+
const color = colorForDiff(c.diff);
|
|
288
|
+
console.log(idx +
|
|
289
|
+
c.name.padEnd(nameW) +
|
|
290
|
+
" " +
|
|
291
|
+
ANSI.dim +
|
|
292
|
+
c.currentRange.padEnd(curW) +
|
|
293
|
+
ANSI.reset +
|
|
294
|
+
" " +
|
|
295
|
+
color +
|
|
296
|
+
c.latestVersion.padEnd(newW) +
|
|
297
|
+
ANSI.reset +
|
|
298
|
+
" " +
|
|
299
|
+
color +
|
|
300
|
+
c.diff +
|
|
301
|
+
ANSI.reset);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
async function promptSelection(candidates) {
|
|
305
|
+
if (!process.stdin.isTTY) {
|
|
306
|
+
console.log("[gjsify upgrade] non-TTY stdin: pass --latest / --minor / --patch (or --yes for interactive-all) to upgrade non-interactively.");
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
310
|
+
try {
|
|
311
|
+
console.log("\nSelect upgrades: comma- or space-separated indices, " +
|
|
312
|
+
ANSI.bold +
|
|
313
|
+
"a" +
|
|
314
|
+
ANSI.reset +
|
|
315
|
+
" for all, ranges like " +
|
|
316
|
+
ANSI.bold +
|
|
317
|
+
"1-3" +
|
|
318
|
+
ANSI.reset +
|
|
319
|
+
", or " +
|
|
320
|
+
ANSI.bold +
|
|
321
|
+
"ENTER" +
|
|
322
|
+
ANSI.reset +
|
|
323
|
+
" to skip:");
|
|
324
|
+
const answer = (await rl.question("> ")).trim();
|
|
325
|
+
if (!answer)
|
|
326
|
+
return [];
|
|
327
|
+
if (answer.toLowerCase() === "a" || answer.toLowerCase() === "all")
|
|
328
|
+
return candidates;
|
|
329
|
+
const picked = new Set();
|
|
330
|
+
for (const token of answer.split(/[\s,]+/).filter(Boolean)) {
|
|
331
|
+
const range = token.match(/^(\d+)-(\d+)$/);
|
|
332
|
+
if (range) {
|
|
333
|
+
const a = Number(range[1]);
|
|
334
|
+
const b = Number(range[2]);
|
|
335
|
+
for (let i = Math.min(a, b); i <= Math.max(a, b); i++)
|
|
336
|
+
picked.add(i - 1);
|
|
337
|
+
}
|
|
338
|
+
else if (/^\d+$/.test(token)) {
|
|
339
|
+
picked.add(Number(token) - 1);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return [...picked]
|
|
343
|
+
.filter((i) => i >= 0 && i < candidates.length)
|
|
344
|
+
.map((i) => candidates[i]);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
rl.close();
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
// ─── Write-back ────────────────────────────────────────────────────────
|
|
351
|
+
function writePackageJson(path, rawText, parsed, selected) {
|
|
352
|
+
// Mutate the parsed object then re-stringify with the original indent.
|
|
353
|
+
for (const c of selected) {
|
|
354
|
+
const map = parsed[c.field];
|
|
355
|
+
if (!map)
|
|
356
|
+
continue;
|
|
357
|
+
map[c.name] = c.prefix + c.latestVersion;
|
|
358
|
+
}
|
|
359
|
+
const indent = detectIndent(rawText);
|
|
360
|
+
writeFileSync(path, JSON.stringify(parsed, null, indent) + (rawText.endsWith("\n") ? "\n" : ""), "utf-8");
|
|
361
|
+
}
|
|
362
|
+
function detectIndent(json) {
|
|
363
|
+
const m = json.match(/^\{\n( +)/);
|
|
364
|
+
if (m)
|
|
365
|
+
return m[1].length;
|
|
366
|
+
return 2;
|
|
367
|
+
}
|
|
368
|
+
// ─── npmrc loader (lightweight, shared shape with install-backend) ─────
|
|
369
|
+
async function loadNpmrcLight(cwd) {
|
|
370
|
+
let parsed = {
|
|
371
|
+
registry: DEFAULT_REGISTRY,
|
|
372
|
+
scopes: {},
|
|
373
|
+
authTokens: {},
|
|
374
|
+
basicAuth: {},
|
|
375
|
+
};
|
|
376
|
+
// Layered .npmrc lookup (most-specific wins): home → cwd. Same precedence
|
|
377
|
+
// as install-backend-native, except env-var `npm_config_registry` wins
|
|
378
|
+
// over file values (matches npm's real semantics, lets the test harness
|
|
379
|
+
// point at a mock registry without touching `~/.npmrc`).
|
|
380
|
+
for (const candidate of [join(homedir(), ".npmrc"), join(cwd, ".npmrc")]) {
|
|
381
|
+
if (!existsSync(candidate))
|
|
382
|
+
continue;
|
|
383
|
+
try {
|
|
384
|
+
const proj = parseNpmrc(readFileSync(candidate, "utf-8"));
|
|
385
|
+
parsed = {
|
|
386
|
+
...parsed,
|
|
387
|
+
...proj,
|
|
388
|
+
scopes: { ...parsed.scopes, ...proj.scopes },
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// ignore malformed .npmrc — same lenient policy as install-backend
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if (process.env.npm_config_registry) {
|
|
396
|
+
parsed.registry = process.env.npm_config_registry;
|
|
397
|
+
}
|
|
398
|
+
return parsed;
|
|
399
|
+
}
|
|
400
|
+
function packumentToString(p) {
|
|
401
|
+
return `${p.name}@${p["dist-tags"]?.latest ?? "?"}`;
|
|
402
|
+
}
|