@gjsify/cli 0.4.13 → 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.
@@ -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
+ }