modern-cmdk-codemod 0.0.0-stage → 1.0.0

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.mjs ADDED
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env node
2
+ import { i as findDirtyFiles, n as transformFile, r as discoverFiles, t as createApiFactory } from "./chunk-worker.mjs";
3
+ import { findPackageJSON } from "node:module";
4
+ import { readFile, writeFile } from "node:fs/promises";
5
+ import { availableParallelism } from "node:os";
6
+ import { relative } from "node:path";
7
+ import { diff, parseArgs, styleText } from "node:util";
8
+ import { Worker } from "node:worker_threads";
9
+ // src/diff.ts
10
+ /** Marks a final line that has no newline; stripped again when the hunk is printed. */
11
+ const NO_EOL = "\0";
12
+ const CONTEXT = 3;
13
+ /** Split on LF or CRLF; a trailing newline adds no empty last line. */
14
+ function toLines(text) {
15
+ if (text === "") return [];
16
+ const lines = text.split(/\r?\n/);
17
+ const last = lines.pop();
18
+ if (last !== "") lines.push(last + NO_EOL);
19
+ return lines;
20
+ }
21
+ function range(start, count) {
22
+ const first = count === 0 ? start - 1 : start;
23
+ return count === 1 ? `${first}` : `${first},${count}`;
24
+ }
25
+ function printRow([op, text]) {
26
+ return text.endsWith(NO_EOL) ? [`${op}${text.slice(0, -1)}`, "\"] : [`${op}${text}`];
27
+ }
28
+ /**
29
+ * A unified diff of `before` against `after` for one file: `--- a/<path>`, `+++ b/<path>`, then
30
+ * `@@` hunks with 3 lines of context. Returns '' when the two are equal line for line.
31
+ */
32
+ function unifiedDiff(path, before, after) {
33
+ const rows = diff(toLines(before), toLines(after)).map(([op, text]) => [op === 0 ? " " : op === 1 ? "-" : "+", text]);
34
+ const changes = rows.flatMap(([op], index) => op === " " ? [] : [index]);
35
+ if (changes.length === 0) return "";
36
+ const groups = [];
37
+ for (const index of changes) {
38
+ const group = groups.at(-1);
39
+ if (group !== void 0 && index - group[1] - 1 <= 6) group[1] = index;
40
+ else groups.push([index, index]);
41
+ }
42
+ const out = [`--- a/${path}`, `+++ b/${path}`];
43
+ for (const [firstChange, lastChange] of groups) {
44
+ const start = Math.max(0, firstChange - CONTEXT);
45
+ const slice = rows.slice(start, Math.min(rows.length, lastChange + CONTEXT + 1));
46
+ const head = rows.slice(0, start);
47
+ const oldStart = head.filter(([op]) => op !== "+").length + 1;
48
+ const newStart = head.filter(([op]) => op !== "-").length + 1;
49
+ const oldCount = slice.filter(([op]) => op !== "+").length;
50
+ const newCount = slice.filter(([op]) => op !== "-").length;
51
+ out.push(`@@ -${range(oldStart, oldCount)} +${range(newStart, newCount)} @@`);
52
+ out.push(...slice.flatMap(printRow));
53
+ }
54
+ return out.join("\n");
55
+ }
56
+ // src/pool.ts
57
+ /**
58
+ * Run `task` over `items` with at most `concurrency` calls in flight.
59
+ * Resolves, once every item has settled, to one `PromiseSettledResult` per item in input order.
60
+ */
61
+ async function runPool(items, concurrency, task) {
62
+ const results = new Array(items.length);
63
+ const next = items.entries();
64
+ const runner = async () => {
65
+ for (const [index, item] of next) try {
66
+ results[index] = {
67
+ status: "fulfilled",
68
+ value: await task(item, index)
69
+ };
70
+ } catch (reason) {
71
+ results[index] = {
72
+ status: "rejected",
73
+ reason
74
+ };
75
+ }
76
+ };
77
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runner));
78
+ return results;
79
+ }
80
+ // src/cli.ts
81
+ const AVAILABLE_TRANSFORMS = /* @__PURE__ */ new Map([
82
+ ["import-rewrite", "Rewrites cmdk imports to modern-cmdk/react"],
83
+ ["data-attrs", "Converts [cmdk-*] selectors to [data-command-*]"],
84
+ ["forward-ref", "Removes React.forwardRef wrappers (React 19)"],
85
+ ["should-filter", "Converts shouldFilter prop to filter"]
86
+ ]);
87
+ /**
88
+ * Dynamically import a transform module by name.
89
+ */
90
+ async function loadTransform(name) {
91
+ return await import(
92
+ /* @vite-ignore */
93
+ `./transforms/${name}.mjs`
94
+ );
95
+ }
96
+ const NODE_FLOOR_LINES = {
97
+ "modern-cmdk": "Node >= 26.4.0 (the floor modern-cmdk's toolchain and create-modern-cmdk declare; modern-cmdk itself declares no engines)",
98
+ "modern-cmdk-codemod": "Node >= 26.4.0 (the engines.node of modern-cmdk-codemod)"
99
+ };
100
+ function usageText(stream, program) {
101
+ const bold = (s) => styleText("bold", s, { stream });
102
+ const opt = (s) => styleText("cyan", s.padEnd(17), { stream });
103
+ const profile = program === "modern-cmdk" ? ["modern-cmdk (this bin) is deprecated. It runs with --lenient unless --strict is given.", "Use: npx modern-cmdk-codemod <transform> <glob|dir>"] : ["modern-cmdk-codemod runs with --strict unless --lenient is given. A strict run without --dry-run", "refuses to write into a git tree with uncommitted changes (or outside a git tree) unless --force is given."];
104
+ return [
105
+ `${bold("Usage:")} ${program} <transform> <glob|dir> [options]`,
106
+ "",
107
+ ...profile,
108
+ "",
109
+ bold("Transforms:"),
110
+ ...AVAILABLE_TRANSFORMS.entries().map(([name, description]) => ` ${opt(name)}${description}`).toArray(),
111
+ "",
112
+ bold("Options:"),
113
+ ` ${opt("--dry-run")}List the files that would change; write nothing`,
114
+ ` ${opt("--strict")}Exit 2 on usage errors (incl. unknown options), 1 on file errors`,
115
+ ` ${opt("--concurrency=N")}Files processed at once (default: min(8, available cores))`,
116
+ ` ${opt("-h, --help")}Show this help`,
117
+ ` ${opt("--lenient")}Keep the lenient exit codes below (cannot be combined with --strict) and skip the clean-git-tree check (modern-cmdk-codemod only)`,
118
+ ` ${opt("--force")}Skip the clean-git-tree check (modern-cmdk-codemod only; modern-cmdk ignores it)`,
119
+ ` ${opt("-v, --version")}Print the package name and version`,
120
+ "",
121
+ bold("Exit codes:"),
122
+ " strict 0 success (reports included); 1 a file errored, no file matched or the run failed;",
123
+ " 2 usage error (unknown or malformed option, unknown transform, missing argument,",
124
+ " --strict with --lenient) or a refused dirty tree (modern-cmdk-codemod only)",
125
+ " lenient 0 for --help, missing arguments, usage errors and per-file errors; 1 for an unknown",
126
+ " transform, no matching files and an uncaught error",
127
+ "",
128
+ "Files run on worker threads when --concurrency is above 1; --concurrency=1 runs them on the main thread.",
129
+ "A directory argument covers its .ts .tsx .mts .cts .js .jsx .mjs .cjs .css .scss files (dot paths excluded).",
130
+ "--dry-run also prints a unified diff of every file that would change.",
131
+ "",
132
+ NODE_FLOOR_LINES[program],
133
+ ""
134
+ ].join("\n");
135
+ }
136
+ /** The name and version of the package this file ships in (the nearest package.json). */
137
+ async function readPackage() {
138
+ const path = findPackageJSON(import.meta.url);
139
+ if (path === void 0) throw new Error("cannot find this package's package.json");
140
+ return JSON.parse(await readFile(path, "utf-8"));
141
+ }
142
+ const OPTIONS = {
143
+ help: {
144
+ type: "boolean",
145
+ short: "h"
146
+ },
147
+ version: {
148
+ type: "boolean",
149
+ short: "v"
150
+ },
151
+ strict: { type: "boolean" },
152
+ lenient: { type: "boolean" },
153
+ force: { type: "boolean" },
154
+ "dry-run": { type: "boolean" },
155
+ concurrency: { type: "string" }
156
+ };
157
+ function parse(argv, program) {
158
+ const terminator = argv.indexOf("--");
159
+ const head = terminator === -1 ? argv : argv.slice(0, terminator);
160
+ if (head.includes("--strict") && head.includes("--lenient")) return {
161
+ kind: "usage-error",
162
+ message: "--strict and --lenient cannot be combined",
163
+ strict: true
164
+ };
165
+ const codemodProfile = program === "modern-cmdk-codemod";
166
+ const strictArg = head.includes("--strict") || codemodProfile && !head.includes("--lenient");
167
+ let values;
168
+ let positionals;
169
+ let ignored = [];
170
+ try {
171
+ ({values, positionals} = parseCli(argv));
172
+ } catch (err) {
173
+ if (err.code !== "ERR_PARSE_ARGS_UNKNOWN_OPTION") return {
174
+ kind: "usage-error",
175
+ message: err instanceof Error ? err.message : String(err),
176
+ strict: strictArg
177
+ };
178
+ const loose = parseArgs({
179
+ args: [...argv],
180
+ options: OPTIONS,
181
+ allowPositionals: true,
182
+ strict: false,
183
+ tokens: true
184
+ });
185
+ ignored = loose.tokens.filter((t) => t.kind === "option" && !Object.hasOwn(OPTIONS, t.name)).map((t) => t.rawName);
186
+ const badValue = loose.tokens.find((t) => t.kind === "option" && Object.hasOwn(OPTIONS, t.name) && (OPTIONS[t.name].type === "boolean" ? t.value !== void 0 : t.value === void 0 || !t.inlineValue && t.value.length > 1 && t.value.startsWith("-")));
187
+ const badMessage = badValue?.kind === "option" ? badValue.value === void 0 ? `Option '${badValue.rawName} <value>' argument missing` : OPTIONS[badValue.name].type === "boolean" ? `Option '${badValue.rawName}' does not take an argument` : `Option '${badValue.rawName}' argument is ambiguous.\nDid you forget to specify the option argument for '${badValue.rawName}'?\nTo specify an option argument starting with a dash use '${badValue.rawName}=-XYZ'.` : void 0;
188
+ if (badMessage !== void 0) return {
189
+ kind: "usage-error",
190
+ message: badMessage,
191
+ strict: strictArg
192
+ };
193
+ if (strictArg || loose.values.help === true || loose.values.version === true || loose.positionals.length < 2) return {
194
+ kind: "usage-error",
195
+ message: `unknown option ${ignored[0]}`,
196
+ strict: strictArg
197
+ };
198
+ values = loose.values;
199
+ positionals = loose.positionals;
200
+ }
201
+ const strict = values.strict === true || values.lenient !== true && codemodProfile;
202
+ if (values.help) return { kind: "help" };
203
+ if (values.version) return { kind: "version" };
204
+ if (positionals.length < 2) return {
205
+ kind: "usage-error",
206
+ message: void 0,
207
+ strict
208
+ };
209
+ if (strict && positionals.length > 2) return {
210
+ kind: "usage-error",
211
+ message: `unexpected argument "${positionals[2]}" (quote the glob so the shell does not expand it)`,
212
+ strict
213
+ };
214
+ let concurrency = defaultConcurrency();
215
+ if (values.concurrency !== void 0) {
216
+ if (typeof values.concurrency !== "string" || !/^[1-9]\d*$/.test(values.concurrency)) return {
217
+ kind: "usage-error",
218
+ message: `--concurrency must be a positive integer, got "${String(values.concurrency)}"`,
219
+ strict
220
+ };
221
+ concurrency = Number.parseInt(values.concurrency, 10);
222
+ }
223
+ return {
224
+ kind: "run",
225
+ transformName: positionals[0],
226
+ fileGlob: positionals[1],
227
+ dryRun: values["dry-run"] === true,
228
+ force: values.force === true,
229
+ strict,
230
+ concurrency,
231
+ ignored
232
+ };
233
+ }
234
+ /** The default for --concurrency: one file per core, at most 8. */
235
+ function defaultConcurrency(cores = availableParallelism()) {
236
+ return Math.min(8, cores);
237
+ }
238
+ function parseCli(argv) {
239
+ return parseArgs({
240
+ args: [...argv],
241
+ options: OPTIONS,
242
+ allowPositionals: true,
243
+ strict: true
244
+ });
245
+ }
246
+ /** Write a changed file (or build its dry-run diff) on the main thread and fold the outcome into a result. */
247
+ async function finish(outcome, dryRun, rel) {
248
+ const { path: filePath, reports } = outcome;
249
+ if (outcome.error !== void 0) return {
250
+ filePath,
251
+ status: "error",
252
+ reports,
253
+ error: new Error(outcome.error)
254
+ };
255
+ if (outcome.skipped) return {
256
+ filePath,
257
+ status: "skipped",
258
+ reports
259
+ };
260
+ if (!outcome.changed) return {
261
+ filePath,
262
+ status: "unchanged",
263
+ reports
264
+ };
265
+ try {
266
+ const output = outcome.output;
267
+ if (!dryRun) await writeFile(filePath, output, "utf-8");
268
+ return {
269
+ filePath,
270
+ status: "changed",
271
+ ...dryRun ? { diff: unifiedDiff(rel(filePath), outcome.source, output) } : {},
272
+ reports
273
+ };
274
+ } catch (err) {
275
+ return {
276
+ filePath,
277
+ status: "error",
278
+ reports,
279
+ error: err instanceof Error ? err : new Error(String(err))
280
+ };
281
+ }
282
+ }
283
+ /** Start `size` worker threads for `transform`; the transformer hands each file to an idle one. */
284
+ function startWorkers(transform, keepSource, size) {
285
+ const init = {
286
+ kind: "modern-cmdk-codemod-worker",
287
+ transform,
288
+ keepSource
289
+ };
290
+ const slots = Array.from({ length: size }, () => ({ worker: new Worker(new URL("./worker.mjs", import.meta.url), { workerData: init }) }));
291
+ const pending = /* @__PURE__ */ new Map();
292
+ for (const slot of slots) {
293
+ const fail = (error) => {
294
+ slot.dead ??= error;
295
+ pending.get(slot)?.reject(slot.dead);
296
+ };
297
+ slot.worker.on("message", (outcome) => pending.get(slot)?.resolve(outcome));
298
+ slot.worker.on("error", fail);
299
+ slot.worker.on("exit", (code) => fail(/* @__PURE__ */ new Error(`worker thread exited with code ${code}`)));
300
+ }
301
+ const idle = [...slots];
302
+ const transformer = async (filePath) => {
303
+ const slot = idle.pop();
304
+ try {
305
+ if (slot.dead !== void 0) throw slot.dead;
306
+ const request = Promise.withResolvers();
307
+ pending.set(slot, request);
308
+ slot.worker.postMessage(filePath);
309
+ return await request.promise;
310
+ } finally {
311
+ pending.delete(slot);
312
+ idle.push(slot);
313
+ }
314
+ };
315
+ return {
316
+ transformer,
317
+ close: async () => {
318
+ await Promise.all(slots.map((slot) => slot.worker.terminate()));
319
+ }
320
+ };
321
+ }
322
+ /** The message for a refused dirty-tree run: up to 10 paths, the count, and the way out. */
323
+ function dirtyMessage(paths, rel) {
324
+ const shown = paths.slice(0, 10).map((p) => ` ${rel(p)}`);
325
+ const more = paths.length > shown.length ? [` ... and ${paths.length - shown.length} more`] : [];
326
+ return [
327
+ `refusing to write: ${paths.length} matched file${paths.length === 1 ? " has" : "s have"} uncommitted changes:`,
328
+ ...shown,
329
+ ...more,
330
+ "Commit or stash them, or pass --force to write anyway (--dry-run previews without writing)."
331
+ ].join("\n");
332
+ }
333
+ /**
334
+ * Run the codemod CLI. Resolves to the process exit code; writes only to `io`.
335
+ */
336
+ async function run(argv, io, deps = {}) {
337
+ const out = (s) => void io.stdout.write(`${s}\n`);
338
+ const err = (s) => void io.stderr.write(`${s}\n`);
339
+ const fail = (s) => err(`${styleText("red", "error:", { stream: io.stderr })} ${s}`);
340
+ try {
341
+ const pkg = deps.program === void 0 ? await readPackage() : void 0;
342
+ const program = deps.program ?? (pkg?.name === "modern-cmdk" ? "modern-cmdk" : "modern-cmdk-codemod");
343
+ const parsed = parse(argv, program);
344
+ if (parsed.kind === "help") {
345
+ out(usageText(io.stdout, program));
346
+ return 0;
347
+ }
348
+ if (parsed.kind === "version") {
349
+ const { name, version } = pkg ?? await readPackage();
350
+ out(`${name} ${version}`);
351
+ return 0;
352
+ }
353
+ if (parsed.kind === "usage-error") {
354
+ if (parsed.message !== void 0) fail(parsed.message);
355
+ err(usageText(io.stderr, program));
356
+ return parsed.strict ? 2 : 0;
357
+ }
358
+ const { transformName, fileGlob, dryRun, force, strict, concurrency, ignored } = parsed;
359
+ for (const name of ignored) err(`warning: unknown option ${name} ignored (--strict makes this exit 2)`);
360
+ if (!AVAILABLE_TRANSFORMS.has(transformName)) {
361
+ fail(`unknown transform "${transformName}". Valid transforms: ${[...AVAILABLE_TRANSFORMS.keys()].join(", ")}`);
362
+ return strict ? 2 : 1;
363
+ }
364
+ const cwd = process.cwd();
365
+ const files = await discoverFiles([fileGlob], {
366
+ program,
367
+ cwd,
368
+ warn: err
369
+ });
370
+ if (files.length === 0) {
371
+ fail(`no files matched "${fileGlob}"`);
372
+ return 1;
373
+ }
374
+ const rel = (p) => relative(cwd, p).replaceAll("\\", "/");
375
+ if (program === "modern-cmdk-codemod" && strict && !dryRun && !force) {
376
+ const dirty = await findDirtyFiles(files, cwd);
377
+ if (dirty.kind === "outside") {
378
+ const n = dirty.paths.length;
379
+ const shown = dirty.paths.slice(0, 10).map((p) => ` ${rel(p)}`);
380
+ fail([
381
+ `refusing to write: ${n} matched file${n === 1 ? " lies" : "s lie"} outside this git work tree:`,
382
+ ...shown,
383
+ ...n > shown.length ? [` ... and ${n - shown.length} more`] : [],
384
+ "Run the codemod from that tree, or pass --force to write anyway."
385
+ ].join("\n"));
386
+ return 2;
387
+ }
388
+ if (dirty.kind === "dirty") {
389
+ fail(dirtyMessage(dirty.paths, rel));
390
+ return 2;
391
+ }
392
+ if (dirty.kind !== "clean") {
393
+ fail(dirty.kind === "no-git" ? "refusing to write: git not found, so a clean tree cannot be confirmed. Pass --force to write anyway." : "refusing to write outside a git work tree: nothing could undo it. Pass --force to write anyway.");
394
+ return 2;
395
+ }
396
+ }
397
+ const mod = await (deps.loadTransform ?? loadTransform)(transformName);
398
+ let transformer;
399
+ let closeWorkers = async () => {};
400
+ if (concurrency > 1 && deps.loadTransform === void 0) ({transformer, close: closeWorkers} = startWorkers(transformName, dryRun, Math.min(concurrency, files.length)));
401
+ else {
402
+ const apiForParser = createApiFactory();
403
+ transformer = (filePath) => transformFile(filePath, mod, apiForParser, dryRun);
404
+ }
405
+ let settled;
406
+ try {
407
+ settled = await runPool(files, concurrency, async (filePath) => finish(await transformer(filePath), dryRun, rel));
408
+ } finally {
409
+ await closeWorkers();
410
+ }
411
+ const results = settled.map((r, i) => r.status === "fulfilled" ? r.value : {
412
+ filePath: files[i],
413
+ status: "error",
414
+ reports: [],
415
+ error: r.reason instanceof Error ? r.reason : new Error(String(r.reason))
416
+ });
417
+ const counts = {
418
+ changed: 0,
419
+ unchanged: 0,
420
+ skipped: 0,
421
+ error: 0,
422
+ reported: 0
423
+ };
424
+ for (const r of results) {
425
+ counts[r.status]++;
426
+ counts.reported += r.reports.length;
427
+ for (const message of r.reports) err(`${rel(r.filePath)}: ${message}`);
428
+ if (r.error) err(`${rel(r.filePath)}: ${r.error.message}`);
429
+ else if (r.status === "changed") {
430
+ out(`${styleText("green", dryRun ? "would change" : "changed", { stream: io.stdout })} ${rel(r.filePath)}`);
431
+ if (r.diff) out(r.diff);
432
+ }
433
+ }
434
+ out(`${counts.changed} changed, ${counts.unchanged} unchanged, ${counts.error} errors` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.reported > 0 ? `, ${counts.reported} reported` : "") + (dryRun ? " (dry run, nothing written)" : ""));
435
+ return strict && counts.error > 0 ? 1 : 0;
436
+ } catch (e) {
437
+ fail(e instanceof Error ? e.message : String(e));
438
+ return 1;
439
+ }
440
+ }
441
+ if (import.meta.main) process.exitCode = await run(process.argv.slice(2), process);
442
+ export { defaultConcurrency, run };