@lablup/ui-common-cli 0.2.0-alpha.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.
- package/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +28 -0
- package/bin/ui-common.mjs +12 -0
- package/cli/agents.mjs +335 -0
- package/cli/diff.mjs +125 -0
- package/cli/lab-peer.mjs +226 -0
- package/cli/main.mjs +97 -0
- package/cli/passthrough.mjs +153 -0
- package/cli/paths.mjs +290 -0
- package/cli/report.mjs +157 -0
- package/cli/resolve.mjs +257 -0
- package/cli/rewrite.mjs +133 -0
- package/cli/semver.mjs +278 -0
- package/cli/shadow.mjs +161 -0
- package/cli/sync-astryx.mjs +417 -0
- package/cli/upgrade.mjs +776 -0
- package/codemods/0.2/components.mjs +903 -0
- package/codemods/0.2/elements.mjs +745 -0
- package/codemods/0.2/index.mjs +68 -0
- package/codemods/0.2/legacy-classes.json +292 -0
- package/codemods/0.2/local-modules.mjs +510 -0
- package/codemods/0.2/map.mjs +178 -0
- package/codemods/0.2/package-json.mjs +333 -0
- package/codemods/0.2/scan.mjs +397 -0
- package/codemods/0.2/stylesheets.mjs +479 -0
- package/codemods/0.2/theme.mjs +325 -0
- package/codemods/lib/jsx.mjs +352 -0
- package/codemods/lib/todo.mjs +151 -0
- package/codemods/registry.mjs +95 -0
- package/codemods/upstream.mjs +155 -0
- package/migration/0.1-to-0.2.json +629 -0
- package/package.json +50 -0
package/cli/upgrade.mjs
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ui-common upgrade`: run the registered codemod steps between two ui-common
|
|
3
|
+
* versions over a consumer's source, edit its package.json, and write a
|
|
4
|
+
* manual-review report.
|
|
5
|
+
*
|
|
6
|
+
* All edits are made in memory first. `--dry-run` stops there and prints what
|
|
7
|
+
* would change and the report, writing nothing (the report only to an
|
|
8
|
+
* explicit `--report <path>`); otherwise the files and the report are
|
|
9
|
+
* written. A file is written only if this run read it, or created it where
|
|
10
|
+
* nothing existed.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
statSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
22
|
+
|
|
23
|
+
import { registeredVersions, stepsBetween } from "../codemods/registry.mjs";
|
|
24
|
+
import { TODO_TAG } from "../codemods/lib/jsx.mjs";
|
|
25
|
+
import { diffStat, unifiedDiff } from "./diff.mjs";
|
|
26
|
+
import { cliPackageJson, findProjectDir } from "./paths.mjs";
|
|
27
|
+
import { createResolver } from "./resolve.mjs";
|
|
28
|
+
import { renderReport, REPORT_HEADING } from "./report.mjs";
|
|
29
|
+
import { coerce, compare, parse } from "./semver.mjs";
|
|
30
|
+
|
|
31
|
+
const IGNORED_DIRS = new Set([
|
|
32
|
+
"node_modules",
|
|
33
|
+
".git",
|
|
34
|
+
"dist",
|
|
35
|
+
"build",
|
|
36
|
+
"out",
|
|
37
|
+
".next",
|
|
38
|
+
"coverage",
|
|
39
|
+
".turbo",
|
|
40
|
+
".cache",
|
|
41
|
+
// Only skipped by the project-wide finding scan (collectProjectFiles):
|
|
42
|
+
// build output and tool state that is never the project's own source.
|
|
43
|
+
"target",
|
|
44
|
+
".venv",
|
|
45
|
+
"venv",
|
|
46
|
+
"__pycache__",
|
|
47
|
+
"storybook-static",
|
|
48
|
+
"playwright-report",
|
|
49
|
+
"test-results",
|
|
50
|
+
".svelte-kit",
|
|
51
|
+
".nuxt",
|
|
52
|
+
".output",
|
|
53
|
+
".vite",
|
|
54
|
+
".yarn",
|
|
55
|
+
".pnpm-store",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/** Bigger than any hand-written source; a bundle or a generated file. */
|
|
59
|
+
const MAX_SCAN_BYTES = 512 * 1024;
|
|
60
|
+
|
|
61
|
+
export const SOURCE_EXTENSIONS = new Set([
|
|
62
|
+
".tsx",
|
|
63
|
+
".ts",
|
|
64
|
+
".jsx",
|
|
65
|
+
".js",
|
|
66
|
+
".mjs",
|
|
67
|
+
".cjs",
|
|
68
|
+
".mts",
|
|
69
|
+
".cts",
|
|
70
|
+
".css",
|
|
71
|
+
".scss",
|
|
72
|
+
".sass",
|
|
73
|
+
".less",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} dir
|
|
78
|
+
* @param {string[]} out
|
|
79
|
+
*/
|
|
80
|
+
function walk(dir, out) {
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
84
|
+
} catch {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.isSymbolicLink()) continue;
|
|
89
|
+
const full = join(dir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
if (!IGNORED_DIRS.has(entry.name)) walk(full, out);
|
|
92
|
+
} else if (
|
|
93
|
+
SOURCE_EXTENSIONS.has(extname(entry.name)) &&
|
|
94
|
+
!entry.name.endsWith(".d.ts")
|
|
95
|
+
) {
|
|
96
|
+
out.push(full);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** @param {string[]} paths absolute */
|
|
102
|
+
export function collectFiles(paths) {
|
|
103
|
+
/** @type {string[]} */
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
if (!existsSync(p)) continue;
|
|
107
|
+
if (readdirSafe(p)) walk(p, out);
|
|
108
|
+
else if (SOURCE_EXTENSIONS.has(extname(p))) out.push(p);
|
|
109
|
+
}
|
|
110
|
+
return [...new Set(out)].sort();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param {string} p */
|
|
114
|
+
function readdirSafe(p) {
|
|
115
|
+
try {
|
|
116
|
+
readdirSync(p);
|
|
117
|
+
return true;
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The files the manual-review scan reads: every source-like file under
|
|
125
|
+
* `roots`, without build output, generated directories, bundles, and
|
|
126
|
+
* packages nested in the project (another package.json below it is another
|
|
127
|
+
* project). In a git checkout the list comes from git, so .gitignore'd files
|
|
128
|
+
* are skipped too.
|
|
129
|
+
*
|
|
130
|
+
* @param {string[]} roots absolute
|
|
131
|
+
* @param {string} projectDir
|
|
132
|
+
*/
|
|
133
|
+
export function collectProjectFiles(roots, projectDir) {
|
|
134
|
+
/** @type {string[]} */
|
|
135
|
+
let listed = [];
|
|
136
|
+
const git = spawnSync(
|
|
137
|
+
"git",
|
|
138
|
+
["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."],
|
|
139
|
+
{ cwd: projectDir, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 },
|
|
140
|
+
);
|
|
141
|
+
if (git.status === 0 && git.stdout) {
|
|
142
|
+
listed = git.stdout
|
|
143
|
+
.split("\0")
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.map((f) => join(projectDir, f));
|
|
146
|
+
} else {
|
|
147
|
+
walk(projectDir, listed);
|
|
148
|
+
// walk keeps sources only; package.json files mark nested packages.
|
|
149
|
+
listed.push(...findPackageJsons(projectDir));
|
|
150
|
+
}
|
|
151
|
+
const nested = new Set(
|
|
152
|
+
listed
|
|
153
|
+
.filter((f) => f.endsWith(`${sep}package.json`) && dirname(f) !== projectDir)
|
|
154
|
+
.map((f) => dirname(f) + sep),
|
|
155
|
+
);
|
|
156
|
+
const inRoots = (/** @type {string} */ f) =>
|
|
157
|
+
roots.some((r) => f === r || f.startsWith(r.endsWith(sep) ? r : r + sep));
|
|
158
|
+
return [
|
|
159
|
+
...new Set(
|
|
160
|
+
listed.filter((f) => {
|
|
161
|
+
if (!inRoots(f)) return false;
|
|
162
|
+
if (!SOURCE_EXTENSIONS.has(extname(f)) || f.endsWith(".d.ts")) return false;
|
|
163
|
+
if (/\.min\.[cm]?[jt]s$|\.min\.css$/.test(f)) return false;
|
|
164
|
+
const parts = relative(projectDir, f).split(sep);
|
|
165
|
+
if (parts.slice(0, -1).some((d) => IGNORED_DIRS.has(d))) return false;
|
|
166
|
+
for (const dir of nested) if (f.startsWith(dir)) return false;
|
|
167
|
+
try {
|
|
168
|
+
return statSync(f).size <= MAX_SCAN_BYTES;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}),
|
|
173
|
+
),
|
|
174
|
+
].sort();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {string} dir */
|
|
178
|
+
function findPackageJsons(dir) {
|
|
179
|
+
/** @type {string[]} */
|
|
180
|
+
const out = [];
|
|
181
|
+
const visit = (/** @type {string} */ d) => {
|
|
182
|
+
let entries;
|
|
183
|
+
try {
|
|
184
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
185
|
+
} catch {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const entry of entries) {
|
|
189
|
+
if (entry.isSymbolicLink()) continue;
|
|
190
|
+
const full = join(d, entry.name);
|
|
191
|
+
if (entry.isDirectory()) {
|
|
192
|
+
if (!IGNORED_DIRS.has(entry.name)) visit(full);
|
|
193
|
+
} else if (entry.name === "package.json") out.push(full);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
visit(dir);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @typedef {object} UpgradeOptions
|
|
202
|
+
* @property {string} cwd
|
|
203
|
+
* @property {string[]} paths as given (relative to cwd)
|
|
204
|
+
* @property {string[]} [scan] where the manual-review scan looks (relative to
|
|
205
|
+
* cwd); default: the whole project
|
|
206
|
+
* @property {string} [from]
|
|
207
|
+
* @property {string} [to]
|
|
208
|
+
* @property {boolean} [dryRun]
|
|
209
|
+
* @property {string} [report] report path
|
|
210
|
+
* @property {boolean} [diff] print unified diffs in a dry run
|
|
211
|
+
* @property {(line: string) => void} [log]
|
|
212
|
+
* @property {(line: string) => void} [warn]
|
|
213
|
+
*/
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {UpgradeOptions} options
|
|
217
|
+
*/
|
|
218
|
+
export async function runUpgrade(options) {
|
|
219
|
+
const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
220
|
+
const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`));
|
|
221
|
+
const cwd = resolve(options.cwd);
|
|
222
|
+
const projectDir = findProjectDir(cwd) ?? cwd;
|
|
223
|
+
const pkgFile = join(projectDir, "package.json");
|
|
224
|
+
const pkgText = existsSync(pkgFile) ? readFileSync(pkgFile, "utf8") : null;
|
|
225
|
+
const pkg = pkgText ? JSON.parse(pkgText) : {};
|
|
226
|
+
// The codemods ship with the CLI, which moves in lockstep with ui-common:
|
|
227
|
+
// its version is the ui-common version they upgrade to.
|
|
228
|
+
const own = cliPackageJson();
|
|
229
|
+
|
|
230
|
+
const declared =
|
|
231
|
+
pkg.dependencies?.["@lablup/ui-common"] ??
|
|
232
|
+
pkg.devDependencies?.["@lablup/ui-common"] ??
|
|
233
|
+
pkg.peerDependencies?.["@lablup/ui-common"];
|
|
234
|
+
const from = options.from ? coerce(options.from) : declared ? coerce(declared) : null;
|
|
235
|
+
if (!from) {
|
|
236
|
+
warn(
|
|
237
|
+
options.from
|
|
238
|
+
? `ui-common upgrade: --from "${options.from}" is not a version.`
|
|
239
|
+
: `ui-common upgrade: cannot tell which ui-common version this project is on (package.json: ${declared ?? "no @lablup/ui-common"}). Pass --from <version>.`,
|
|
240
|
+
);
|
|
241
|
+
return { code: 2 };
|
|
242
|
+
}
|
|
243
|
+
const to = options.to ? coerce(options.to) : own.version;
|
|
244
|
+
if (!to || !parse(to)) {
|
|
245
|
+
warn(`ui-common upgrade: --to "${options.to}" is not a version.`);
|
|
246
|
+
return { code: 2 };
|
|
247
|
+
}
|
|
248
|
+
if (compare(from, to) >= 0) {
|
|
249
|
+
log(`ui-common upgrade: ${from} → ${to} is not an upgrade; nothing to do.`);
|
|
250
|
+
return { code: 0 };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const steps = await stepsBetween(from, to);
|
|
254
|
+
if (steps.length === 0) {
|
|
255
|
+
log(
|
|
256
|
+
`ui-common upgrade: no codemods between ${from} and ${to} (registered: ${registeredVersions().join(", ")}).`,
|
|
257
|
+
);
|
|
258
|
+
return { code: 0 };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const roots = (options.paths.length > 0 ? options.paths : ["src"]).map((p) =>
|
|
262
|
+
resolve(cwd, p),
|
|
263
|
+
);
|
|
264
|
+
const missing = roots.filter((p) => !existsSync(p));
|
|
265
|
+
if (missing.length === roots.length) {
|
|
266
|
+
warn(
|
|
267
|
+
`ui-common upgrade: nothing to scan: ${missing.map((p) => relative(cwd, p) || ".").join(", ")} not found.`,
|
|
268
|
+
);
|
|
269
|
+
return { code: 2 };
|
|
270
|
+
}
|
|
271
|
+
const files = collectFiles(roots);
|
|
272
|
+
const scanRoots =
|
|
273
|
+
options.scan && options.scan.length > 0
|
|
274
|
+
? options.scan.map((p) => resolve(cwd, p))
|
|
275
|
+
: [projectDir];
|
|
276
|
+
|
|
277
|
+
// A dry run prints the report unless --report names a file for it. The
|
|
278
|
+
// report replaces an earlier report, never anything else.
|
|
279
|
+
const writeReport = !options.dryRun || options.report != null;
|
|
280
|
+
const reportFile = resolve(cwd, options.report ?? "ui-common-upgrade-report.md");
|
|
281
|
+
if (
|
|
282
|
+
writeReport &&
|
|
283
|
+
existsSync(reportFile) &&
|
|
284
|
+
!readFileSync(reportFile, "utf8").startsWith(REPORT_HEADING)
|
|
285
|
+
) {
|
|
286
|
+
warn(
|
|
287
|
+
`ui-common upgrade: ${relative(cwd, reportFile)} exists and is not an upgrade report; not overwriting it. Pass --report <path>.`,
|
|
288
|
+
);
|
|
289
|
+
return { code: 2 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const { default: jscodeshift } = await import("jscodeshift");
|
|
293
|
+
|
|
294
|
+
/** @type {Map<string, {original: string, current: string, transforms: string[], created: boolean, project?: boolean}>} */
|
|
295
|
+
const state = new Map();
|
|
296
|
+
for (const file of files) {
|
|
297
|
+
const source = readFileSync(file, "utf8");
|
|
298
|
+
state.set(file, {
|
|
299
|
+
original: source,
|
|
300
|
+
current: source,
|
|
301
|
+
transforms: [],
|
|
302
|
+
created: false,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const rel = (/** @type {string} */ file) =>
|
|
306
|
+
relative(projectDir, file).split(sep).join("/");
|
|
307
|
+
|
|
308
|
+
/** @type {string[]} */
|
|
309
|
+
const packageNotes = [];
|
|
310
|
+
/** @type {Array<{file: string, transform: string, error: string}>} */
|
|
311
|
+
const errors = [];
|
|
312
|
+
/** @type {string[]} */
|
|
313
|
+
const notices = [];
|
|
314
|
+
/** @type {string[]} */
|
|
315
|
+
const alerts = [];
|
|
316
|
+
const resolver = createResolver(projectDir);
|
|
317
|
+
/** @type {Map<string, string | null>} */
|
|
318
|
+
const unscanned = new Map();
|
|
319
|
+
const ctx = {
|
|
320
|
+
from,
|
|
321
|
+
to,
|
|
322
|
+
flags: { packages: new Map(), touched: new Set() },
|
|
323
|
+
projectDir,
|
|
324
|
+
note: (/** @type {string} */ message) => packageNotes.push(message),
|
|
325
|
+
/**
|
|
326
|
+
* A project file as it was before this run: the scanned sources from
|
|
327
|
+
* memory, anything else from disk (read once). Null outside the project
|
|
328
|
+
* or when unreadable.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} path absolute
|
|
331
|
+
*/
|
|
332
|
+
source: (path) => {
|
|
333
|
+
const known = state.get(path);
|
|
334
|
+
if (known) return known.created ? null : known.original;
|
|
335
|
+
if (
|
|
336
|
+
!path.startsWith(projectDir + sep) ||
|
|
337
|
+
path.split(sep).includes("node_modules")
|
|
338
|
+
)
|
|
339
|
+
return null;
|
|
340
|
+
if (!unscanned.has(path)) {
|
|
341
|
+
let text = null;
|
|
342
|
+
try {
|
|
343
|
+
text = readFileSync(path, "utf8");
|
|
344
|
+
} catch {
|
|
345
|
+
// left null
|
|
346
|
+
}
|
|
347
|
+
unscanned.set(path, text);
|
|
348
|
+
}
|
|
349
|
+
return unscanned.get(path) ?? null;
|
|
350
|
+
},
|
|
351
|
+
/** The project file an import specifier names (relative or a tsconfig alias), or null. */
|
|
352
|
+
resolveImport: resolver.resolveImport,
|
|
353
|
+
/** The project's package.json, parsed, as it was before this run. */
|
|
354
|
+
pkg,
|
|
355
|
+
/**
|
|
356
|
+
* Something the person has to do before the upgraded app works: the
|
|
357
|
+
* report opens with these.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} message
|
|
360
|
+
*/
|
|
361
|
+
alert: (message) => alerts.push(message),
|
|
362
|
+
/** @param {string} message where the run did something other than the usual */
|
|
363
|
+
notice: (message) => notices.push(message),
|
|
364
|
+
/** Every file the finding scan would read (the whole project), absolute. */
|
|
365
|
+
projectFiles: () => collectProjectFiles([projectDir], projectDir),
|
|
366
|
+
/**
|
|
367
|
+
* A file's content as this run has it now (edits included), or from disk.
|
|
368
|
+
*
|
|
369
|
+
* @param {string} path absolute
|
|
370
|
+
*/
|
|
371
|
+
current: (path) => {
|
|
372
|
+
const known = state.get(path);
|
|
373
|
+
if (known) return known.current;
|
|
374
|
+
try {
|
|
375
|
+
return readFileSync(path, "utf8");
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
/**
|
|
381
|
+
* Edit a project file outside the scanned sources (pnpm-workspace.yaml).
|
|
382
|
+
* `edit` gets its current text (null: absent) and returns the new text,
|
|
383
|
+
* or undefined to leave it. The write goes through the same checks, diff
|
|
384
|
+
* and dry run as the sources.
|
|
385
|
+
*
|
|
386
|
+
* @param {string} path
|
|
387
|
+
* @param {(current: string | null) => string | undefined} edit
|
|
388
|
+
* @param {string} [id] the transform named in the report
|
|
389
|
+
*/
|
|
390
|
+
editFile: (path, edit, id = "package-json") => {
|
|
391
|
+
const known = state.get(path);
|
|
392
|
+
const current = known
|
|
393
|
+
? known.current
|
|
394
|
+
: existsSync(path)
|
|
395
|
+
? readFileSync(path, "utf8")
|
|
396
|
+
: null;
|
|
397
|
+
const next = edit(current);
|
|
398
|
+
if (next == null || next === current) return;
|
|
399
|
+
if (known) {
|
|
400
|
+
known.current = next;
|
|
401
|
+
if (!known.transforms.includes(id)) known.transforms.push(id);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
state.set(path, {
|
|
405
|
+
original: current ?? "",
|
|
406
|
+
current: next,
|
|
407
|
+
transforms: [id],
|
|
408
|
+
created: current == null,
|
|
409
|
+
project: true,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
412
|
+
/**
|
|
413
|
+
* Claim a new file and return the path it will be written to. A file the
|
|
414
|
+
* project already has, scanned or not, is never overwritten: one holding
|
|
415
|
+
* exactly `content` is reused, otherwise the next free numbered sibling
|
|
416
|
+
* (`ui-common-entry-2.css`) is used and the report says so.
|
|
417
|
+
*
|
|
418
|
+
* @param {string} path
|
|
419
|
+
* @param {string} content
|
|
420
|
+
*/
|
|
421
|
+
createFile: (path, content) => {
|
|
422
|
+
const ext = extname(path);
|
|
423
|
+
const stem = path.slice(0, path.length - ext.length);
|
|
424
|
+
for (let n = 1; ; n++) {
|
|
425
|
+
const candidate = n === 1 ? path : `${stem}-${n}${ext}`;
|
|
426
|
+
const known = state.get(candidate);
|
|
427
|
+
const onDisk =
|
|
428
|
+
known?.current ??
|
|
429
|
+
(existsSync(candidate) ? readFileSync(candidate, "utf8") : null);
|
|
430
|
+
if (onDisk === content) return candidate;
|
|
431
|
+
if (onDisk != null) continue;
|
|
432
|
+
if (n > 1 && !notices.some((m) => m.startsWith(`${rel(path)} `))) {
|
|
433
|
+
notices.push(
|
|
434
|
+
`${rel(path)} exists already and is not the 0.2 stylesheet entry, so it was left alone. The entry was written to ${rel(candidate)} instead, and the script that imported styles/base.css imports it.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
state.set(candidate, {
|
|
438
|
+
original: "",
|
|
439
|
+
current: content,
|
|
440
|
+
transforms: ["stylesheet-entry"],
|
|
441
|
+
created: true,
|
|
442
|
+
});
|
|
443
|
+
return candidate;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
for (const { version, step } of steps) {
|
|
449
|
+
log(`Applying ${step.title} (${version})`);
|
|
450
|
+
for (const transform of step.transforms) {
|
|
451
|
+
const extensions = new Set(transform.extensions);
|
|
452
|
+
for (const [file, entry] of state) {
|
|
453
|
+
if (entry.created || !extensions.has(extname(file))) continue;
|
|
454
|
+
const ext = extname(file);
|
|
455
|
+
const j = jscodeshift.withParser(/\.[cm]?tsx?$/.test(ext) ? "tsx" : "babel");
|
|
456
|
+
let out;
|
|
457
|
+
try {
|
|
458
|
+
out = transform.run(
|
|
459
|
+
{ path: file, source: entry.current },
|
|
460
|
+
{ jscodeshift: j },
|
|
461
|
+
ctx,
|
|
462
|
+
);
|
|
463
|
+
} catch (err) {
|
|
464
|
+
errors.push({
|
|
465
|
+
file: rel(file),
|
|
466
|
+
transform: transform.id,
|
|
467
|
+
error: /** @type {Error} */ (err).message,
|
|
468
|
+
});
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
if (out == null || out === entry.current) continue;
|
|
472
|
+
if (transform.parse) {
|
|
473
|
+
try {
|
|
474
|
+
j(out);
|
|
475
|
+
} catch (err) {
|
|
476
|
+
errors.push({
|
|
477
|
+
file: rel(file),
|
|
478
|
+
transform: transform.id,
|
|
479
|
+
error: `produced unparseable output, not applied: ${/** @type {Error} */ (err).message}`,
|
|
480
|
+
});
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
entry.current = out;
|
|
485
|
+
if (!entry.transforms.includes(transform.id))
|
|
486
|
+
entry.transforms.push(transform.id);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Project-level edits that need every file transformed first.
|
|
492
|
+
for (const { step } of steps) {
|
|
493
|
+
if (!step.afterTransforms) continue;
|
|
494
|
+
try {
|
|
495
|
+
step.afterTransforms(ctx, { jscodeshift });
|
|
496
|
+
} catch (err) {
|
|
497
|
+
errors.push({
|
|
498
|
+
file: ".",
|
|
499
|
+
transform: "after-transforms",
|
|
500
|
+
error: /** @type {Error} */ (err).message,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// package.json, after every source transform has set its flags.
|
|
506
|
+
let pkgAfter = pkgText;
|
|
507
|
+
if (pkgText) {
|
|
508
|
+
for (const { step } of steps) {
|
|
509
|
+
if (!step.packageJson || pkgAfter == null) continue;
|
|
510
|
+
try {
|
|
511
|
+
pkgAfter = step.packageJson(pkgAfter, ctx) ?? pkgAfter;
|
|
512
|
+
} catch (err) {
|
|
513
|
+
errors.push({
|
|
514
|
+
file: "package.json",
|
|
515
|
+
transform: "package-json",
|
|
516
|
+
error: /** @type {Error} */ (err).message,
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Report-only scans, over the final content.
|
|
523
|
+
/** @type {Array<{category: string, file: string, line: number, text: string, detail?: string}>} */
|
|
524
|
+
const findings = [];
|
|
525
|
+
/** @type {Record<string, {title: string, help: string}>} */
|
|
526
|
+
const categories = {};
|
|
527
|
+
// The finding scan reads the whole project (or --scan), not only the
|
|
528
|
+
// transformed sources: tests, e2e specs and scripts name 0.1 classes and
|
|
529
|
+
// stylesheet paths too. Files outside the sources are read, never written.
|
|
530
|
+
/** @type {Array<[string, string]>} */
|
|
531
|
+
const scanned = [];
|
|
532
|
+
const inScan = (/** @type {string} */ f) =>
|
|
533
|
+
scanRoots.some((r) => f === r || f.startsWith(r + sep));
|
|
534
|
+
for (const [file, entry] of state) {
|
|
535
|
+
if (entry.created || entry.project || !inScan(file)) continue;
|
|
536
|
+
scanned.push([file, entry.current]);
|
|
537
|
+
}
|
|
538
|
+
for (const file of collectProjectFiles(scanRoots, projectDir)) {
|
|
539
|
+
if (state.has(file)) continue;
|
|
540
|
+
const text = ctx.source(file);
|
|
541
|
+
if (text != null) scanned.push([file, text]);
|
|
542
|
+
}
|
|
543
|
+
scanned.sort((a, b) => a[0].localeCompare(b[0]));
|
|
544
|
+
for (const { step } of steps) {
|
|
545
|
+
Object.assign(categories, step.categories ?? {});
|
|
546
|
+
if (step.findings) findings.push(...step.findings(ctx, rel));
|
|
547
|
+
if (!step.scan) continue;
|
|
548
|
+
const context = step.prepareScan?.(
|
|
549
|
+
scanned.map(([file, text]) => [rel(file), text]),
|
|
550
|
+
);
|
|
551
|
+
for (const [file, text] of scanned)
|
|
552
|
+
findings.push(...step.scan(rel(file), text, context));
|
|
553
|
+
}
|
|
554
|
+
/** @type {Array<{file: string, line: number, text: string}>} */
|
|
555
|
+
const todos = [];
|
|
556
|
+
let tokenReads = 0;
|
|
557
|
+
for (const [file, entry] of state) {
|
|
558
|
+
entry.current.split("\n").forEach((line, i) => {
|
|
559
|
+
if (line.includes(TODO_TAG)) {
|
|
560
|
+
todos.push({
|
|
561
|
+
file: rel(file),
|
|
562
|
+
line: i + 1,
|
|
563
|
+
text: line
|
|
564
|
+
.replace(/^.*TODO\(ui-common-upgrade\):\s*/, "")
|
|
565
|
+
.replace(/\s*\*\/\s*\}?\s*$/, "")
|
|
566
|
+
.trim(),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
tokenReads += (entry.current.match(/var\(\s*--token-/g) ?? []).length;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const changed = [...state.entries()]
|
|
574
|
+
.filter(([, e]) => e.current !== e.original)
|
|
575
|
+
.map(([file, e]) => ({
|
|
576
|
+
file: rel(file),
|
|
577
|
+
abs: file,
|
|
578
|
+
...e,
|
|
579
|
+
stat: diffStat(e.original, e.current),
|
|
580
|
+
}));
|
|
581
|
+
const pkgChanged = pkgText != null && pkgAfter != null && pkgAfter !== pkgText;
|
|
582
|
+
|
|
583
|
+
const dryRun = Boolean(options.dryRun);
|
|
584
|
+
if (!dryRun) {
|
|
585
|
+
for (const c of changed) {
|
|
586
|
+
// Only what this run read, or a file it claimed that is still absent.
|
|
587
|
+
const now = existsSync(c.abs) ? readFileSync(c.abs, "utf8") : null;
|
|
588
|
+
if (c.created ? now != null : now !== c.original) {
|
|
589
|
+
errors.push({
|
|
590
|
+
file: c.file,
|
|
591
|
+
transform: c.transforms.join(", "),
|
|
592
|
+
error: c.created
|
|
593
|
+
? "appeared on disk during the run; left alone."
|
|
594
|
+
: "changed on disk during the run; left alone.",
|
|
595
|
+
});
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
mkdirSync(dirname(c.abs), { recursive: true });
|
|
599
|
+
writeFileSync(c.abs, c.current);
|
|
600
|
+
}
|
|
601
|
+
if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (resolver.unresolved.size > 0) {
|
|
605
|
+
const list = [...resolver.unresolved.entries()]
|
|
606
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
607
|
+
.map(([prefix, n]) => `\`${prefix}\` (${n})`);
|
|
608
|
+
notices.push(
|
|
609
|
+
`Imports through ${list.join(", ")} did not resolve: the upgrade reads relative imports and tsconfig \`paths\`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand.`,
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const report = renderReport({
|
|
614
|
+
from,
|
|
615
|
+
to,
|
|
616
|
+
version: own.version,
|
|
617
|
+
dryRun,
|
|
618
|
+
roots: roots.map((r) => relative(projectDir, r).split(sep).join("/") || "."),
|
|
619
|
+
fileCount: files.length,
|
|
620
|
+
scanRoots: scanRoots.map(
|
|
621
|
+
(r) => relative(projectDir, r).split(sep).join("/") || ".",
|
|
622
|
+
),
|
|
623
|
+
scanCount: scanned.length,
|
|
624
|
+
steps: steps.map(({ version, step }) => ({
|
|
625
|
+
version,
|
|
626
|
+
title: step.title,
|
|
627
|
+
notes: typeof step.notes === "function" ? step.notes(ctx) : (step.notes ?? []),
|
|
628
|
+
})),
|
|
629
|
+
changed: changed.map((c) => ({
|
|
630
|
+
file: c.file,
|
|
631
|
+
created: c.created,
|
|
632
|
+
transforms: c.transforms,
|
|
633
|
+
...c.stat,
|
|
634
|
+
})),
|
|
635
|
+
packageJson: { changed: pkgChanged, notes: packageNotes },
|
|
636
|
+
todos,
|
|
637
|
+
findings,
|
|
638
|
+
categories,
|
|
639
|
+
errors,
|
|
640
|
+
notices,
|
|
641
|
+
alerts,
|
|
642
|
+
tokenReads,
|
|
643
|
+
});
|
|
644
|
+
if (writeReport) {
|
|
645
|
+
mkdirSync(dirname(reportFile), { recursive: true });
|
|
646
|
+
writeFileSync(reportFile, report);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Terminal summary.
|
|
650
|
+
const verb = dryRun ? "Would change" : "Changed";
|
|
651
|
+
for (const c of changed) {
|
|
652
|
+
log(` ${c.created ? "+" : "~"} ${c.file} (+${c.stat.added} -${c.stat.removed})`);
|
|
653
|
+
}
|
|
654
|
+
if (pkgChanged) log(` ~ ${rel(pkgFile)}`);
|
|
655
|
+
log(
|
|
656
|
+
`${verb} ${changed.length} file${changed.length === 1 ? "" : "s"}${pkgChanged ? " and package.json" : ""}; ` +
|
|
657
|
+
`${todos.length} TODO marker${todos.length === 1 ? "" : "s"}, ${findings.length} manual-review item${findings.length === 1 ? "" : "s"}.`,
|
|
658
|
+
);
|
|
659
|
+
if (dryRun && options.diff) {
|
|
660
|
+
for (const c of changed) log(unifiedDiff(c.file, c.original, c.current));
|
|
661
|
+
if (pkgChanged && pkgText != null && pkgAfter != null)
|
|
662
|
+
log(unifiedDiff("package.json", pkgText, pkgAfter));
|
|
663
|
+
}
|
|
664
|
+
for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`);
|
|
665
|
+
for (const alert of alerts) warn(` ACTION REQUIRED: ${alert}`);
|
|
666
|
+
for (const notice of notices) warn(` note: ${notice}`);
|
|
667
|
+
if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`);
|
|
668
|
+
else log(`\n${report}`);
|
|
669
|
+
if (dryRun)
|
|
670
|
+
log(
|
|
671
|
+
`Dry run: nothing was written${writeReport ? " but the report" : ""}. Run without --dry-run to apply${writeReport ? "" : ", or pass --report <path> to keep the report"}.`,
|
|
672
|
+
);
|
|
673
|
+
if (!dryRun && pkgChanged)
|
|
674
|
+
log("package.json changed: run your package manager's install.");
|
|
675
|
+
|
|
676
|
+
return {
|
|
677
|
+
code: errors.length > 0 ? 1 : 0,
|
|
678
|
+
changed,
|
|
679
|
+
todos,
|
|
680
|
+
findings,
|
|
681
|
+
errors,
|
|
682
|
+
report,
|
|
683
|
+
pkgAfter,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export const UPGRADE_HELP = `Usage: ui-common upgrade [--from <version>] [--to <version>] [--dry-run] [--diff] [--report <path>] [--scan <path>]… [paths…]
|
|
688
|
+
|
|
689
|
+
Run the codemods registered between two @lablup/ui-common versions over your
|
|
690
|
+
source (TS/TSX/JS/JSX through jscodeshift, CSS through postcss), update
|
|
691
|
+
package.json, and write a manual-review report. From 0.1, package.json also
|
|
692
|
+
gains @lablup/ui-common-cli (this bin) as a devDependency at the new version.
|
|
693
|
+
|
|
694
|
+
One-off, from a project still on 0.1 (\`@next\` until 0.2.0 is published):
|
|
695
|
+
pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run
|
|
696
|
+
(npx @lablup/ui-common-cli@next upgrade --from 0.1 --dry-run)
|
|
697
|
+
|
|
698
|
+
--from <version> The ui-common version the code is written against.
|
|
699
|
+
Default: the version package.json declares.
|
|
700
|
+
--to <version> Default: this CLI's version, which is the @lablup/ui-common
|
|
701
|
+
version it ships with.
|
|
702
|
+
--dry-run Write nothing; list what would change and print the report.
|
|
703
|
+
--diff With --dry-run, also print unified diffs.
|
|
704
|
+
--report <path> Where to write the report. Default: ui-common-upgrade-report.md,
|
|
705
|
+
except in a dry run, which writes a report only to a path
|
|
706
|
+
given here. An existing file is replaced only if it is an
|
|
707
|
+
earlier report.
|
|
708
|
+
--scan <path> Where the report looks for 0.1 class names, stylesheet
|
|
709
|
+
paths, test queries and module mocks. Default: the whole
|
|
710
|
+
project (tests, e2e, scripts, …), without node_modules,
|
|
711
|
+
build output, git-ignored files and nested packages.
|
|
712
|
+
Repeat to narrow it: --scan src --scan e2e.
|
|
713
|
+
paths… Directories or files to transform. Default: src/
|
|
714
|
+
|
|
715
|
+
Exit codes: 0 done, 1 some files could not be transformed (see the report),
|
|
716
|
+
2 bad arguments or nothing to scan.
|
|
717
|
+
`;
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* @param {string[]} argv arguments after `upgrade`
|
|
721
|
+
*/
|
|
722
|
+
export async function upgradeCommand(argv) {
|
|
723
|
+
/** @type {UpgradeOptions} */
|
|
724
|
+
const options = { cwd: process.cwd(), paths: [] };
|
|
725
|
+
for (let i = 0; i < argv.length; i++) {
|
|
726
|
+
const arg = argv[i];
|
|
727
|
+
const [flag, inline] =
|
|
728
|
+
arg.startsWith("--") && arg.includes("=")
|
|
729
|
+
? arg.split(/=(.*)/s)
|
|
730
|
+
: [arg, undefined];
|
|
731
|
+
const value = () => inline ?? argv[++i];
|
|
732
|
+
switch (flag) {
|
|
733
|
+
case "--from":
|
|
734
|
+
options.from = value();
|
|
735
|
+
break;
|
|
736
|
+
case "--to":
|
|
737
|
+
options.to = value();
|
|
738
|
+
break;
|
|
739
|
+
case "--report":
|
|
740
|
+
options.report = value();
|
|
741
|
+
break;
|
|
742
|
+
case "--scan": {
|
|
743
|
+
const v = value();
|
|
744
|
+
if (v) (options.scan ??= []).push(v);
|
|
745
|
+
break;
|
|
746
|
+
}
|
|
747
|
+
case "--dry-run":
|
|
748
|
+
options.dryRun = true;
|
|
749
|
+
break;
|
|
750
|
+
case "--diff":
|
|
751
|
+
options.diff = true;
|
|
752
|
+
break;
|
|
753
|
+
case "-h":
|
|
754
|
+
case "--help":
|
|
755
|
+
process.stdout.write(UPGRADE_HELP);
|
|
756
|
+
return 0;
|
|
757
|
+
default:
|
|
758
|
+
if (arg.startsWith("-")) {
|
|
759
|
+
process.stderr.write(
|
|
760
|
+
`ui-common upgrade: unknown option "${arg}"\n${UPGRADE_HELP}`,
|
|
761
|
+
);
|
|
762
|
+
return 2;
|
|
763
|
+
}
|
|
764
|
+
options.paths.push(arg);
|
|
765
|
+
}
|
|
766
|
+
if (["--from", "--to", "--report", "--scan"].includes(flag)) {
|
|
767
|
+
const v = /** @type {any} */ (options)[flag.slice(2)];
|
|
768
|
+
if (!v) {
|
|
769
|
+
process.stderr.write(`ui-common upgrade: ${flag} needs a value\n`);
|
|
770
|
+
return 2;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const result = await runUpgrade(options);
|
|
775
|
+
return result.code;
|
|
776
|
+
}
|