From 490ddf34fd5d6f830f2ebecb3fd8dc51f0de059c Mon Sep 17 00:00:00 2001 From: Jongeun Date: Fri, 2 Oct 2026 09:48:55 +0000 Subject: [PATCH 1/2] feat(cli): add adopt --from astryx, doctor and the ui-common-adopt agent skill ui-common adopt --from astryx moves an app that imports @astryxdesign/core, lab or theme-neutral onto @lablup/ui-common in one pass: it rewrites module specifiers in scripts and stylesheets, renames Dialog and AlertDialog to Modal and AlertModal, adds the ui-common layer to the cascade order, edits package.json in every workspace member and writes a manual-review report in the upgrade report's format. --check exits 1 while anything is left, for CI; upgrade --from astryx is an alias. ui-common doctor runs read-only checks of the wiring the first adopters learned the hard way (one Astryx core, lab on it, no direct imports, the layer order, the Vite pre-bundle fix, Vitest inlining, i18n, the agent block, matching versions, Node), with a fix and a doc section per failure. doctor with a subcommand still reaches Astryx's doctor. agents --skill installs the ui-common-adopt skill shipped in the CLI package, and the agent block names it once installed. --- .prettierignore | 2 +- eslint.config.js | 4 +- packages/cli/README.md | 5 +- packages/cli/cli/adopt.mjs | 771 ++++++++++++ packages/cli/cli/agents.mjs | 98 +- packages/cli/cli/doctor.mjs | 1045 +++++++++++++++++ packages/cli/cli/main.mjs | 22 +- packages/cli/cli/project.mjs | 454 +++++++ packages/cli/cli/report.mjs | 16 +- packages/cli/cli/upgrade.mjs | 15 + packages/cli/codemods/0.2/package-json.mjs | 4 +- packages/cli/codemods/0.2/stylesheets.mjs | 2 +- packages/cli/codemods/adopt/dialog.mjs | 316 +++++ packages/cli/codemods/adopt/layers.mjs | 129 ++ packages/cli/codemods/adopt/package-json.mjs | 208 ++++ packages/cli/codemods/adopt/scan.mjs | 310 +++++ packages/cli/codemods/adopt/specifiers.mjs | 237 ++++ packages/cli/package.json | 3 +- packages/cli/skill/ui-common-adopt/SKILL.md | 122 ++ packages/cli/test/adopt/adopt.test.ts | 427 +++++++ .../app/expected/.github/workflows/ci.yml | 10 + .../app/expected/.storybook/preview.ts | 4 + .../adopt/fixtures/app/expected/AGENTS.md | 8 + .../adopt/fixtures/app/expected/index.html | 13 + .../adopt/fixtures/app/expected/package.json | 27 + .../patches/@astryxdesign__core@0.6.2.patch | 8 + .../fixtures/app/expected/pnpm-workspace.yaml | 8 + .../app/expected/scripts/append-rules.mjs | 6 + .../fixtures/app/expected/src/App.test.tsx | 16 + .../adopt/fixtures/app/expected/src/App.tsx | 39 + .../app/expected/src/ConfirmDelete.tsx | 21 + .../fixtures/app/expected/src/augment.ts | 8 + .../adopt/fixtures/app/expected/src/index.css | 13 + .../fixtures/app/expected/src/legacy.cjs | 4 + .../adopt/fixtures/app/expected/src/main.tsx | 20 + .../fixtures/app/expected/src/panel.scss | 9 + .../adopt/fixtures/app/expected/src/tokens.ts | 16 + .../app/expected/ui-common-adopt-report.md | 141 +++ .../fixtures/app/expected/vite.config.ts | 10 + .../app/input/.github/workflows/ci.yml | 10 + .../fixtures/app/input/.storybook/preview.ts | 4 + .../test/adopt/fixtures/app/input/AGENTS.md | 8 + .../test/adopt/fixtures/app/input/index.html | 12 + .../adopt/fixtures/app/input/package.json | 26 + .../patches/@astryxdesign__core@0.6.2.patch | 8 + .../fixtures/app/input/pnpm-workspace.yaml | 4 + .../app/input/scripts/append-rules.mjs | 6 + .../adopt/fixtures/app/input/src/App.test.tsx | 16 + .../test/adopt/fixtures/app/input/src/App.tsx | 39 + .../fixtures/app/input/src/ConfirmDelete.tsx | 21 + .../adopt/fixtures/app/input/src/augment.ts | 8 + .../adopt/fixtures/app/input/src/index.css | 13 + .../adopt/fixtures/app/input/src/legacy.cjs | 4 + .../adopt/fixtures/app/input/src/main.tsx | 20 + .../adopt/fixtures/app/input/src/panel.scss | 7 + .../adopt/fixtures/app/input/src/tokens.ts | 16 + .../adopt/fixtures/app/input/vite.config.ts | 10 + .../npm-lab/expected/package-lock.json | 6 + .../fixtures/npm-lab/expected/package.json | 19 + .../fixtures/npm-lab/expected/src/Filters.jsx | 14 + .../fixtures/npm-lab/expected/src/styles.css | 5 + .../expected/ui-common-adopt-report.md | 84 ++ .../fixtures/npm-lab/input/package-lock.json | 6 + .../adopt/fixtures/npm-lab/input/package.json | 10 + .../fixtures/npm-lab/input/src/Filters.jsx | 14 + .../fixtures/npm-lab/input/src/styles.css | 4 + .../fixtures/workspace/expected/package.json | 7 + .../expected/packages/kit/package.json | 19 + .../expected/packages/kit/src/SidePanel.tsx | 13 + .../expected/packages/kit/src/index.ts | 3 + .../expected/packages/kit/src/kit.css | 5 + .../expected/packages/web/index.html | 14 + .../expected/packages/web/package.json | 16 + .../expected/packages/web/src/entry.css | 5 + .../expected/packages/web/src/main.tsx | 13 + .../workspace/expected/pnpm-workspace.yaml | 14 + .../expected/tools/legacy-report/index.ts | 2 + .../expected/tools/legacy-report/package.json | 7 + .../expected/ui-common-adopt-report.md | 93 ++ .../fixtures/workspace/input/package.json | 7 + .../workspace/input/packages/kit/package.json | 15 + .../input/packages/kit/src/SidePanel.tsx | 13 + .../workspace/input/packages/kit/src/index.ts | 3 + .../workspace/input/packages/kit/src/kit.css | 5 + .../workspace/input/packages/web/index.html | 14 + .../workspace/input/packages/web/package.json | 12 + .../input/packages/web/src/entry.css | 3 + .../workspace/input/packages/web/src/main.tsx | 13 + .../workspace/input/pnpm-workspace.yaml | 9 + .../input/tools/legacy-report/index.ts | 2 + .../input/tools/legacy-report/package.json | 7 + packages/cli/test/cli/cli.test.ts | 28 + packages/cli/test/doctor/doctor.test.ts | 413 +++++++ scripts/check-pack.mjs | 3 +- vite.config.ts | 4 +- 95 files changed, 5705 insertions(+), 22 deletions(-) create mode 100644 packages/cli/cli/adopt.mjs create mode 100644 packages/cli/cli/doctor.mjs create mode 100644 packages/cli/cli/project.mjs create mode 100644 packages/cli/codemods/adopt/dialog.mjs create mode 100644 packages/cli/codemods/adopt/layers.mjs create mode 100644 packages/cli/codemods/adopt/package-json.mjs create mode 100644 packages/cli/codemods/adopt/scan.mjs create mode 100644 packages/cli/codemods/adopt/specifiers.mjs create mode 100644 packages/cli/skill/ui-common-adopt/SKILL.md create mode 100644 packages/cli/test/adopt/adopt.test.ts create mode 100644 packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml create mode 100644 packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts create mode 100644 packages/cli/test/adopt/fixtures/app/expected/AGENTS.md create mode 100644 packages/cli/test/adopt/fixtures/app/expected/index.html create mode 100644 packages/cli/test/adopt/fixtures/app/expected/package.json create mode 100644 packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch create mode 100644 packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml create mode 100644 packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/App.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/augment.ts create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/index.css create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/main.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/panel.scss create mode 100644 packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts create mode 100644 packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md create mode 100644 packages/cli/test/adopt/fixtures/app/expected/vite.config.ts create mode 100644 packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml create mode 100644 packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts create mode 100644 packages/cli/test/adopt/fixtures/app/input/AGENTS.md create mode 100644 packages/cli/test/adopt/fixtures/app/input/index.html create mode 100644 packages/cli/test/adopt/fixtures/app/input/package.json create mode 100644 packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch create mode 100644 packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml create mode 100644 packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/App.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/augment.ts create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/index.css create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/main.tsx create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/panel.scss create mode 100644 packages/cli/test/adopt/fixtures/app/input/src/tokens.ts create mode 100644 packages/cli/test/adopt/fixtures/app/input/vite.config.ts create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/expected/package.json create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/expected/src/styles.css create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/expected/ui-common-adopt-report.md create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/input/package-lock.json create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/input/package.json create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/input/src/Filters.jsx create mode 100644 packages/cli/test/adopt/fixtures/npm-lab/input/src/styles.css create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/kit/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/kit/src/SidePanel.tsx create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/kit/src/index.ts create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/kit/src/kit.css create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/web/index.html create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/web/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/web/src/entry.css create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/packages/web/src/main.tsx create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/pnpm-workspace.yaml create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/tools/legacy-report/index.ts create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/tools/legacy-report/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/expected/ui-common-adopt-report.md create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/kit/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/kit/src/SidePanel.tsx create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/kit/src/index.ts create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/kit/src/kit.css create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/web/index.html create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/web/package.json create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/web/src/entry.css create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/packages/web/src/main.tsx create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/pnpm-workspace.yaml create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/tools/legacy-report/index.ts create mode 100644 packages/cli/test/adopt/fixtures/workspace/input/tools/legacy-report/package.json create mode 100644 packages/cli/test/doctor/doctor.test.ts diff --git a/.prettierignore b/.prettierignore index 85289f4..cb0958f 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,5 +3,5 @@ coverage/ pnpm-lock.yaml LICENSE src/theme/*/built/ -packages/cli/test/upgrade/fixtures/ +packages/cli/test/*/fixtures/ .claude/ diff --git a/eslint.config.js b/eslint.config.js index d32c0a5..4dc7163 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,8 +12,8 @@ export default tseslint.config( "fixture/dist", // `astryx theme build` output, committed as generated. "src/theme/*/built/**", - // Consumer code the upgrade codemods run on, and their expected output. - "packages/cli/test/upgrade/fixtures/**", + // Consumer code the upgrade and adopt codemods run on, and their expected output. + "packages/cli/test/*/fixtures/**", // Agent worktrees: other checkouts of this repository, with their own dist. ".claude/**", ], diff --git a/packages/cli/README.md b/packages/cli/README.md index 990efb4..e1b0432 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -2,7 +2,9 @@ The `ui-common` command line for [`@lablup/ui-common`](https://www.npmjs.com/package/@lablup/ui-common): the Astryx CLI that ui-common pins, with its output in `@lablup/ui-common` -terms, the agent block, and the upgrade codemods. +terms, the agent block, the upgrade codemods, `adopt --from astryx` for an +app that uses Astryx directly, `doctor`, and the `ui-common-adopt` agent +skill. It is a separate package so the CLI's toolchain (the Astryx CLI, jscodeshift, postcss) stays out of a consumer's production install. It is released at the @@ -10,6 +12,7 @@ same version as `@lablup/ui-common` and takes it as a peer. ``` pnpm dlx @lablup/ui-common-cli@next upgrade --from 0.1 # one-off (npx @lablup/ui-common-cli@next …) +pnpm dlx @lablup/ui-common-cli@next adopt --from astryx --dry-run # an app on @astryxdesign/* pnpm add -D @lablup/ui-common-cli@ # or keep it next to ui-common pnpm exec ui-common --help diff --git a/packages/cli/cli/adopt.mjs b/packages/cli/cli/adopt.mjs new file mode 100644 index 0000000..f8815fa --- /dev/null +++ b/packages/cli/cli/adopt.mjs @@ -0,0 +1,771 @@ +/** + * `ui-common adopt --from astryx`: move an app that imports Astryx directly + * (`@astryxdesign/*`) onto `@lablup/ui-common`, in one pass. + * + * - Every `@astryxdesign/core[/X]`, `@astryxdesign/lab` and + * `@astryxdesign/theme-neutral` module specifier becomes its ui-common + * mirror, in scripts and stylesheets (../codemods/adopt/specifiers.mjs). + * - `Dialog` / `AlertDialog` become `Modal` / `AlertModal` + * (../codemods/adopt/dialog.mjs). + * - Each Astryx layer-order statement gains `ui-common`; an entry stylesheet + * and an `index.html` that declare none get one (../codemods/adopt/layers.mjs). + * - Each package that used Astryx gets ui-common in its package.json + * (../codemods/adopt/package-json.mjs); a pnpm project declines the Astryx + * postinstalls in `allowBuilds`, and `/lab` brings the lab override. + * - The report lists what needs a person (../codemods/adopt/scan.mjs). + * + * The edits are made in memory first: `--dry-run` writes nothing, `--check` + * writes nothing and exits 1 when anything is left to do. A second run + * changes nothing. In a workspace, every member package is covered. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, dirname, extname, join, relative, resolve, sep } from "node:path"; + +import { + applyAllowBuilds, + DECLINED_BUILDS, + addLabOverride, +} from "../codemods/0.2/package-json.mjs"; +import { DIALOG_NAMES, transformDialogs } from "../codemods/adopt/dialog.mjs"; +import { + addUiCommonLayer, + ensureHtmlLayerStatement, + ensureLayerStatement, + LAYER_ORDER, + layerStatements, +} from "../codemods/adopt/layers.mjs"; +import { adoptPackageJson, CLI_PACKAGE } from "../codemods/adopt/package-json.mjs"; +import { CATEGORIES, scanFile, scanPatches } from "../codemods/adopt/scan.mjs"; +import { isTestFile } from "../codemods/0.2/scan.mjs"; +import { + findSpecifiers, + importedNamesAt, + mapSpecifier, + rewriteSpecifiers, + UIC, +} from "../codemods/adopt/specifiers.mjs"; +import { detectPackageManager } from "./lab-peer.mjs"; +import { diffStat, unifiedDiff } from "./diff.mjs"; +import { + cliPackageJson, + findProjectDir, + targetUiCommonRoot, + uiCommonPackageJson, +} from "./paths.mjs"; +import { + declarations, + installRoot, + isGenerated, + listProjectFiles, + ownerRoot, + packageRoots, + pnpmCatalogs, + readText, + relPath, + SCRIPT_EXTENSIONS, + STYLE_EXTENSIONS, +} from "./project.mjs"; +import { ADOPT_REPORT_HEADING, renderReport } from "./report.mjs"; + +export const DEFAULT_ADOPT_REPORT = "ui-common-adopt-report.md"; + +/** Root-barrel names ui-common does not export (the hidden Dialog family). */ +const HIDDEN_ROOT_NAMES = new Set([ + "Dialog", + "DialogProps", + "AlertDialog", + "AlertDialogProps", + "DialogVariantMap", + "useImperativeDialog", + "ImperativeDialogReturn", + "useImperativeAlertDialog", + "ImperativeAlertDialogReturn", +]); + +const MIRRORED = /@astryxdesign\/(?:core|lab|theme-neutral)\b/; + +/** + * @typedef {object} AdoptOptions + * @property {string} cwd + * @property {string[]} [paths] where to rewrite (relative to cwd); default: the whole project + * @property {string[]} [ignore] project-relative paths (or `*` globs) to leave alone + * @property {boolean} [dryRun] + * @property {boolean} [check] write nothing; exit 1 when anything is left + * @property {boolean} [diff] + * @property {string} [report] + * @property {string} [version] the ui-common version to adopt (default: this CLI's) + * @property {(line: string) => void} [log] + * @property {(line: string) => void} [warn] + */ + +/** @param {string} pattern project-relative path or glob */ +function ignoreMatcher(pattern) { + const p = pattern.replace(/^\.\//, "").replace(/\/+$/, ""); + if (!/[*?]/.test(p)) + return (/** @type {string} */ rel) => rel === p || rel.startsWith(`${p}/`); + let re = ""; + for (let i = 0; i < p.length; i++) { + const c = p[i]; + if (c === "*" && p[i + 1] === "*") { + re += ".*"; + i++; + } else if (c === "*") re += "[^/]*"; + else if (c === "?") re += "[^/]"; + else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); + } + const rx = new RegExp(`^${re}(/.*)?$`); + return (/** @type {string} */ rel) => rx.test(rel); +} + +/** + * @param {AdoptOptions} options + */ +export async function runAdopt(options) { + const log = options.log ?? ((line) => process.stdout.write(`${line}\n`)); + const warn = options.warn ?? ((line) => process.stderr.write(`${line}\n`)); + const cwd = resolve(options.cwd); + const projectDir = findProjectDir(cwd) ?? cwd; + const roots = packageRoots(projectDir); + const install = installRoot(projectDir); + const own = cliPackageJson(); + // The ui-common version adopted is the CLI's own (they are released in + // lockstep); `version` overrides it for the tests' fixtures. + const version = options.version ?? own.version; + const target = uiCommonPackageJson(targetUiCommonRoot(projectDir)); + const exportsMap = /** @type {Record} */ (target.exports ?? {}); + const corePin = target.dependencies?.["@astryxdesign/core"] ?? ""; + const labPin = target.peerDependencies?.["@astryxdesign/lab"] ?? ""; + const stylexRange = target.peerDependencies?.["@stylexjs/stylex"] ?? "^0.19.0"; + const check = Boolean(options.check); + const dryRun = Boolean(options.dryRun) || check; + const rel = (/** @type {string} */ f) => relPath(projectDir, f); + + const reportFile = resolve(cwd, options.report ?? DEFAULT_ADOPT_REPORT); + const writeReport = !dryRun || (options.report != null && !check); + if ( + writeReport && + existsSync(reportFile) && + !readFileSync(reportFile, "utf8").startsWith(ADOPT_REPORT_HEADING) + ) { + warn( + `ui-common adopt: ${relative(cwd, reportFile)} exists and is not an adopt report; not overwriting it. Pass --report .`, + ); + return { code: 2 }; + } + + const scopeRoots = (options.paths ?? []).map((p) => resolve(cwd, p)); + const missing = scopeRoots.filter((p) => !existsSync(p)); + if (scopeRoots.length > 0 && missing.length === scopeRoots.length) { + warn( + `ui-common adopt: nothing to adopt: ${missing.map((p) => relative(cwd, p) || ".").join(", ")} not found.`, + ); + return { code: 2 }; + } + const ignores = (options.ignore ?? []).map(ignoreMatcher); + const inScope = (/** @type {string} */ f) => + (scopeRoots.length === 0 || + scopeRoots.some((r) => f === r || f.startsWith(r.endsWith(sep) ? r : r + sep))) && + !ignores.some((m) => m(rel(f))); + + const allFiles = listProjectFiles(projectDir, roots).filter((f) => f !== reportFile); + const { default: jscodeshift } = await import("jscodeshift"); + + /** @type {Map} */ + const state = new Map(); + /** @type {Array<{file: string, transform: string, error: string}>} */ + const errors = []; + /** @type {Array<{category: string, file: string, line: number, text: string, detail?: string}>} */ + const findings = []; + /** @type {string[]} */ + const notes = []; + /** @type {string[]} */ + const notices = []; + /** @type {string[]} */ + const alerts = []; + /** @type {Record} */ + const rows = {}; + /** @type {string[]} */ + const generated = []; + /** Package roots whose modules imported Astryx before the run. */ + /** @type {Set} */ + const usingRoots = new Set(); + + const touch = ( + /** @type {string} */ file, + /** @type {string} */ next, + /** @type {string} */ transform, + ) => { + const entry = state.get(file); + if (!entry || next === entry.current) return; + entry.current = next; + if (!entry.transforms.includes(transform)) entry.transforms.push(transform); + }; + + // 1. Module specifiers and Dialog renames, in scripts and stylesheets. + for (const file of allFiles) { + if (!inScope(file)) continue; + const ext = extname(file); + const script = SCRIPT_EXTENSIONS.has(ext); + const style = STYLE_EXTENSIONS.has(ext); + if (!script && !style) continue; + const source = readText(file); + if (source == null) continue; + if (!MIRRORED.test(source) && !(style && /@layer\b/.test(source))) continue; + if (isGenerated(source)) { + if ( + findSpecifiers(source, script ? "script" : "style").some((s) => + mapSpecifier(s.spec, exportsMap), + ) + ) + generated.push(rel(file)); + continue; + } + state.set(file, { + original: source, + current: source, + transforms: [], + created: false, + }); + if ( + findSpecifiers(source, script ? "script" : "style").some((s) => + mapSpecifier(s.spec, exportsMap), + ) + ) + usingRoots.add(ownerRoot(file, roots) ?? projectDir); + + if (script && DIALOG_NAMES.test(source)) { + const j = jscodeshift.withParser(/\.[cm]?tsx?$/.test(ext) ? "tsx" : "babel"); + try { + const result = transformDialogs({ source, path: file }, j); + if (result.text != null) touch(file, result.text, "dialog-to-modal"); + for (const f of result.findings) findings.push({ ...f, file: rel(file) }); + } catch (err) { + errors.push({ + file: rel(file), + transform: "dialog-to-modal", + error: `could not be parsed, so Dialog imports were not renamed: ${/** @type {Error} */ (err).message}`, + }); + } + } + const entry = /** @type {NonNullable>} */ ( + state.get(file) + ); + const text = entry.current; + const rewritten = rewriteSpecifiers( + text, + script ? "script" : "style", + exportsMap, + (spec, at) => { + if (spec !== "@astryxdesign/core") return null; + const names = importedNamesAt(text, at); + const hidden = names?.filter((n) => HIDDEN_ROOT_NAMES.has(n)) ?? []; + return hidden.length > 0 + ? `${UIC}'s root does not export ${hidden.join(", ")}: rename to Modal / AlertModal (${UIC}/Modal, ${UIC}/AlertModal), then run adopt again.` + : null; + }, + ); + touch(file, rewritten.text, "specifiers"); + for (const [row, n] of Object.entries(rewritten.rows)) + rows[row] = (rows[row] ?? 0) + n; + for (const l of rewritten.left) { + findings.push({ + category: "unmirrored", + file: rel(file), + line: l.line, + text: l.text, + detail: l.reason, + }); + } + if (style) { + touch(file, addUiCommonLayer(entry.current).text, "layer-order"); + const ensured = ensureLayerStatement(entry.current, file); + if (ensured != null) touch(file, ensured, "layer-order"); + } + } + + // Roots that declare Astryx count as using it, whatever their modules say. + for (const r of roots) { + const pkg = readJsonSafe(join(r, "package.json")); + if ( + ["@astryxdesign/core", "@astryxdesign/lab", "@astryxdesign/theme-neutral"].some( + (n) => declarations(pkg, n).length > 0, + ) + ) + usingRoots.add(r); + } + + // 2. index.html (and Storybook's head) copies of the layer order. One page + // of the project that declares the order is enough: a second index.html is + // usually a build-entry stub whose content a plugin replaces. + const htmlDeclares = allFiles.some( + (f) => + basename(f) === "index.html" && + layerStatements(readText(f) ?? "").some((s) => + s.names.some((n) => n.startsWith("astryx-")), + ), + ); + for (const r of roots) { + if (!usingRoots.has(r)) continue; + const statements = new Set(); + for (const [file, entry] of state) { + if (ownerRoot(file, roots) !== r || !STYLE_EXTENSIONS.has(extname(file))) + continue; + for (const s of layerStatements(entry.current)) + if (s.names.includes("ui-common")) + statements.add(`@layer ${s.names.join(", ")};`); + } + const statement = statements.size === 1 ? [...statements][0] : LAYER_ORDER; + for (const html of [ + join(r, "index.html"), + join(r, ".storybook", "preview-head.html"), + ]) { + if (!existsSync(html) || !inScope(html)) continue; + const source = readFileSync(html, "utf8"); + state.set(html, { + original: source, + current: source, + transforms: [], + created: false, + }); + touch(html, addUiCommonLayer(source).text, "layer-order"); + if (basename(html) === "index.html" && !htmlDeclares) { + const ensured = ensureHtmlLayerStatement( + state.get(html)?.current ?? source, + statement, + ); + if (ensured != null) touch(html, ensured, "layer-order"); + } + } + } + + // 3. package.json, per package that used Astryx; pnpm-workspace.yaml once. + const { manager, workspaceYaml } = detectPackageManager( + projectDir, + readJsonSafe(join(projectDir, "package.json")), + ); + const yamlText = workspaceYaml ? readText(workspaceYaml) : null; + const catalogs = pnpmCatalogs(yamlText); + const usingList = roots.filter((r) => usingRoots.has(r)); + const declaresCli = roots.some( + (r) => declarations(readJsonSafe(join(r, "package.json")), CLI_PACKAGE).length > 0, + ); + // The bin goes next to ui-common in an application, else the first package. + const cliHome = + usingList.find((r) => { + const pkg = readJsonSafe(join(r, "package.json")); + return ![ + "@astryxdesign/core", + "@astryxdesign/lab", + "@astryxdesign/theme-neutral", + UIC, + ].some((n) => pkg?.peerDependencies?.[n] != null); + }) ?? usingList[0]; + /** @type {Array<{file: string, before: string, after: string}>} */ + const pkgEdits = []; + let anyLab = false; + const editFile = ( + /** @type {string} */ path, + /** @type {(current: string | null) => string | undefined} */ edit, + id = "package-json", + ) => { + const known = state.get(path); + const current = known + ? known.current + : existsSync(path) + ? readFileSync(path, "utf8") + : null; + const next = edit(current); + if (next == null || next === current) return; + if (known) { + touch(path, next, id); + return; + } + state.set(path, { + original: current ?? "", + current: next, + transforms: [id], + created: current == null, + }); + }; + for (const r of usingList) { + const pkgFile = join(r, "package.json"); + const text = readText(pkgFile); + if (text == null) continue; + // Every module of the package as the run leaves it, not only the rewritten ones. + const owned = allFiles + .filter( + (f) => + ownerRoot(f, roots) === r && + (SCRIPT_EXTENSIONS.has(extname(f)) || STYLE_EXTENSIONS.has(extname(f))), + ) + .map((f) => state.get(f)?.current ?? readText(f) ?? ""); + const usesLab = owned.some((t) => /['"]@lablup\/ui-common\/lab\b/.test(t)); + if (usesLab) anyLab = true; + const keepsLab = owned.some((t) => /['"]@astryxdesign\/lab\b/.test(t)); + const keepsNeutral = owned.some((t) => + /['"]@astryxdesign\/theme-neutral\b/.test(t), + ); + const result = adoptPackageJson(text, { + rel: rel(r), + version, + corePin, + labPin, + stylexRange, + astryxCliPin: own.dependencies?.["@astryxdesign/cli"], + usesLab, + keepsLab, + keepsNeutral, + addCli: !declaresCli && r === cliHome, + catalogs, + note: (m) => notes.push(m), + alert: (m) => alerts.push(m), + }); + let after = result.text ?? text; + if (usesLab && (result.labAdded || !result.declaresCore)) { + const pkg = JSON.parse(after); + addLabOverride(pkg, { + projectDir: r, + note: (/** @type {string} */ m) => m && notes.push(m), + editFile, + }); + const indent = /^([ \t]+)"/m.exec(after)?.[1] ?? " "; + after = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; + } + if (after !== text) pkgEdits.push({ file: pkgFile, before: text, after }); + } + if (usingList.length > 0 && manager === "pnpm" && workspaceYaml) { + editFile(workspaceYaml, (current) => { + const edit = applyAllowBuilds(current, DECLINED_BUILDS); + for (const note of edit.notes) notes.push(note); + return edit.yaml; + }); + const yaml = readText(workspaceYaml) ?? ""; + if (/^minimumReleaseAge\s*:/m.test(yaml)) { + const wanted = [ + `${UIC}@${version}`, + `${CLI_PACKAGE}@${version}`, + ...(labPin && anyLab ? [`@astryxdesign/lab@${labPin}`] : []), + ]; + const missingExcludes = wanted.filter((w) => !yaml.includes(w)); + if (missingExcludes.length > 0) + notices.push( + `pnpm-workspace.yaml sets \`minimumReleaseAge\`: a ${UIC} release younger than that will not install. Until it ages out, list ${missingExcludes.map((w) => `\`${w}\``).join(", ")} under \`minimumReleaseAgeExclude\`${missingExcludes.some((w) => w.startsWith("@astryxdesign/lab@")) ? " (a lab canary never ages out)" : ""}.`, + ); + } + } + for (const e of pkgEdits) { + state.set(e.file, { + original: e.before, + current: e.after, + transforms: ["package-json"], + created: false, + }); + } + + // 4. Report-only findings, over the whole project as the run leaves it. + // Runtime findings (overlays, shortcuts, i18n) come from the packages that + // use Astryx, without their tests; tooling findings from everywhere. + const runtime = new Set(["portals", "hotkeys", "escape", "i18n"]); + for (const file of allFiles) { + const text = state.get(file)?.current ?? readText(file); + if (text == null || isGenerated(text)) continue; + const app = usingRoots.has(ownerRoot(file, roots) ?? "") && !isTestFile(rel(file)); + findings.push( + ...scanFile(rel(file), text).filter((f) => app || !runtime.has(f.category)), + ); + } + findings.push(...scanPatches(install, projectDir)); + // ui-common's catalog wired anywhere answers the i18n finding for the project. + const catalogWired = allFiles.some((f) => + /@lablup\/ui-common\/(?:i18n-catalog|ui-common-locales)/.test( + state.get(f)?.current ?? readText(f) ?? "", + ), + ); + findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line); + // One row per place and kind: details of the same line are joined. + for (let i = findings.length - 1; i >= 0; i--) { + const f = findings[i]; + if (catalogWired && f.category === "i18n") { + findings.splice(i, 1); + continue; + } + const twin = findings.findIndex( + (g, j) => + j < i && g.category === f.category && g.file === f.file && g.line === f.line, + ); + if (twin !== -1) { + const g = findings[twin]; + if (f.detail && f.detail !== g.detail) + g.detail = g.detail ? `${g.detail}; ${f.detail}` : f.detail; + findings.splice(i, 1); + } + } + if (generated.length > 0) { + notices.push( + `${generated.length} generated file${generated.length === 1 ? "" : "s"} (an \`@generated\` header) still name${generated.length === 1 ? "s" : ""} \`@astryxdesign/*\` and ${generated.length === 1 ? "was" : "were"} left as ${generated.length === 1 ? "it is" : "they are"}: ${generated + .slice(0, 5) + .map((g) => `\`${g}\``) + .join( + ", ", + )}${generated.length > 5 ? ", …" : ""}. Regenerate them after adopting; \`astryx theme build\` output keeps Astryx's own ids.`, + ); + } + + const changed = [...state.entries()] + .filter(([, e]) => e.current !== e.original) + .map(([file, e]) => ({ + file: rel(file), + abs: file, + ...e, + stat: diffStat(e.original, e.current), + })) + .sort((a, b) => a.file.localeCompare(b.file)); + const left = findings.filter((f) => f.category === "unmirrored"); + + if (!dryRun) { + for (const c of changed) { + const now = existsSync(c.abs) ? readFileSync(c.abs, "utf8") : null; + if (c.created ? now != null : now !== c.original) { + errors.push({ + file: c.file, + transform: c.transforms.join(", "), + error: c.created + ? "appeared on disk during the run; left alone." + : "changed on disk during the run; left alone.", + }); + continue; + } + mkdirSync(dirname(c.abs), { recursive: true }); + writeFileSync(c.abs, c.current); + } + } + + const rewrites = Object.entries(rows) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([row, count]) => ({ row, count })); + const total = rewrites.reduce((n, r) => n + r.count, 0); + const pkgChanged = changed.some((c) => basename(c.abs) === "package.json"); + const report = renderReport({ + heading: ADOPT_REPORT_HEADING, + command: "ui-common adopt", + from: "astryx", + to: `${UIC} ${version}`, + version, + dryRun, + roots: + scopeRoots.length > 0 ? scopeRoots.map((r) => rel(r)) : roots.map((r) => rel(r)), + fileCount: state.size, + scanRoots: ["."], + scanCount: allFiles.length, + steps: [ + { version, title: "Astryx (`@astryxdesign/*`) → @lablup/ui-common", notes: [] }, + ], + rewrites, + changed: changed.map((c) => ({ + file: c.file, + created: c.created, + transforms: c.transforms, + ...c.stat, + })), + packageJson: { changed: pkgChanged, notes }, + todos: [], + findings, + categories: CATEGORIES, + errors, + notices, + alerts, + tokenReads: 0, + }); + if (writeReport) { + mkdirSync(dirname(reportFile), { recursive: true }); + writeFileSync(reportFile, report); + } + + const verb = dryRun ? "Would change" : "Changed"; + for (const c of changed) + log(` ${c.created ? "+" : "~"} ${c.file} (+${c.stat.added} -${c.stat.removed})`); + log( + `${verb} ${changed.length} file${changed.length === 1 ? "" : "s"} (${total} specifier${total === 1 ? "" : "s"} rewritten); ${findings.length} manual-review item${findings.length === 1 ? "" : "s"}, ${left.length} \`@astryxdesign/*\` import${left.length === 1 ? "" : "s"} left.`, + ); + if (dryRun && options.diff) + for (const c of changed) log(unifiedDiff(c.file, c.original, c.current)); + for (const e of errors) warn(` ! ${e.file} [${e.transform}]: ${e.error}`); + for (const a of alerts) warn(` ACTION REQUIRED: ${a}`); + for (const n of notices) warn(` note: ${n}`); + + if (check) { + const pending = changed.length + left.length; + if (pending === 0) log("ui-common adopt --check: nothing left to adopt."); + else { + for (const c of changed) + warn(` would change ${c.file} (${c.transforms.join(", ")})`); + for (const l of left) warn(` left: ${l.file}:${l.line} ${l.text}`); + warn( + `ui-common adopt --check: ${changed.length} file${changed.length === 1 ? "" : "s"} to change, ${left.length} \`@astryxdesign/*\` import${left.length === 1 ? "" : "s"} left. ${changed.length > 0 ? "Run `ui-common adopt --from astryx`" : "Move what is left by hand (the reason is beside each)"}${changed.length > 0 && left.length > 0 ? ", then move what it leaves by hand" : ""}.`, + ); + } + return { + code: errors.length > 0 || pending > 0 ? 1 : 0, + changed, + findings, + left, + errors, + report, + rows, + }; + } + + if (writeReport) log(`Report: ${relative(cwd, reportFile) || reportFile}`); + else log(`\n${report}`); + if (dryRun) + log( + `Dry run: nothing was written${writeReport ? " but the report" : ""}. Run without --dry-run to apply${writeReport ? "" : ", or pass --report to keep the report"}.`, + ); + else if (pkgChanged) + log( + "package.json changed: run your package manager's install, then `ui-common doctor`.", + ); + return { + code: errors.length > 0 ? 1 : 0, + changed, + findings, + left, + errors, + report, + rows, + }; +} + +/** @param {string} file */ +function readJsonSafe(file) { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return null; + } +} + +export const ADOPT_HELP = `Usage: ui-common adopt --from astryx [--dry-run | --check] [--diff] [--report ] [--ignore ]… [paths…] + +Move a project that imports Astryx directly (@astryxdesign/*) onto +@lablup/ui-common, in one pass. In a workspace, every member package is +covered. \`ui-common upgrade --from astryx\` is the same command. + +- Rewrites every @astryxdesign/core[/X], @astryxdesign/lab and + @astryxdesign/theme-neutral module specifier to its @lablup/ui-common + mirror: imports, export … from, import(), require(), typeof import, + vi.mock/jest.mock, and CSS/SCSS @import/@use. @astryxdesign/cli and + \`declare module\` augmentations are left alone. +- Renames Dialog / AlertDialog to Modal / AlertModal (ui-common hides them). +- Adds the \`ui-common\` layer to every Astryx @layer order statement, and the + statement to an entry stylesheet or index.html that has none. +- package.json: adds @lablup/ui-common and @lablup/ui-common-cli at this CLI's + version, keeps @astryxdesign/core only where declared and at ui-common's + pin, drops @astryxdesign/theme-neutral and an unused @astryxdesign/lab, and + (pnpm) declines the Astryx postinstalls in allowBuilds. +- Writes ui-common-adopt-report.md: what changed, and what needs a person + (local Astryx patches, Modal refs, body portals, global hotkeys, ASTRYX agent + blocks, \`astryx\` CLI calls, i18n wiring). + +One-off, before ui-common is installed (\`@next\` until 0.2.0 is published): + pnpm dlx @lablup/ui-common-cli@next adopt --from astryx --dry-run + (npx @lablup/ui-common-cli@next adopt --from astryx --dry-run) + + --from astryx What the project uses now. Required; \`astryx\` is the only value. + --dry-run Write nothing; list what would change and print the report. + --check Write nothing; exit 1 when a file would change or an + @astryxdesign/* import is left. For CI, after adopting: + every merge can bring new @astryxdesign/* imports back. + --diff With --dry-run, also print unified diffs. + --report Where to write the report. Default: ${DEFAULT_ADOPT_REPORT} + (a dry run writes one only to a path given here). + --ignore A project-relative path or glob to leave alone. Repeatable. + Files with an @generated header are always left alone. + paths… Only rewrite under these. Default: the whole project + (git-tracked and untracked files, without node_modules, + build output, and nested packages that are not workspace + members). + +Exit codes: 0 done (or, with --check, nothing left), 1 a file could not be +transformed or --check found work, 2 bad arguments. +`; + +/** + * @param {string[]} argv arguments after `adopt` + */ +export async function adoptCommand(argv) { + /** @type {AdoptOptions} */ + const options = { cwd: process.cwd(), paths: [], ignore: [] }; + /** @type {string | undefined} */ + let from; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + const [flag, inline] = + arg.startsWith("--") && arg.includes("=") + ? arg.split(/=(.*)/s) + : [arg, undefined]; + const value = () => inline ?? argv[++i]; + switch (flag) { + case "--from": + from = value(); + break; + case "--report": + options.report = value(); + if (!options.report) { + process.stderr.write("ui-common adopt: --report needs a value\n"); + return 2; + } + break; + case "--ignore": { + const v = value(); + if (!v) { + process.stderr.write("ui-common adopt: --ignore needs a value\n"); + return 2; + } + options.ignore?.push(v); + break; + } + case "--dry-run": + options.dryRun = true; + break; + case "--check": + options.check = true; + break; + case "--diff": + options.diff = true; + break; + case "-h": + case "--help": + process.stdout.write(ADOPT_HELP); + return 0; + default: + if (arg.startsWith("-")) { + process.stderr.write( + `ui-common adopt: unknown option "${arg}"\n${ADOPT_HELP}`, + ); + return 2; + } + options.paths?.push(arg); + } + } + if (from === undefined) { + process.stderr.write( + "ui-common adopt: say what the project uses now: --from astryx (an app on @astryxdesign/*). An app on ui-common 0.1 runs `ui-common upgrade --from 0.1` instead.\n", + ); + return 2; + } + if (from.toLowerCase() !== "astryx") { + process.stderr.write( + `ui-common adopt: --from "${from}" is not supported; the only value is "astryx". An app on ui-common 0.1 runs \`ui-common upgrade --from 0.1\`.\n`, + ); + return 2; + } + if (options.check && options.dryRun) { + process.stderr.write( + "ui-common adopt: --check already writes nothing; drop --dry-run.\n", + ); + return 2; + } + const result = await runAdopt(options); + return result.code; +} diff --git a/packages/cli/cli/agents.mjs b/packages/cli/cli/agents.mjs index 49e58d1..032f264 100644 --- a/packages/cli/cli/agents.mjs +++ b/packages/cli/cli/agents.mjs @@ -8,10 +8,12 @@ * `astryx upgrade` never overwrite it. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { binInvocation, + CLI_ROOT, customExports, dependencyDir, dependencyVersion, @@ -20,6 +22,7 @@ import { hiddenExports, importAstryxInternal, uiCommonPackageJson, + uiCommonRoot, } from "./paths.mjs"; import { rewriteOutput } from "./rewrite.mjs"; @@ -69,7 +72,7 @@ async function coreComponentCount() { } /** ui-common's own lines, appended to the rewritten Astryx block. */ -function uiCommonSection({ version, astryxVersion, invocation }) { +function uiCommonSection({ version, astryxVersion, invocation, skill }) { const components = customExports() .filter((c) => !c.legacy && !c.fork && /^[A-Z]/.test(c.name)) .map((c) => c.name); @@ -102,14 +105,52 @@ function uiCommonSection({ version, astryxVersion, invocation }) { `- The \`ui-common\` bin is @lablup/ui-common-cli, a devDependency pinned to the same version as @lablup/ui-common; bump both together. Without it installed, \`pnpm dlx @lablup/ui-common-cli@next \` (or \`npx @lablup/ui-common-cli@next \`); drop \`@next\` once 0.2.0 is published.`, `- After bumping @lablup/ui-common and @lablup/ui-common-cli: \`${invocation} upgrade --from \`, then read ui-common-upgrade-report.md.`, ); + if (skill) { + lines.push( + `- Moving code off @astryxdesign/*: follow the ${SKILL_NAME} skill (${skill}): \`${invocation} adopt --from astryx\`, then \`${invocation} doctor\` until it passes.`, + ); + } return lines; } +/** The agent skill shipped with the CLI: the guided direct-Astryx → ui-common pass. */ +export const SKILL_NAME = "ui-common-adopt"; +export const SKILL_SOURCE = join(CLI_ROOT, "skill", SKILL_NAME, "SKILL.md"); +/** Where `agents --skill` installs it, relative to the project. */ +export const DEFAULT_SKILL_DIR = ".claude/skills"; + +/** + * The project-relative path of the installed skill, when the project has it + * under the default skills directory; the block mentions it then. + * + * @param {string} projectDir + */ +export function installedSkill(projectDir) { + const rel = `${DEFAULT_SKILL_DIR}/${SKILL_NAME}/SKILL.md`; + return existsSync(join(projectDir, rel)) ? rel : null; +} + +/** + * Copy the skill into `/ui-common-adopt/SKILL.md`. + * + * @param {string} dir the skills directory + * @returns {{file: string, changed: boolean}} + */ +export function installSkill(dir) { + const file = join(dir, SKILL_NAME, "SKILL.md"); + const content = readFileSync(SKILL_SOURCE, "utf8"); + const before = existsSync(file) ? readFileSync(file, "utf8") : null; + if (before === content) return { file, changed: false }; + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, content); + return { file, changed: true }; +} + /** * Turn the Astryx block into the ui-common block. Exported for the tests. * * @param {string} astryxBlock - * @param {{version: string, astryxVersion: string, invocation: string, componentCount?: number|null}} ctx + * @param {{version: string, astryxVersion: string, invocation: string, componentCount?: number|null, skill?: string|null}} ctx */ export function transformBlock(astryxBlock, ctx) { const { invocation, componentCount } = ctx; @@ -178,11 +219,13 @@ export function transformBlock(astryxBlock, ctx) { export async function generateBlock(cwd) { const projectDir = findProjectDir(cwd) ?? cwd; const astryxBlock = await renderAstryxBlock(projectDir); + const root = uiCommonRoot(projectDir); return transformBlock(astryxBlock, { - version: uiCommonPackageJson().version, - astryxVersion: dependencyVersion("@astryxdesign/core") ?? "unknown", + version: uiCommonPackageJson(root).version, + astryxVersion: dependencyVersion("@astryxdesign/core", root) ?? "unknown", invocation: binInvocation(projectDir), componentCount: await coreComponentCount(), + skill: installedSkill(projectDir), }); } @@ -232,9 +275,20 @@ export async function agentsCommand(argv) { /** @type {string|undefined} */ let write; let check = false; + let skill = false; + /** @type {string|undefined} */ + let skillDir; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--write") { + if (arg === "--skill") { + skill = true; + } else if (arg === "--dir" || arg.startsWith("--dir=")) { + skillDir = arg === "--dir" ? argv[++i] : arg.slice("--dir=".length); + if (!skillDir) { + process.stderr.write("ui-common agents: --dir needs a directory\n"); + return 2; + } + } else if (arg === "--write") { write = argv[++i]; if (!write) { process.stderr.write("ui-common agents: --write needs a file path\n"); @@ -256,6 +310,32 @@ export async function agentsCommand(argv) { } const cwd = process.cwd(); + if (skillDir && !skill) { + process.stderr.write("ui-common agents: --dir goes with --skill\n"); + return 2; + } + if (skill) { + if (check) { + process.stderr.write( + "ui-common agents: --skill installs; it does not combine with --check\n", + ); + return 2; + } + const dir = skillDir + ? resolve(cwd, skillDir.replace(/^~(?=$|\/)/, homedir())) + : join(findProjectDir(cwd) ?? cwd, DEFAULT_SKILL_DIR); + const { file, changed } = installSkill(dir); + process.stdout.write( + `ui-common agents: ${changed ? "installed" : "already up to date:"} the ${SKILL_NAME} skill ${changed ? "at " : ""}${file}.\n`, + ); + if (!write) { + if (!skillDir) + process.stdout.write( + "Run `ui-common agents --write ` too: the block names the skill once it is installed.\n", + ); + return 0; + } + } let block; try { block = await generateBlock(cwd); @@ -323,7 +403,7 @@ export async function agentsCommand(argv) { return 0; } -export const AGENTS_HELP = `Usage: ui-common agents [--write ] [--check] +export const AGENTS_HELP = `Usage: ui-common agents [--write ] [--check] [--skill [--dir ]] Print the ui-common agent block: Astryx's \`init --features agents\` block, rewritten for @lablup/ui-common, between ${MARKER_START} and ${MARKER_END}. @@ -332,4 +412,10 @@ rewritten for @lablup/ui-common, between ${MARKER_START} and ${MARKER_END}. and keeping everything outside the markers. Creates the file. --check Exit 1 when the block in (or the first of AGENTS.md, CLAUDE.md, .claude/CLAUDE.md that has one) is stale or missing. + --skill Install the ${SKILL_NAME} agent skill (Claude Code format): the + guided move of an app off @astryxdesign/* (adopt, doctor, + verification). Default: ${DEFAULT_SKILL_DIR}/${SKILL_NAME}/SKILL.md in + the project, which the block then names. With --write, both. + --dir With --skill, the skills directory to install into, e.g. + ~/.claude/skills for every project of this user. `; diff --git a/packages/cli/cli/doctor.mjs b/packages/cli/cli/doctor.mjs new file mode 100644 index 0000000..e7b157e --- /dev/null +++ b/packages/cli/cli/doctor.mjs @@ -0,0 +1,1045 @@ +/** + * `ui-common doctor`: read-only checks that a project is wired onto + * @lablup/ui-common the way the first adopters learned it has to be. Each + * check names the problem, a fix, and the section of + * docs/adopting-from-astryx.md that explains it. Exit 1 on any failure. + * + * Works on a single package or a workspace (each member that depends on + * ui-common or Astryx is checked), with pnpm or npm. + */ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { basename, extname, join } from "node:path"; + +import { LAB, CORE, PNPM_OVERRIDE_KEY, detectPackageManager } from "./lab-peer.mjs"; +import { DEFAULT_AGENT_FILES, findBlock, generateBlock } from "./agents.mjs"; +import { cliPackageJson, findProjectDir } from "./paths.mjs"; +import { coerce, compare } from "./semver.mjs"; +import { + bareVersion, + declarations, + installRoot, + isGenerated, + listProjectFiles, + lockVersionOf, + npmLockCopies, + ownerRoot, + packageRoots, + pnpmCatalogs, + pnpmLockKeys, + readJsonFile, + readText, + relPath, + resolveSpec, + SCRIPT_EXTENSIONS, + STYLE_EXTENSIONS, +} from "./project.mjs"; +import { findSpecifiers, mapSpecifier, UIC } from "../codemods/adopt/specifiers.mjs"; +import { blankComments, layerStatements } from "../codemods/adopt/layers.mjs"; + +const CLI_PACKAGE = "@lablup/ui-common-cli"; +const DOC = "docs/adopting-from-astryx.md"; +const DOC_URL = + "https://github.com/lablup/ui-common/blob/main/docs/adopting-from-astryx.md"; +const NODE_FLOOR = "22.13.0"; + +/** + * @typedef {"pass" | "warn" | "fail" | "skip"} Status + * @typedef {{id: string, title: string, status: Status, summary: string, details: string[], fix?: string, doc: string}} Check + */ + +/** + * @param {string} id + * @param {string} title + * @param {Status} status + * @param {string} summary + * @param {{details?: string[], fix?: string}} [more] + * @returns {Check} + */ +function result(id, title, status, summary, more = {}) { + return { + id, + title, + status, + summary, + details: more.details ?? [], + ...(more.fix && (status === "fail" || status === "warn") ? { fix: more.fix } : {}), + doc: `${DOC}#${id}`, + }; +} + +/** The package directory `name` resolves to from `fromDir`, real path, or null. */ +function resolvePackageDir(/** @type {string} */ name, /** @type {string} */ fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + let file; + try { + file = req.resolve(`${name}/package.json`); + } catch { + file = req.resolve(name); + } + let dir = file; + for (;;) { + const parent = join(dir, ".."); + if (parent === dir) return null; + dir = parent; + const pkg = readJsonFile(join(dir, "package.json")); + if (pkg?.name === name) return realpathSync(dir); + } + } catch { + return null; + } +} + +/** + * Everything the checks read, gathered once. + * + * @param {string} cwd + */ +export function gatherProject(cwd) { + const start = findProjectDir(cwd) ?? cwd; + const install = installRoot(start); + // Run from a workspace member, the checks cover that member, but read the + // whole workspace: the page that declares the layer order, the lockfile + // and the catalog live at its root. + const workspace = install !== start && packageRoots(install).includes(start); + const projectDir = workspace ? install : start; + const roots = packageRoots(projectDir); + const rootPkg = readJsonFile(join(projectDir, "package.json")); + const pm = detectPackageManager(projectDir, rootPkg); + const workspaceYaml = pm.workspaceYaml ? readText(pm.workspaceYaml) : null; + const catalogs = pnpmCatalogs(workspaceYaml); + const pkgs = new Map(roots.map((r) => [r, readJsonFile(join(r, "package.json"))])); + const relevant = (/** @type {any} */ pkg) => + [UIC, CLI_PACKAGE, CORE, LAB, "@astryxdesign/theme-neutral"].some( + (n) => declarations(pkg, n).length > 0, + ); + // Every package of the project that depends on ui-common or Astryx. + let related = roots.filter((r) => relevant(pkgs.get(r))); + if (related.length === 0) related = [projectDir]; + let using = workspace ? [start] : related; + if (using.length === 0) using = [projectDir]; + const files = listProjectFiles(projectDir, roots); + /** @type {Map} */ + const cache = new Map(); + const text = (/** @type {string} */ f) => { + if (!cache.has(f)) cache.set(f, readText(f)); + return cache.get(f) ?? null; + }; + const lockText = existsSync(join(install, "pnpm-lock.yaml")) + ? readText(join(install, "pnpm-lock.yaml")) + : null; + const npmLock = + readJsonFile(join(install, "package-lock.json")) ?? + readJsonFile(join(install, "npm-shrinkwrap.json")); + return { + projectDir, + roots, + using, + related, + install, + pm, + workspaceYaml, + catalogs, + pkgs, + files, + text, + lockText, + npmLock, + rel: (/** @type {string} */ f) => relPath(projectDir, f), + }; +} + +/** @typedef {ReturnType} Project */ + +/** @param {Project} p */ +export function checkNode(p, nodeVersion = process.versions.node) { + void p; + const ok = compare(nodeVersion, NODE_FLOOR) >= 0; + return result( + "node", + "Node for the CLI", + ok ? "pass" : "fail", + ok + ? `Node ${nodeVersion} (the CLI needs ${NODE_FLOOR} or later).` + : `Node ${nodeVersion} is older than ${NODE_FLOOR}, which @lablup/ui-common-cli and the Astryx CLI it wraps need.`, + { + fix: `Run the CLI on Node ${NODE_FLOOR} or later (the app itself may build on its own Node).`, + }, + ); +} + +/** @param {Project} p */ +export function checkVersions(p) { + const id = "versions"; + const title = "@lablup/ui-common and @lablup/ui-common-cli at one version"; + /** @type {string[]} */ + const details = []; + /** @type {Set} */ + const ui = new Set(); + /** @type {Set} */ + const cli = new Set(); + for (const r of p.related) { + const pkg = p.pkgs.get(r); + for (const [field, spec] of declarations(pkg, UIC)) { + const v = resolveSpec(UIC, spec, p.catalogs) ?? spec; + ui.add(coerce(v) ?? v); + details.push( + `${p.rel(r)}: ${field}["${UIC}"] = ${spec}${v !== spec ? ` (${v})` : ""}`, + ); + } + for (const [field, spec] of declarations(pkg, CLI_PACKAGE)) { + const v = resolveSpec(CLI_PACKAGE, spec, p.catalogs) ?? spec; + cli.add(coerce(v) ?? v); + details.push( + `${p.rel(r)}: ${field}["${CLI_PACKAGE}"] = ${spec}${v !== spec ? ` (${v})` : ""}`, + ); + } + } + if (ui.size === 0) + return result(id, title, "fail", `No package declares ${UIC}.`, { + details, + fix: "Run `ui-common adopt --from astryx` (an app on @astryxdesign/*), or add @lablup/ui-common.", + }); + /** @type {Set} */ + const installedUi = new Set(); + /** @type {Set} */ + const installedCli = new Set(); + for (const r of p.related) { + const u = resolvePackageDir(UIC, r); + if (u) installedUi.add(readJsonFile(join(u, "package.json"))?.version); + const c = resolvePackageDir(CLI_PACKAGE, r); + if (c) installedCli.add(readJsonFile(join(c, "package.json"))?.version); + } + if (installedUi.size > 0) + details.push(`installed ${UIC}: ${[...installedUi].join(", ")}`); + if (installedCli.size > 0) + details.push(`installed ${CLI_PACKAGE}: ${[...installedCli].join(", ")}`); + const all = new Set([...ui, ...cli]); + if (ui.size > 1 || all.size > 1) + return result( + id, + title, + "fail", + `Declared versions differ: ${[...all].join(", ")}.`, + { + details, + fix: `Pin ${UIC} and ${CLI_PACKAGE} to the same exact version everywhere, and bump them together.`, + }, + ); + if (installedUi.size === 0) + return result(id, title, "fail", `${UIC} is declared but not installed.`, { + details, + fix: "Run your package manager's install.", + }); + const installed = new Set([...installedUi, ...installedCli]); + if (installedUi.size > 1 || installed.size > 1) + return result( + id, + title, + "fail", + `Installed versions differ: ${[...installed].join(", ")}.`, + { + details, + fix: "Run your package manager's install; dedupe until one version of each is installed.", + }, + ); + if (cli.size === 0) + return result( + id, + title, + "warn", + `${UIC} ${[...ui][0]}; ${CLI_PACKAGE} is not a dependency.`, + { + details, + fix: `Add ${CLI_PACKAGE}@${[...ui][0]} as a devDependency, so \`ui-common doctor\` and \`ui-common agents\` run at the project's version.`, + }, + ); + return result( + id, + title, + "pass", + `${UIC} and ${CLI_PACKAGE} at ${[...installed][0]}.`, + { details }, + ); +} + +/** Installed core copies, from the lockfile, as [label, version] pairs. */ +function lockCopies(/** @type {Project} */ p, /** @type {string} */ name) { + if (p.lockText) { + const keys = pnpmLockKeys(p.lockText); + return { + kind: "pnpm-lock.yaml", + versions: keys.packages + .map((k) => lockVersionOf(k, name)) + .filter((v) => v != null), + snapshots: keys.snapshots + .map((k) => lockVersionOf(k, name)) + .filter((v) => v != null), + }; + } + if (p.npmLock) { + const copies = npmLockCopies(p.npmLock, name); + return { + kind: "package-lock.json", + versions: [...new Set(copies.map((c) => c.version))], + snapshots: copies.map((c) => `${c.version} at ${c.path}`), + }; + } + return null; +} + +/** The core ui-common pins, from the installed ui-common. */ +function pinnedCore(/** @type {Project} */ p) { + for (const r of p.using) { + const dir = resolvePackageDir(UIC, r); + const pin = dir + ? readJsonFile(join(dir, "package.json"))?.dependencies?.[CORE] + : null; + if (pin) return { pin, dir }; + } + return { pin: null, dir: null }; +} + +/** @param {Project} p */ +export function checkSingleCore(p) { + const id = "single-core"; + const title = "One @astryxdesign/core"; + /** @type {string[]} */ + const details = []; + const fix = `Remove every other source of ${CORE}: keep it in your own package.json only at the version ${UIC} pins, add the lab override when you use ${UIC}/lab, then reinstall until \`pnpm why -r ${CORE}\` (npm: \`npm ls ${CORE}\`) lists one version.`; + const { pin, dir: uiDir } = pinnedCore(p); + const copies = lockCopies(p, CORE); + const problems = []; + if (copies) { + details.push( + `${copies.kind}: ${copies.snapshots.length} ${copies.kind === "pnpm-lock.yaml" ? "resolution" : "cop"}${copies.snapshots.length === 1 ? (copies.kind === "pnpm-lock.yaml" ? "" : "y") : copies.kind === "pnpm-lock.yaml" ? "s" : "ies"}`, + ); + for (const s of copies.snapshots) details.push(` ${CORE}@${s}`); + if (copies.versions.length === 0) problems.push(`${CORE} is not in ${copies.kind}`); + if (copies.versions.length > 1) + problems.push( + `${copies.versions.length} versions: ${copies.versions.map(bareVersion).join(", ")}`, + ); + if (copies.snapshots.length > 1 && copies.versions.length <= 1) + problems.push( + copies.kind === "pnpm-lock.yaml" + ? `the same version resolved against ${copies.snapshots.length} peer sets, which pnpm installs as separate copies` + : `${copies.snapshots.length} copies of one version in separate directories`, + ); + if (pin && copies.versions.length === 1 && bareVersion(copies.versions[0]) !== pin) + problems.push( + `the lockfile has ${bareVersion(copies.versions[0])}, ${UIC} pins ${pin}`, + ); + } + // What actually resolves: from ui-common, from lab, from each package. + /** @type {Map} */ + const resolved = new Map(); + const add = (/** @type {string | null} */ d, /** @type {string} */ from) => { + if (!d) return; + resolved.set(d, [...(resolved.get(d) ?? []), from]); + }; + if (uiDir) add(resolvePackageDir(CORE, uiDir), UIC); + const labDir = p.using.map((r) => resolvePackageDir(LAB, r)).find(Boolean) ?? null; + if (labDir) add(resolvePackageDir(CORE, labDir), LAB); + for (const r of p.using) { + if (declarations(p.pkgs.get(r), CORE).length > 0) + add(resolvePackageDir(CORE, r), p.rel(r)); + } + if (resolved.size > 1) { + problems.push(`${resolved.size} copies resolve at runtime`); + for (const [d, from] of resolved) details.push(` ${from.join(", ")} → ${d}`); + } + if (!copies && resolved.size === 0) + return result( + id, + title, + "fail", + `Nothing to read: no lockfile, and ${CORE} does not resolve.`, + { + details, + fix: "Run your package manager's install.", + }, + ); + if (problems.length > 0) + return result( + id, + title, + "fail", + `More than one ${CORE}, or not ui-common's: ${problems.join("; ")}.`, + { details, fix }, + ); + const version = copies?.versions[0] ? bareVersion(copies.versions[0]) : (pin ?? ""); + return result( + id, + title, + "pass", + `One ${CORE}${version ? ` (${version})` : ""}${pin ? `, the version ${UIC} pins` : ""}.`, + { details }, + ); +} + +/** Whether the project's modules use `@lablup/ui-common/lab`, by package root. */ +function labUsers(/** @type {Project} */ p) { + /** @type {Set} */ + const users = new Set(); + for (const f of p.files) { + const ext = extname(f); + if (!SCRIPT_EXTENSIONS.has(ext) && !STYLE_EXTENSIONS.has(ext)) continue; + const t = p.text(f); + const owner = ownerRoot(f, p.roots) ?? p.projectDir; + if (p.using.includes(owner) && t && /['"]@lablup\/ui-common\/lab\b/.test(t)) + users.add(owner); + } + return users; +} + +/** @param {Project} p */ +export function checkLabCore(p) { + const id = "lab-core"; + const title = `${LAB} runs on that core`; + const fix = `Point ${LAB}'s ${CORE} peer at ${UIC}'s: \`ui-common adopt\` adds the override, or see README "With @lablup/ui-common/lab".`; + /** @type {string[]} */ + const details = []; + const copies = lockCopies(p, LAB); + const core = lockCopies(p, CORE); + const labDir = p.using.map((r) => resolvePackageDir(LAB, r)).find(Boolean) ?? null; + if ((!copies || copies.versions.length === 0) && !labDir) + return result(id, title, "skip", `${LAB} is not installed.`); + const problems = []; + if (copies?.kind === "pnpm-lock.yaml" && core) { + const coreVersion = + core.versions.length === 1 ? bareVersion(core.versions[0]) : null; + for (const snap of copies.snapshots) { + const peer = /\(@astryxdesign\/core@([^()]+)/.exec(snap)?.[1] ?? null; + details.push(`${LAB}@${bareVersion(snap)} → ${CORE} ${peer ?? "(none)"}`); + if (!peer) problems.push(`a ${LAB} resolution has no ${CORE} peer`); + else if (coreVersion && peer !== coreVersion) + problems.push( + `${LAB} resolves ${CORE} ${peer}, everything else ${coreVersion}`, + ); + } + } + if (copies?.kind === "package-lock.json" && p.npmLock) { + for (const c of npmLockCopies(p.npmLock, CORE)) { + if (c.path.includes(`node_modules/${LAB}/node_modules/`)) + problems.push(`npm nested ${CORE} ${c.version} under ${LAB}`); + } + } + if (labDir) { + const fromLab = resolvePackageDir(CORE, labDir); + const { dir: uiDir } = pinnedCore(p); + const fromUi = uiDir ? resolvePackageDir(CORE, uiDir) : null; + if (fromLab && fromUi && fromLab !== fromUi) { + problems.push(`${LAB} resolves a different ${CORE} directory than ${UIC}`); + details.push(` ${LAB} → ${fromLab}`, ` ${UIC} → ${fromUi}`); + } + } + if (problems.length > 0) + return result(id, title, "fail", `${problems.join("; ")}.`, { details, fix }); + return result(id, title, "pass", `${LAB} shares ${UIC}'s ${CORE}.`, { details }); +} + +/** @param {Project} p */ +export function checkLabOverride(p) { + const id = "lab-override"; + const title = "The lab override, where /lab is used"; + const users = labUsers(p); + if (users.size === 0) + return result(id, title, "skip", `No module imports ${UIC}/lab.`); + const pin = pinnedCore(p).pin; + /** @type {string[]} */ + const details = [ + `${UIC}/lab is imported in ${[...users].map((u) => p.rel(u)).join(", ")}`, + ]; + let override = null; + if (p.pm.manager === "pnpm" && p.workspaceYaml) { + const block = /^overrides:[ \t]*\r?\n((?:[ \t]+.*\r?\n?)*)/m.exec(p.workspaceYaml); + const key = PNPM_OVERRIDE_KEY.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); + const m = block + ? new RegExp(`["']?${key}["']?\\s*:\\s*["']?([^"'\\s#]+)`).exec(block[1]) + : null; + if (m) override = m[1]; + } else { + const rootPkg = readJsonFile(join(p.pm.root, "package.json")); + const o = rootPkg?.overrides?.[LAB]?.[CORE]; + if (typeof o === "string") override = o; + } + if (override) { + details.push(`override: ${PNPM_OVERRIDE_KEY} → ${override}`); + if (pin && override !== pin && !override.startsWith("$")) + return result( + id, + title, + "fail", + `The override pins ${CORE} ${override}; ${UIC} pins ${pin}.`, + { + details, + fix: `Move the override to ${pin}.`, + }, + ); + return result( + id, + title, + "pass", + `${LAB}'s core peer is overridden to ${override}.`, + { details }, + ); + } + // Without an override, pnpm resolves lab's core peer to the core the + // importing package declares itself (README, "With @lablup/ui-common/lab"). + const own = [...users].map((u) => + declarations(p.pkgs.get(u), CORE) + .filter(([field]) => field !== "peerDependencies") + .map(([, spec]) => resolveSpec(CORE, spec, p.catalogs) ?? spec), + ); + const direct = own.every((specs) => specs.length > 0); + const atPin = direct && own.every((specs) => specs.every((s) => !pin || s === pin)); + const recipe = + p.pm.manager === "npm" + ? `"overrides": {"${LAB}": {"${CORE}": "${pin ?? ""}"}} in the root package.json` + : `overrides: { "${PNPM_OVERRIDE_KEY}": "${pin ?? ""}" } in pnpm-workspace.yaml`; + if (atPin && p.pm.manager === "pnpm") + return result( + id, + title, + "pass", + `No override; each package that uses /lab declares ${CORE} ${pin ?? ""} itself, which pnpm resolves ${LAB}'s peer to.`, + { details }, + ); + if (direct) + return result( + id, + title, + "warn", + `No override; ${LAB}'s core peer relies on the package's own ${CORE}${atPin ? "" : `, which is not at ${UIC}'s pin ${pin}`}.`, + { + details, + fix: `Add ${recipe}, so lab runs on ${UIC}'s core whatever the package declares.`, + }, + ); + return result( + id, + title, + "fail", + `No override, and no ${CORE} of the package's own for ${LAB}'s peer to resolve to.`, + { + details, + fix: `Add ${recipe}, then reinstall.`, + }, + ); +} + +/** @param {Project} p */ +export function checkImports(p) { + const id = "imports"; + const title = "No @astryxdesign/* imports"; + /** @type {string[]} */ + const details = []; + let count = 0; + const exportsAny = new Proxy({}, { has: () => true }); + for (const f of p.files) { + const ext = extname(f); + const script = SCRIPT_EXTENSIONS.has(ext); + if (!script && !STYLE_EXTENSIONS.has(ext)) continue; + const t = p.text(f); + if (!t || !/@astryxdesign\//.test(t) || isGenerated(t)) continue; + for (const s of findSpecifiers(t, script ? "script" : "style")) { + if (!mapSpecifier(s.spec, /** @type {any} */ (exportsAny))) continue; + count++; + if (details.length < 25) details.push(`${p.rel(f)}:${s.line} ${s.spec}`); + } + } + if (count > 25) details.push(`… and ${count - 25} more`); + if (count > 0) + return result( + id, + title, + "fail", + `${count} module specifier${count === 1 ? "" : "s"} still import Astryx directly.`, + { + details, + fix: "Run `ui-common adopt --from astryx` (then `--check` in CI), and ban `@astryxdesign/*` in ESLint (see the doc).", + }, + ); + return result( + id, + title, + "pass", + "Every Astryx import goes through @lablup/ui-common.", + ); +} + +/** + * Where a layer-order statement has to be: the first `${html.slice(at)}`; +} diff --git a/packages/cli/codemods/adopt/package-json.mjs b/packages/cli/codemods/adopt/package-json.mjs new file mode 100644 index 0000000..ebd01ed --- /dev/null +++ b/packages/cli/codemods/adopt/package-json.mjs @@ -0,0 +1,208 @@ +/** + * `ui-common adopt` package.json edits, for one package of the project: + * + * - `@lablup/ui-common` is added at exactly the CLI's version (a library + * takes it as a peer and a devDependency), with the `@stylexjs/stylex` peer + * it needs when that is missing. A declaration already there is left alone. + * - `@lablup/ui-common-cli`, the `ui-common` bin, is added once per project + * (`addCli`) as a devDependency at the same version. + * - `@astryxdesign/core` stays only where it is declared, moved to exactly the + * version ui-common pins (a second version would be a second copy of + * Astryx). It is never added. A library's core peer is dropped: ui-common + * brings core. + * - `@astryxdesign/theme-neutral` is removed once no module imports it. + * - `@astryxdesign/lab` is removed once no module uses `@lablup/ui-common/lab`; + * while one does, it is pinned to the canary ui-common is built against. + * - `catalog:`, `workspace:`, `link:` and `file:` specs are never edited; a + * catalog entry that disagrees with ui-common's pin is reported. + */ +import { addDependency } from "../0.2/package-json.mjs"; +import { coerce, compare } from "../../cli/semver.mjs"; +import { DEP_FIELDS, resolveSpec } from "../../cli/project.mjs"; +import { UIC } from "./specifiers.mjs"; + +export const CLI_PACKAGE = "@lablup/ui-common-cli"; +const CORE = "@astryxdesign/core"; +const LAB = "@astryxdesign/lab"; +const NEUTRAL = "@astryxdesign/theme-neutral"; +const ASTRYX_CLI = "@astryxdesign/cli"; +const STYLEX = "@stylexjs/stylex"; + +/** @param {string} spec */ +const isIndirect = (spec) => + /^(catalog:|workspace:|link:|file:|npm:|git|https?:|portal:)/.test(spec.trim()); + +/** + * @typedef {object} AdoptPackageContext + * @property {string} rel the package's directory, project-relative ("." for the root) + * @property {string} version the ui-common version to adopt (the CLI's own) + * @property {string} corePin ui-common's `@astryxdesign/core` + * @property {string} labPin ui-common's `@astryxdesign/lab` peer + * @property {string} stylexRange ui-common's `@stylexjs/stylex` peer + * @property {string | undefined} astryxCliPin the `@astryxdesign/cli` the CLI wraps + * @property {boolean} usesLab a module of the package imports `@lablup/ui-common/lab` + * @property {boolean} keepsLab a module still imports `@astryxdesign/lab` + * @property {boolean} keepsNeutral a module still imports `@astryxdesign/theme-neutral` + * @property {boolean} addCli add `@lablup/ui-common-cli` here + * @property {ReturnType} catalogs + * @property {(message: string) => void} note + * @property {(message: string) => void} alert + */ + +/** + * @param {string} text package.json source + * @param {AdoptPackageContext} ctx + * @returns {{text: string | undefined, labAdded: boolean, declaresCore: boolean}} + */ +export function adoptPackageJson(text, ctx) { + const pkg = JSON.parse(text); + const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " "; + const where = ctx.rel === "." ? "" : `${ctx.rel}/package.json: `; + const note = (/** @type {string} */ m) => ctx.note(`${where}${m}`); + const has = (/** @type {string} */ name) => + DEP_FIELDS.some((f) => pkg[f]?.[name] != null); + const library = [CORE, LAB, NEUTRAL, UIC].some( + (n) => pkg.peerDependencies?.[n] != null, + ); + let labAdded = false; + + /** + * Where a package this project needs goes: an application's dependencies, + * a library's peers plus devDependencies. + * + * @param {string} name + * @param {string} range + * @param {string} why + */ + const ensure = (name, range, why) => { + const fields = library + ? [ + ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["peerDependencies"] + : []), + ...(pkg.devDependencies?.[name] == null && pkg.dependencies?.[name] == null + ? ["devDependencies"] + : []), + ] + : has(name) + ? [] + : ["dependencies"]; + for (const field of fields) addDependency(pkg, field, name, range); + if (fields.length > 0) + note(`added ${name} ${range} to ${fields.join(" and ")}${why}.`); + return fields.length > 0; + }; + + /** Remove `name` from every field. */ + const drop = (/** @type {string} */ name, /** @type {string} */ why) => { + const fields = DEP_FIELDS.filter((f) => pkg[f]?.[name] != null); + for (const f of fields) { + delete pkg[f][name]; + if (Object.keys(pkg[f]).length === 0) delete pkg[f]; + } + if (fields.length > 0) + note(`removed ${name} from ${fields.join(" and ")}: ${why}.`); + }; + + /** + * Move `name` to exactly `pin` wherever it is declared with a plain range. + * + * @param {string} name + * @param {string} pin + */ + const pinExactly = (name, pin) => { + for (const field of DEP_FIELDS) { + const spec = pkg[field]?.[name]; + if (typeof spec !== "string") continue; + if (isIndirect(spec)) { + const resolved = resolveSpec(name, spec, ctx.catalogs); + if (resolved !== spec && resolved !== pin) + note( + `${field}["${name}"] is "${spec}", which resolves to ${resolved}; ${UIC} ${ctx.version} pins ${pin}. Move that entry to ${pin}.`, + ); + continue; + } + if (spec === pin) continue; + const from = coerce(spec); + pkg[field][name] = pin; + note( + `${field}["${name}"]: "${spec}" → "${pin}", the version ${UIC} ${ctx.version} pins.`, + ); + if (name === CORE && from && compare(from, pin) < 0) { + ctx.alert( + `**Astryx moves from ${from} to ${pin}${where ? ` in ${ctx.rel}` : ""}.** ${UIC} ${ctx.version} is built on Astryx ${pin}. Run Astryx's own codemods for that span while the code still imports \`@astryxdesign/*\` (before this adopt run, or on a revert of it): \`ui-common astryx upgrade --from ${from} --path --apply\`, then adopt again.`, + ); + } + } + }; + + // ui-common itself, and what it needs. + if (!has(UIC)) { + ensure( + UIC, + ctx.version, + `: the one dependency that brings Astryx (core, theme-neutral) at the versions it pins`, + ); + ensure(STYLEX, ctx.stylexRange, `: the StyleX runtime ${UIC} and Astryx share`); + } + if (ctx.addCli && !has(CLI_PACKAGE)) { + addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.version); + note( + `added ${CLI_PACKAGE} ${ctx.version} to devDependencies: the \`ui-common\` bin (\`ui-common doctor\`, \`ui-common agents\`, the Astryx CLI in ${UIC} terms), released at the same version as ${UIC}.`, + ); + } + + // Astryx core: kept only at ui-common's pin. + if (has(CORE)) { + if (library && pkg.peerDependencies?.[CORE] != null) { + delete pkg.peerDependencies[CORE]; + if (Object.keys(pkg.peerDependencies).length === 0) delete pkg.peerDependencies; + note(`removed ${CORE} from peerDependencies: ${UIC} brings it, pinned.`); + } + pinExactly(CORE, ctx.corePin); + } + + // theme-neutral: mirrored as @lablup/ui-common/theme/neutral. + if (has(NEUTRAL)) { + if (ctx.keepsNeutral) + note( + `${NEUTRAL} stays: a module still imports it (see the report). ${UIC}/theme/neutral mirrors it.`, + ); + else drop(NEUTRAL, `${UIC} depends on it and mirrors it as ${UIC}/theme/neutral`); + } + + // lab: an optional peer of ui-common, exact-pinned. + if (ctx.usesLab) { + if (has(LAB)) pinExactly(LAB, ctx.labPin); + else { + labAdded = ensure( + LAB, + ctx.labPin, + `: ${UIC}/lab needs it, and ${UIC} pins the lab canary exactly`, + ); + } + } else if (has(LAB)) { + if (ctx.keepsLab) note(`${LAB} stays: a module still imports it (see the report).`); + else drop(LAB, `no module uses ${UIC}/lab`); + } + + // The Astryx CLI: kept, but it should be the one ui-common-cli wraps. + for (const field of DEP_FIELDS) { + const spec = pkg[field]?.[ASTRYX_CLI]; + if (typeof spec !== "string" || !ctx.astryxCliPin) continue; + const resolved = resolveSpec(ASTRYX_CLI, spec, ctx.catalogs); + if (resolved === ctx.astryxCliPin) continue; + note( + `${field}["${ASTRYX_CLI}"] is "${spec}"; ${CLI_PACKAGE} wraps ${ASTRYX_CLI} ${ctx.astryxCliPin}. Use \`ui-common \` and drop it, or keep it at ${ctx.astryxCliPin}.`, + ); + } + + const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`; + return { + text: out === text ? undefined : out, + labAdded, + declaresCore: DEP_FIELDS.some( + (f) => f !== "peerDependencies" && pkg[f]?.[CORE] != null, + ), + }; +} diff --git a/packages/cli/codemods/adopt/scan.mjs b/packages/cli/codemods/adopt/scan.mjs new file mode 100644 index 0000000..205fdeb --- /dev/null +++ b/packages/cli/codemods/adopt/scan.mjs @@ -0,0 +1,310 @@ +/** + * `ui-common adopt` report-only scans. Nothing here edits a file: each + * finding is a place where moving onto ui-common changes behaviour or + * tooling in a way a person has to decide about. + */ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, extname, join } from "node:path"; + +import { ASTRYX_COMMANDS } from "../../cli/rewrite.mjs"; +import { + lineAt, + lineTextAt, + readText, + relPath, + SCRIPT_EXTENSIONS, +} from "../../cli/project.mjs"; + +/** @type {Record} */ +export const CATEGORIES = { + unmirrored: { + title: "`@astryxdesign/*` imports left in place", + help: "ui-common has no mirror for these, or adopt could not move them. Replace each with a ui-common export (the detail says which), or keep Astryx for it on purpose. `adopt --check` and `ui-common doctor` fail while any is left.", + }, + "dialog-ref": { + title: "Dialog → Modal: refs", + help: 'A `Modal` / `AlertModal` ref reaches the element with `role="dialog"`, not an `HTMLDialogElement`. Check each use: retype it, and drive the modal with `isOpen` / `onOpenChange` instead of `showModal()` / `close()`.', + }, + patches: { + title: "Local patches on Astryx", + help: "A pnpm or patch-package patch on `@astryxdesign/*` applies only in this project, and ui-common's own copies of some components do not see it. Drop each patch ui-common's fork covers; for the rest decide whether the fix still matters on the Astryx version ui-common pins, and keep the patch at that version or drop it.", + }, + portals: { + title: "Overlays rendered into document.body", + help: "While a `Modal` is open, every other child of `` is `inert`. A toast viewport, notification area or debug overlay that must stay usable over a modal needs `MODAL_LIVE_ATTRIBUTE` (from `@lablup/ui-common/Modal`) on its root, and `refreshModalBackground()` if it mounts while a modal is open. Check in a real browser: jsdom ignores `inert`.", + }, + hotkeys: { + title: "Global keyboard shortcuts", + help: "A document- or window-level shortcut still fires while a modal is open. Skip it while an element with `MODAL_OPEN_ATTRIBUTE` (from `@lablup/ui-common/Modal`) that is not `inert` exists.", + }, + escape: { + title: "Escape handlers of your own", + help: "`Modal`, the lab `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too.", + }, + "agent-blocks": { + title: "Astryx agent blocks, and tools anchored on them", + help: "Replace each `` block with ui-common's: `ui-common agents --write ` adds the `UI-COMMON` block (keep your own lines outside its markers), then delete the ASTRYX block. A script or workflow that finds its place by the ASTRYX markers breaks when they go: point it at `` or at your own marker.", + }, + "astryx-cli": { + title: "`astryx` CLI invocations", + help: "`ui-common ` runs the Astryx CLI ui-common pins and rewrites its output to ui-common paths; `ui-common astryx ` runs it unrewritten. Switch each invocation, or keep `@astryxdesign/cli` as a devDependency at the version `@lablup/ui-common-cli` pins. `astryx theme build` keeps working either way (a recipe that imports ui-common components needs the `.css` stub in docs/adopting-from-astryx.md).", + }, + i18n: { + title: "InternationalizationProvider without ui-common's strings", + help: "ui-common's built-in strings resolve through Astryx's `InternationalizationProvider`. Merge `uiCommonMessages` (from `@lablup/ui-common/i18n-catalog`) into its `messages` with `mergeMessages`, or ui-common's components stay in English. Map your language codes to Astryx locale names (`ko` → `ko-KR`).", + }, +}; + +/** + * @typedef {{category: string, file: string, line: number, text: string, detail?: string}} Finding + */ + +/** + * @param {string} category + * @param {string} file + * @param {string} text + * @param {number} index + * @param {string} [detail] + * @returns {Finding} + */ +function at(category, file, text, index, detail) { + return { + category, + file, + line: lineAt(text, index), + text: lineTextAt(text, index), + detail, + }; +} + +const ASTRYX_INVOCATION = new RegExp( + String.raw`(^|[^\w@/.-])astryx\s+(${ASTRYX_COMMANDS.join("|")})\b`, + "g", +); + +/** + * Findings in one file. `file` is project-relative, `text` its content after + * the run. + * + * @param {string} file + * @param {string} text + * @returns {Finding[]} + */ +export function scanFile(file, text) { + /** @type {Finding[]} */ + const out = []; + const ext = extname(file); + const name = basename(file); + const script = SCRIPT_EXTENSIONS.has(ext); + + if (script) { + const live = /MODAL_LIVE_ATTRIBUTE|data-uic-modal-live/.test(text); + if (!live) { + for (const m of text.matchAll(/\bcreatePortal\s*\(/g)) { + const call = text.slice(m.index, (m.index ?? 0) + 600); + if (/document\.body\b/.test(call)) + out.push( + at("portals", file, text, m.index ?? 0, "a portal into document.body"), + ); + } + for (const m of text.matchAll(//.test(text)) { + const m = //.exec(text); + out.push( + at( + "agent-blocks", + file, + text, + m?.index ?? 0, + //.test(text) + ? "the UI-COMMON block is there too: delete this one" + : "an Astryx agent block", + ), + ); + } else if (/ASTRYX:(?:START|END)/.test(text) && !/\.mdx?$/.test(name)) { + const m = /ASTRYX:(?:START|END)/.exec(text); + out.push( + at("agent-blocks", file, text, m?.index ?? 0, "anchors on the ASTRYX markers"), + ); + } + + const invocations = name === "package.json" ? scriptsOf(text) : [{ text, offset: 0 }]; + if (name === "package.json" || /\.(ya?ml|sh|md|mdx|json)$/.test(name) || script) { + for (const part of invocations) { + for (const m of part.text.matchAll(ASTRYX_INVOCATION)) { + const index = part.offset + (m.index ?? 0) + m[1].length; + // `ui-common astryx …` is the raw passthrough, already through ui-common. + if (/ui-common\s+$/.test(text.slice(Math.max(0, index - 12), index))) continue; + if (script && !/['"`]/.test(lineTextAt(text, index))) continue; + out.push(at("astryx-cli", file, text, index)); + } + } + } + return out; +} + +/** The `scripts` values of a package.json, with their offsets. */ +function scriptsOf(/** @type {string} */ text) { + const block = /"scripts"\s*:\s*\{[^}]*\}/.exec(text); + return block ? [{ text: block[0], offset: block.index ?? 0 }] : []; +} + +/** + * Astryx components ui-common ships its own copy of (src/forks), by the path + * fragment a patch on Astryx's dist would touch. + */ +export const FORKED = [ + { name: "ComplexSelector", pattern: /ComplexSelector/ }, + { name: "Drawer (lab)", pattern: /Drawer/ }, + { name: "Tour (lab)", pattern: /\bTour/ }, +]; + +/** + * Local patches on Astryx: pnpm `patchedDependencies` (pnpm-workspace.yaml or + * package.json `pnpm`), and patch-package files under `patches/`. + * + * @param {string} installDir the install root + * @param {string} projectDir + * @returns {Finding[]} + */ +export function scanPatches(installDir, projectDir) { + /** @type {Finding[]} */ + const out = []; + /** @type {Set} */ + const seen = new Set(); + const describe = (/** @type {string | null} */ patchText) => { + if (!patchText) return "patch file not found"; + const files = [...patchText.matchAll(/^diff --git a\/(\S+)/gm)].map((m) => m[1]); + const covered = FORKED.filter((f) => files.some((p) => f.pattern.test(p))).map( + (f) => f.name, + ); + const touched = + files.length > 0 + ? `touches ${files.slice(0, 4).join(", ")}${files.length > 4 ? ", …" : ""}` + : ""; + return covered.length > 0 + ? `${touched}; ui-common ships its own ${covered.join(" and ")}, so this patch does not reach it: drop it unless the rest still matters` + : `${touched}; not covered by a ui-common fork`; + }; + const record = ( + /** @type {string} */ file, + /** @type {string} */ text, + /** @type {number} */ index, + /** @type {string} */ patchPath, + ) => { + const abs = join(installDir, patchPath); + if (seen.has(abs)) return; + seen.add(abs); + out.push( + at("patches", relPath(projectDir, file), text, index, describe(readText(abs))), + ); + }; + const yamlFile = join(installDir, "pnpm-workspace.yaml"); + const yaml = readText(yamlFile); + const block = yaml + ? /^patchedDependencies:[ \t]*(?:#.*)?\r?\n((?:[ \t]+.*(?:\r?\n|$)|[ \t]*\r?\n)*)/m.exec( + yaml, + ) + : null; + if (yaml && block) { + const offset = (block.index ?? 0) + block[0].length - block[1].length; + for (const m of block[1].matchAll( + /^[ \t]+["']?(@astryxdesign\/[^"':\s]+)["']?\s*:\s*["']?([^"'\s#]+)/gm, + )) + record(yamlFile, yaml, offset + (m.index ?? 0), m[2]); + } + const pkgFile = join(installDir, "package.json"); + const pkgText = readText(pkgFile); + if (pkgText) { + try { + const patched = JSON.parse(pkgText)?.pnpm?.patchedDependencies ?? {}; + for (const [key, path] of Object.entries(patched)) { + if (!key.startsWith("@astryxdesign/")) continue; + record(pkgFile, pkgText, Math.max(0, pkgText.indexOf(key)), String(path)); + } + } catch { + // not JSON: nothing to read + } + } + for (const dir of ["patches", ".patches"]) { + const full = join(installDir, dir); + if (!existsSync(full)) continue; + let entries = []; + try { + entries = readdirSync(full); + } catch { + continue; + } + for (const entry of entries) { + if (!/^@astryxdesign(\+|__)/.test(entry) || !entry.endsWith(".patch")) continue; + const abs = join(full, entry); + if (seen.has(abs)) continue; + seen.add(abs); + const text = readFileSync(abs, "utf8"); + out.push({ + category: "patches", + file: relPath(projectDir, abs), + line: 1, + text: entry, + detail: describe(text), + }); + } + } + return out; +} diff --git a/packages/cli/codemods/adopt/specifiers.mjs b/packages/cli/codemods/adopt/specifiers.mjs new file mode 100644 index 0000000..ff8002f --- /dev/null +++ b/packages/cli/codemods/adopt/specifiers.mjs @@ -0,0 +1,237 @@ +/** + * `@astryxdesign/*` module specifiers → their `@lablup/ui-common` mirror. + * + * Only specifiers in a module position are touched: `from "…"`, a bare + * `import "…"`, `import("…")` (and `typeof import("…")`), `require("…")`, + * `require.resolve("…")`, `import.meta.resolve("…")`, `vi.mock("…")` and its + * jest/vitest siblings, and CSS/Sass `@import` / `@use` / `@forward`. A + * package name in any other string (a list of package names in a script, a + * `declare module "…"` augmentation, which must name the real module) is + * left alone. `@astryxdesign/cli` is tooling, not UI, and stays. + */ +import { lineAt, lineTextAt } from "../../cli/project.mjs"; + +export const UIC = "@lablup/ui-common"; + +/** Astryx packages ui-common mirrors; the rest of `@astryxdesign/*` is not UI. */ +export const MIRRORED_PACKAGES = [ + "@astryxdesign/core", + "@astryxdesign/lab", + "@astryxdesign/theme-neutral", +]; + +const SPEC = String.raw`(@astryxdesign\/[A-Za-z0-9._-]+(?:\/[^'"\s)]*)?)`; + +/** + * Script positions, each its own pattern so that one inside another + * (`vi.importActual("…")`) is found too. Group 1: the + * lead-in; 2: the quote; 3: the specifier. + */ +export const SCRIPT_SPECIFIERS = [ + String.raw`\bfrom\s*`, + String.raw`\bimport\s*`, + String.raw`\bimport\s*\(\s*`, + String.raw`\brequire(?:\.resolve)?\s*\(\s*`, + String.raw`\bimport\.meta\.resolve\s*\(\s*`, + String.raw`\b(?:vi|jest)\.(?:mock|doMock|unmock|doUnmock|requireActual|importActual|importMock|requireMock)\s*(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\(\s*`, +].map((lead) => new RegExp(String.raw`(${lead})(['"])${SPEC}\2`, "g")); + +/** Stylesheet positions: `@import "…"`, `@import url(…)`, Sass `@use` / `@forward`. */ +export const STYLE_SPECIFIERS = [ + new RegExp( + String.raw`(@(?:import|use|forward)\s+(?:url\(\s*)?)(['"]?)${SPEC}\2`, + "g", + ), +]; + +/** + * Each specifier in a module position: where it starts and what it says. + * + * @param {string} text + * @param {"script" | "style"} kind + * @returns {Array<{spec: string, index: number}>} by position, no duplicates + */ +function locate(text, kind) { + /** @type {Map} */ + const found = new Map(); + for (const pattern of kind === "style" ? STYLE_SPECIFIERS : SCRIPT_SPECIFIERS) { + for (const m of text.matchAll(new RegExp(pattern.source, "g"))) { + found.set((m.index ?? 0) + m[1].length + m[2].length, m[3]); + } + } + return [...found.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([index, spec]) => ({ spec, index })); +} + +/** + * Whether a subpath exists in an `exports` map, wildcards included + * (`./locales/*.json` covers `./locales/ko-KR.json`). + * + * @param {Record} exportsMap + * @param {string} subpath "./X" + */ +export function exportsHas(exportsMap, subpath) { + if (subpath in exportsMap) return true; + for (const key of Object.keys(exportsMap)) { + const star = key.indexOf("*"); + if (star === -1) continue; + const head = key.slice(0, star); + const tail = key.slice(star + 1); + if ( + subpath.length > head.length + tail.length && + subpath.startsWith(head) && + subpath.endsWith(tail) && + !subpath.slice(head.length, subpath.length - tail.length).includes("/") + ) + return true; + } + return false; +} + +/** The core subpaths ui-common hides, with what replaces them (exports.exclude.json). */ +export const HIDDEN_SUBPATHS = { + Dialog: { replacement: "Modal", subpath: "Modal" }, + AlertDialog: { replacement: "AlertModal", subpath: "AlertModal" }, +}; + +/** + * Where an `@astryxdesign/*` specifier goes. + * + * @param {string} spec + * @param {Record} exportsMap the target ui-common's `exports` + * @returns {{to: string, row: string} | {left: string} | null} null: not ui-common's business + */ +export function mapSpecifier(spec, exportsMap) { + const m = /^(@astryxdesign\/[^/]+)(?:\/(.*))?$/.exec(spec); + if (!m) return null; + const [, pkg, sub = ""] = m; + if (!MIRRORED_PACKAGES.includes(pkg)) { + if (pkg === "@astryxdesign/cli") return null; + return { left: `${pkg} is not part of ${UIC}; nothing mirrors it.` }; + } + const via = (/** @type {string} */ target, /** @type {string} */ row) => + exportsHas(exportsMap, `./${target}`) + ? { to: `${UIC}/${target}`, row } + : { left: `${UIC} has no \`./${target}\` export to mirror ${spec}.` }; + if (sub === "package.json") + return { + left: `${spec} is Astryx's own manifest; read it from Astryx, or drop it.`, + }; + + if (pkg === "@astryxdesign/core") { + if (sub === "") return { to: UIC, row: "core (root)" }; + const head = sub.split("/")[0]; + const hidden = HIDDEN_SUBPATHS[/** @type {keyof typeof HIDDEN_SUBPATHS} */ (head)]; + if (hidden) { + return { + left: `${UIC} hides ${head}: import ${hidden.replacement} from ${UIC}/${hidden.subpath} (adopt renames named imports; this one it could not).`, + }; + } + if (/\.css$/.test(sub)) return via(sub, "core stylesheets"); + if (sub === "theme/tokens.stylex") return via(sub, "core/theme/tokens.stylex"); + if (/^locales\//.test(sub)) return via(sub, "core/locales/*.json"); + return via(sub, "core/"); + } + if (pkg === "@astryxdesign/lab") { + if (sub === "") return { to: `${UIC}/lab`, row: "lab" }; + return via(`lab/${sub}`, /\.css$/.test(sub) ? "lab/lab.css" : "lab/"); + } + // @astryxdesign/theme-neutral + if (sub === "") return via("theme/neutral", "theme-neutral"); + return via( + `theme/neutral/${sub}`, + /\.css$/.test(sub) ? "theme-neutral/theme.css" : "theme-neutral/", + ); +} + +/** + * Every `@astryxdesign/*` specifier in a module position, with its line. + * + * @param {string} text + * @param {"script" | "style"} kind + */ +export function findSpecifiers(text, kind) { + return locate(text, kind).map(({ spec, index }) => ({ + spec, + index, + line: lineAt(text, index), + text: lineTextAt(text, index), + })); +} + +/** + * Rewrite every mappable specifier in `text`. + * + * `keep(spec, index)` may veto one rewrite (a root import that still names a + * hidden Dialog export). + * + * @param {string} text + * @param {"script" | "style"} kind + * @param {Record} exportsMap + * @param {(spec: string, index: number) => string | null} [keep] a reason to keep it, or null + * @returns {{text: string, rows: Record, left: Array<{spec: string, line: number, text: string, reason: string}>}} + */ +export function rewriteSpecifiers(text, kind, exportsMap, keep) { + /** @type {Record} */ + const rows = {}; + /** @type {Array<{spec: string, line: number, text: string, reason: string}>} */ + const left = []; + /** @type {Array<{at: number, spec: string, to: string}>} */ + const edits = []; + for (const { spec, index: at } of locate(text, kind)) { + const mapped = mapSpecifier(spec, exportsMap); + if (!mapped) continue; + if ("left" in mapped) { + left.push({ + spec, + line: lineAt(text, at), + text: lineTextAt(text, at), + reason: mapped.left, + }); + continue; + } + const veto = keep?.(spec, at); + if (veto) { + left.push({ + spec, + line: lineAt(text, at), + text: lineTextAt(text, at), + reason: veto, + }); + continue; + } + rows[mapped.row] = (rows[mapped.row] ?? 0) + 1; + edits.push({ at, spec, to: mapped.to }); + } + let out = text; + for (const e of edits.reverse()) + out = out.slice(0, e.at) + e.to + out.slice(e.at + e.spec.length); + return { text: out, rows, left }; +} + +/** + * The names an import or export statement takes from a module, from the + * statement's text: `{ A, B as C, type D }` → A, B, D. + * + * @param {string} text the whole file + * @param {number} at index of the specifier + * @returns {string[] | null} null for a statement with no named clause + */ +export function importedNamesAt(text, at) { + const before = text.slice(Math.max(0, at - 4000), at); + const start = Math.max(before.lastIndexOf("import"), before.lastIndexOf("export")); + if (start === -1) return null; + const clause = /\{([^}]*)\}\s*from\s*['"]?$/.exec(before.slice(start)); + if (!clause) return null; + return clause[1] + .split(",") + .map((s) => + s + .replace(/\/\*[\s\S]*?\*\/|\/\/.*$/gm, "") + .trim() + .replace(/^type\s+/, ""), + ) + .filter(Boolean) + .map((s) => s.split(/\s+as\s+/)[0].trim()); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 57da650..400ed90 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "name": "@lablup/ui-common-cli", "version": "0.2.0-alpha.15", - "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", + "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block and skill, the upgrade codemods, adopt (from Astryx) and doctor", "license": "Apache-2.0", "author": "Lablup Inc.", "type": "module", @@ -29,6 +29,7 @@ "cli", "codemods", "migration", + "skill", "README.md", "NOTICE" ], diff --git a/packages/cli/skill/ui-common-adopt/SKILL.md b/packages/cli/skill/ui-common-adopt/SKILL.md new file mode 100644 index 0000000..310f17a --- /dev/null +++ b/packages/cli/skill/ui-common-adopt/SKILL.md @@ -0,0 +1,122 @@ +--- +name: ui-common-adopt +description: Move an app that imports Astryx directly (@astryxdesign/core, @astryxdesign/lab, @astryxdesign/theme-neutral) onto @lablup/ui-common in one guided pass, then prove it works. Use when asked to "migrate to ui-common", "adopt ui-common", "move onto @lablup/ui-common", "replace @astryxdesign with ui-common", "stop importing Astryx directly", or when `ui-common doctor` or `ui-common adopt --check` fails. +--- + +# Adopt @lablup/ui-common in an app that uses Astryx directly + +`ui-common adopt --from astryx` does the mechanical part and writes +`ui-common-adopt-report.md`; `ui-common doctor` says what is still wired wrong. +This skill is the order to run them in and the calls only a person (or you, +deliberately) can make. Do not re-derive what the commands print: read their +output and act on it. Background for every check: +`docs/adopting-from-astryx.md` in the ui-common repository (each doctor +failure prints its section). + +Run the CLI one-off until the project has it (`npx …` with npm): + +``` +pnpm dlx @lablup/ui-common-cli@next +``` + +After `pnpm install` it is `pnpm exec ui-common `. + +## 1. Preflight + +- The working tree is clean, on a branch of its own. Stop and ask if not. +- Record the baseline: the build, test and dev-server commands from + package.json, and whether they pass now. A failure that predates the + migration is not yours to fix; note it. +- Find where Storybook or the main screens can be screenshotted now, in light + and dark. Take the "before" screenshots now: after the change there is no + going back for them. + +## 2. Dry run, then apply + +``` +ui-common adopt --from astryx --dry-run --report /tmp/adopt-dry-run.md +``` + +Read the dry-run report before applying: + +- **Action required: "Astryx moves from X to Y".** The app is on an older + Astryx than ui-common pins. Run Astryx's codemods first, while the code still + imports `@astryxdesign/*`: `ui-common astryx upgrade --from X --path --apply`. + Commit that separately, then dry-run adopt again. +- **Files you did not expect to change** (vendored code, generated output + without an `@generated` header): rerun with `--ignore ` for each. + +Then apply (`ui-common adopt --from astryx`), run the package manager's +install, and commit the mechanical change on its own, report included, so the +hand edits that follow review separately. + +If install stops on `minimumReleaseAge`, add the exact versions the report's +note lists to `minimumReleaseAgeExclude`. Never lower the age policy itself. + +## 3. Doctor until it passes + +``` +ui-common doctor +``` + +Fix each FAIL with the printed fix and rerun. The judgement calls: + +- **single-core / lab-core**: find which package pulls the second + `@astryxdesign/core` (`pnpm why -r @astryxdesign/core`, `npm ls`). Fix the + pin or add the override; never silence it with `peerDependencyRules`. +- **vite-prebundle / vitest-inline**: add the snippet from the doc to the + config the check names. Do not restructure the config. +- **agents**: `ui-common agents --write `, + then delete the `` block. Keep project lines outside + the markers. +- A WARN is a decision, not a failure: fix it, or say in the PR why not. + +## 4. Work through the report + +Every row of `ui-common-adopt-report.md` gets a decision. The rules: + +| Report section | Decide | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@astryxdesign/*` imports left | Replace with the ui-common export the row names. With no replacement (`useImperativeDialog`), rewrite the call site to `Modal` state; keep the Astryx import only if the user agrees, and say so in the PR. | +| Dialog → Modal: refs | A ref used only for focus or measuring can stay (retype to `HTMLDivElement`). A ref used for `showModal()` / `close()` / `open` becomes `isOpen` / `onOpenChange`. | +| Local patches on Astryx | Covered by a ui-common fork: drop the patch. Otherwise check whether the bug still exists on the pinned Astryx; keep the patch (re-cut for that version) only if it does. | +| Overlays into document.body | Must it work while a modal is open (toasts, notifications, devtools)? Then add `MODAL_LIVE_ATTRIBUTE` to its root (and `refreshModalBackground()` if it can mount over an open modal). If not, leave it inert. | +| Global keyboard shortcuts | Should it fire over a modal? Almost never: guard it with `MODAL_OPEN_ATTRIBUTE`. | +| Escape handlers | Add `event.preventDefault()` where the handler consumed the key. | +| ASTRYX agent blocks / anchored tools | Replace the block (step 3); repoint each tool at `` or its own marker. | +| `astryx` CLI invocations | `astryx ` → `ui-common ` in scripts, CI and docs. Keep `astryx theme build` if the project builds a theme; add the `.css` stub only if that build then fails. | +| InternationalizationProvider | Merge `uiCommonMessages` for every supported locale; map app language codes to Astryx locale names; list unsupported ones explicitly. | + +Then add the guard that keeps it adopted: ban `@astryxdesign/*` in ESLint +(static and dynamic imports, see the doc) and run +`ui-common adopt --from astryx --check` and `ui-common doctor` in CI. + +## 5. Prove it + +All of these, and report each result: + +1. `ui-common adopt --from astryx --check` exits 0; `ui-common doctor` exits 0. +2. Type check, lint, the production build, and the test suite pass (or fail + only as they did at baseline). +3. Cold dev-server start: delete the optimizer cache (`node_modules/.vite`), + start the dev server, load the app in a browser, and confirm it renders + with no page errors. A warm start proves nothing here. +4. Screenshot the same Storybook stories or screens as in step 1, light and + dark, and diff them pixel by pixel. Explain every difference: expected ones + (ui-common's layer now beats Astryx's base styles where it wraps a + primitive) go in the PR; anything else is a bug to fix. +5. In a real browser, open a modal and check: toasts still dismiss, Escape + closes one layer per press, shortcuts stay quiet. + +## Done when + +- adopt `--check` and doctor are green, and both run in CI. +- Every report row is resolved or explained in the PR description. +- Build, tests and a cold dev start pass; the screenshot diff is explained. +- No `@astryxdesign/*` dependency remains except `@astryxdesign/core` at + ui-common's exact pin (and `@astryxdesign/lab`, `@astryxdesign/cli` where + used), and `@lablup/ui-common` equals `@lablup/ui-common-cli`. + +Hand back to a person when: an Astryx patch fixes something ui-common does not, +a screenshot difference has no explanation, or a call site depends on +`HTMLDialogElement` behaviour `Modal` does not have. diff --git a/packages/cli/test/adopt/adopt.test.ts b/packages/cli/test/adopt/adopt.test.ts new file mode 100644 index 0000000..8cd09f4 --- /dev/null +++ b/packages/cli/test/adopt/adopt.test.ts @@ -0,0 +1,427 @@ +/** + * `ui-common adopt --from astryx` against fixture projects that use Astryx + * directly: an app (Vite, StyleX, lab, theme-neutral, a local Astryx patch, + * Storybook, an ASTRYX agent block), a pnpm workspace with a library and an + * app, and an npm app on lab. + * + * Each fixture is copied to a temp dir, adopted, and compared file by file + * with `expected/` (report and package.json included). Regenerate after an + * intended change with `UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/adopt`, + * then read the diff. + */ +import { spawnSync } from "node:child_process"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import jscodeshift from "jscodeshift"; +import { afterEach, describe, expect, it } from "vitest"; + +import { runAdopt } from "../../cli/adopt.mjs"; +import { transformDialogs } from "../../codemods/adopt/dialog.mjs"; +import { + addUiCommonLayer, + ensureHtmlLayerStatement, + ensureLayerStatement, + LAYER_ORDER, + withUiCommonLayer, +} from "../../codemods/adopt/layers.mjs"; +import { + exportsHas, + findSpecifiers, + mapSpecifier, + rewriteSpecifiers, +} from "../../codemods/adopt/specifiers.mjs"; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURES = join(here, "fixtures"); +const UPDATE = process.env.UPDATE_FIXTURES === "1"; +const VERSION = "0.2.0"; +const bin = resolve(here, "../../bin/ui-common.mjs"); +const exportsMap = JSON.parse( + readFileSync(resolve(here, "../../../../package.json"), "utf8"), +).exports as Record; + +const temps: string[] = []; +afterEach(() => { + for (const dir of temps.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +function tree(dir: string): string[] { + const out: string[] = []; + const walk = (d: string) => { + for (const entry of readdirSync(d, { withFileTypes: true })) { + const full = join(d, entry.name); + if (entry.isDirectory()) walk(full); + else out.push(relative(dir, full)); + } + }; + walk(dir); + return out.sort(); +} + +function copyFixture(name: string) { + const dir = mkdtempSync(join(tmpdir(), `uic-adopt-${name}-`)); + temps.push(dir); + cpSync(join(FIXTURES, name, "input"), dir, { recursive: true }); + return dir; +} + +function snapshot(dir: string) { + return new Map(tree(dir).map((f) => [f, readFileSync(join(dir, f), "utf8")])); +} + +const quiet = { log: () => {}, warn: () => {} }; + +const cases = readdirSync(FIXTURES).filter((name) => + existsSync(join(FIXTURES, name, "input")), +); + +describe("ui-common adopt --from astryx", () => { + it.each(cases)("%s", async (name) => { + const dir = copyFixture(name); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + + const expectedDir = join(FIXTURES, name, "expected"); + if (UPDATE) { + rmSync(expectedDir, { recursive: true, force: true }); + for (const file of tree(dir)) { + mkdirSync(dirname(join(expectedDir, file)), { recursive: true }); + writeFileSync(join(expectedDir, file), readFileSync(join(dir, file), "utf8")); + } + } + expect(tree(dir)).toEqual(tree(expectedDir)); + for (const file of tree(dir)) { + expect(readFileSync(join(dir, file), "utf8"), file).toBe( + readFileSync(join(expectedDir, file), "utf8"), + ); + } + }); + + it.each(cases)("%s: a second run changes nothing", async (name) => { + const dir = copyFixture(name); + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const before = snapshot(dir); + const again = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(again.code).toBe(0); + expect(again.changed).toEqual([]); + for (const [file, text] of before) { + if (file.endsWith(".md") && file.includes("adopt-report")) continue; + expect(readFileSync(join(dir, file), "utf8"), file).toBe(text); + } + }); + + it("--check writes nothing, fails while work is left, and passes after adopting", async () => { + const dir = copyFixture("workspace"); + const before = snapshot(dir); + const lines: string[] = []; + const first = await runAdopt({ + cwd: dir, + version: VERSION, + check: true, + log: () => {}, + warn: (l: string) => lines.push(l), + }); + expect(first.code).toBe(1); + expect(snapshot(dir)).toEqual(before); + expect(lines.join("\n")).toContain("would change packages/web/src/main.tsx"); + + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const after = await runAdopt({ cwd: dir, version: VERSION, check: true, ...quiet }); + expect(after.code).toBe(0); + }); + + it("--check fails on an Astryx import adopt cannot move", async () => { + const dir = copyFixture("app"); + await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + const check = await runAdopt({ cwd: dir, version: VERSION, check: true, ...quiet }); + expect(check.code).toBe(1); + expect(check.changed).toEqual([]); + expect(check.left?.map((l) => `${l.file}:${l.line}`)).toEqual([ + "src/ConfirmDelete.tsx:2", + ]); + }); + + it("--dry-run writes nothing and prints the report", async () => { + const dir = copyFixture("app"); + const before = snapshot(dir); + const lines: string[] = []; + const result = await runAdopt({ + cwd: dir, + version: VERSION, + dryRun: true, + log: (l: string) => lines.push(l), + warn: () => {}, + }); + expect(result.code).toBe(0); + expect(snapshot(dir)).toEqual(before); + const printed = lines.join("\n"); + expect(printed).toContain("~ src/App.tsx"); + expect(printed).toContain("# ui-common adopt report"); + expect(printed).toContain("dry run: nothing was written"); + }); + + it("leaves a file it did not write as the report alone", async () => { + const dir = copyFixture("npm-lab"); + writeFileSync(join(dir, "ui-common-adopt-report.md"), "# Notes of mine\n"); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.code).toBe(2); + expect(readFileSync(join(dir, "ui-common-adopt-report.md"), "utf8")).toBe( + "# Notes of mine\n", + ); + }); + + it("raises an Astryx version move as an action, with the codemod command", async () => { + const dir = copyFixture("npm-lab"); + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + pkg.dependencies["@astryxdesign/core"] = "^0.5.4"; + writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2)); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(result.report).toContain("## Action required"); + expect(result.report).toContain("Astryx moves from 0.5.4 to 0.6.2"); + expect(result.report).toContain("ui-common astryx upgrade --from 0.5.4"); + }); + + it("--ignore and paths narrow what is rewritten", async () => { + const dir = copyFixture("app"); + await runAdopt({ + cwd: dir, + version: VERSION, + ignore: ["src/legacy.cjs"], + paths: ["src"], + ...quiet, + }); + expect(readFileSync(join(dir, "src/legacy.cjs"), "utf8")).toContain( + "@astryxdesign/core/Text", + ); + expect(readFileSync(join(dir, ".storybook/preview.ts"), "utf8")).toContain( + "@astryxdesign/core", + ); + expect(readFileSync(join(dir, "src/main.tsx"), "utf8")).not.toContain( + "@astryxdesign/", + ); + }); + + it("leaves generated files alone and says so", async () => { + const dir = copyFixture("npm-lab"); + writeFileSync( + join(dir, "src/built-theme.js"), + '/**\n * @generated by `astryx theme build`\n */\nimport { defineTheme } from "@astryxdesign/core/theme";\n', + ); + const result = await runAdopt({ cwd: dir, version: VERSION, ...quiet }); + expect(readFileSync(join(dir, "src/built-theme.js"), "utf8")).toContain( + "@astryxdesign/core/theme", + ); + expect(result.report).toContain("generated file"); + }); +}); + +describe("the command line", { timeout: 60_000 }, () => { + const run = (args: string[], cwd: string) => { + const r = spawnSync(process.execPath, [bin, ...args], { cwd, encoding: "utf8" }); + return { code: r.status, stdout: r.stdout, stderr: r.stderr }; + }; + + it("needs --from astryx", () => { + const dir = copyFixture("npm-lab"); + expect(run(["adopt"], dir).code).toBe(2); + expect(run(["adopt", "--from", "0.1"], dir).code).toBe(2); + expect(run(["adopt", "--from", "astryx", "--check", "--dry-run"], dir).code).toBe( + 2, + ); + expect(run(["adopt", "--bogus"], dir).code).toBe(2); + }); + + it("`upgrade --from astryx` is adopt", () => { + const dir = copyFixture("npm-lab"); + const check = run(["upgrade", "--from", "astryx", "--check"], dir); + expect(check.code).toBe(1); + expect(check.stderr).toContain("ui-common adopt --check"); + expect(run(["upgrade", "--from=astryx"], dir).code).toBe(0); + expect(run(["adopt", "--from", "astryx", "--check"], dir).code).toBe(0); + }); +}); + +describe("specifiers", () => { + it("maps every kind of Astryx specifier onto ui-common's mirror", () => { + const to = (spec: string) => { + const m = mapSpecifier(spec, exportsMap); + return m && "to" in m ? m.to : m && "left" in m ? "LEFT" : null; + }; + expect(to("@astryxdesign/core")).toBe("@lablup/ui-common"); + expect(to("@astryxdesign/core/Button")).toBe("@lablup/ui-common/Button"); + expect(to("@astryxdesign/core/Table/utils")).toBe("@lablup/ui-common/Table/utils"); + expect(to("@astryxdesign/core/reset.css")).toBe("@lablup/ui-common/reset.css"); + expect(to("@astryxdesign/core/astryx.css")).toBe("@lablup/ui-common/astryx.css"); + expect(to("@astryxdesign/core/theme/tokens.stylex")).toBe( + "@lablup/ui-common/theme/tokens.stylex", + ); + expect(to("@astryxdesign/core/locales/ko-KR.json")).toBe( + "@lablup/ui-common/locales/ko-KR.json", + ); + expect(to("@astryxdesign/lab")).toBe("@lablup/ui-common/lab"); + expect(to("@astryxdesign/lab/lab.css")).toBe("@lablup/ui-common/lab/lab.css"); + expect(to("@astryxdesign/theme-neutral")).toBe("@lablup/ui-common/theme/neutral"); + expect(to("@astryxdesign/theme-neutral/built")).toBe( + "@lablup/ui-common/theme/neutral/built", + ); + expect(to("@astryxdesign/theme-neutral/theme.css")).toBe( + "@lablup/ui-common/theme/neutral/theme.css", + ); + expect(to("@astryxdesign/core/Dialog")).toBe("LEFT"); + expect(to("@astryxdesign/core/AlertDialog")).toBe("LEFT"); + expect(to("@astryxdesign/core/docs.mjs")).toBe("LEFT"); + expect(to("@astryxdesign/core/package.json")).toBe("LEFT"); + expect(to("@astryxdesign/core/NoSuchThing")).toBe("LEFT"); + expect(to("@astryxdesign/cli/authoring")).toBeNull(); + expect(exportsHas({ "./locales/*.json": "" }, "./locales/a/b.json")).toBe(false); + }); + + it("touches module positions only", () => { + const source = [ + `import { Button } from '@astryxdesign/core';`, + `import "@astryxdesign/core/astryx.css";`, + `export * from "@astryxdesign/core/Text";`, + `const lazy = () => import( '@astryxdesign/core/Table' );`, + `type T = typeof import("@astryxdesign/core/Toast");`, + `const r = require("@astryxdesign/lab");`, + `const u = import.meta.resolve("@astryxdesign/core/astryx.css");`, + `vi.mock("@astryxdesign/core/Toast");`, + `jest.requireActual("@astryxdesign/core/Badge");`, + `const names = ["@astryxdesign/core", '@astryxdesign/lab'];`, + `declare module "@astryxdesign/core/Text" {}`, + `import { parseDoc } from "@astryxdesign/cli/authoring";`, + "// `@astryxdesign/core/src/Banner.tsx` in prose", + ].join("\n"); + const out = rewriteSpecifiers(source, "script", exportsMap).text.split("\n"); + expect(out[0]).toBe(`import { Button } from '@lablup/ui-common';`); + expect(out[1]).toBe(`import "@lablup/ui-common/astryx.css";`); + expect(out[2]).toBe(`export * from "@lablup/ui-common/Text";`); + expect(out[3]).toBe(`const lazy = () => import( '@lablup/ui-common/Table' );`); + expect(out[4]).toBe(`type T = typeof import("@lablup/ui-common/Toast");`); + expect(out[5]).toBe(`const r = require("@lablup/ui-common/lab");`); + expect(out[6]).toBe( + `const u = import.meta.resolve("@lablup/ui-common/astryx.css");`, + ); + expect(out[7]).toBe(`vi.mock("@lablup/ui-common/Toast");`); + expect(out[8]).toBe( + `jest.requireActual("@lablup/ui-common/Badge");`, + ); + expect(out.slice(9)).toEqual(source.split("\n").slice(9)); + expect(findSpecifiers(source, "script")).toHaveLength(11); + }); + + it("rewrites stylesheet imports, quoted or in url()", () => { + const css = `@import "@astryxdesign/core/reset.css";\n@import url(@astryxdesign/lab/lab.css) layer(astryx-base);\n@use '@astryxdesign/theme-neutral/theme.css';\n`; + expect(rewriteSpecifiers(css, "style", exportsMap).text).toBe( + `@import "@lablup/ui-common/reset.css";\n@import url(@lablup/ui-common/lab/lab.css) layer(astryx-base);\n@use '@lablup/ui-common/theme/neutral/theme.css';\n`, + ); + }); +}); + +describe("the layer-order statement", () => { + it("adds ui-common after astryx-theme, and nowhere else", () => { + expect( + withUiCommonLayer(["reset", "astryx-base", "astryx-theme", "components"]), + ).toEqual(["reset", "astryx-base", "astryx-theme", "ui-common", "components"]); + expect(withUiCommonLayer(["astryx-base", "app"])).toEqual([ + "astryx-base", + "ui-common", + "app", + ]); + expect(withUiCommonLayer(["a", "b"])).toBeNull(); + expect(withUiCommonLayer(["astryx-theme", "ui-common"])).toBeNull(); + const text = + "/* @layer a, astryx-theme; */\n@layer base, astryx-theme;\n@layer x, y;\n"; + expect(addUiCommonLayer(text).text).toBe( + "/* @layer a, astryx-theme; */\n@layer base, astryx-theme, ui-common;\n@layer x, y;\n", + ); + }); + + it("puts a statement first where Astryx's sheets load without one", () => { + expect( + ensureLayerStatement('@import "@lablup/ui-common/astryx.css";\n', "a.css"), + ).toBe(`${LAYER_ORDER}\n\n@import "@lablup/ui-common/astryx.css";\n`); + expect( + ensureLayerStatement( + `${LAYER_ORDER}\n@import "@lablup/ui-common/astryx.css";\n`, + "a.css", + ), + ).toBeNull(); + expect(ensureLayerStatement("a { color: red }\n", "a.css")).toBeNull(); + expect( + ensureLayerStatement( + '@use "sass:math";\n@import "@lablup/ui-common/reset.css";\n', + "a.scss", + ), + ).toBe( + `@use "sass:math";\n\n${LAYER_ORDER}\n\n@import "@lablup/ui-common/reset.css";\n`, + ); + }); + + it("gives index.html the statement as its first \n "), + ).toBeNull(); + }); +}); + +describe("Dialog → Modal", () => { + const j = jscodeshift.withParser("tsx"); + const run = (source: string) => transformDialogs({ source, path: "x.tsx" }, j); + + it("splits names with no ui-common counterpart off a root import", () => { + const out = run( + `import { Button, useImperativeDialog, AlertDialog } from "@astryxdesign/core";\n;\n`, + ); + expect(out.text).toContain( + `import { Button, AlertModal } from "@astryxdesign/core";`, + ); + expect(out.text).toContain( + `import { useImperativeDialog } from "@astryxdesign/core/Dialog";`, + ); + expect(out.text).toContain(""); + }); + + it("keeps a module's own names: shadowed locals, properties, its re-exports", () => { + const out = run( + [ + `import { Dialog } from "@astryxdesign/core/Dialog";`, + `const ui = { Dialog, other: theme.Dialog };`, + `function f(Dialog: string) { return Dialog; }`, + `export { Dialog };`, + `;`, + ].join("\n"), + ); + expect(out.text).toContain(`import { Modal } from "@lablup/ui-common/Modal";`); + expect(out.text).toContain("const ui = { Dialog: Modal, other: theme.Dialog };"); + expect(out.text).toContain("function f(Dialog: string) { return Dialog; }"); + expect(out.text).toContain("export { Modal as Dialog };"); + expect(out.text).toContain(";"); + }); + + it("leaves a module without Dialog imports alone", () => { + expect( + run(`import { Button } from "@astryxdesign/core";\nconst Dialog = 1;\n`).text, + ).toBeNull(); + expect(run(`import { Modal } from "@lablup/ui-common/Modal";\n`).text).toBeNull(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml b/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml new file mode 100644 index 0000000..0ddcb1a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: ci +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pnpm install --frozen-lockfile + - run: pnpm exec astryx upgrade --from 0.6.0 + - run: pnpm test diff --git a/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts b/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts new file mode 100644 index 0000000..435263a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/.storybook/preview.ts @@ -0,0 +1,4 @@ +import "@lablup/ui-common/reset.css"; +import "@lablup/ui-common/astryx.css"; + +export default {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md b/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md new file mode 100644 index 0000000..46f23f2 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/AGENTS.md @@ -0,0 +1,8 @@ +# Agents + +Project rules stay outside the managed block. + + +Astryx v0.6.2 · 90+ components +CLI: run every command as `pnpm exec astryx ` (shown below as `astryx ...`). + diff --git a/packages/cli/test/adopt/fixtures/app/expected/index.html b/packages/cli/test/adopt/fixtures/app/expected/index.html new file mode 100644 index 0000000..c960fc0 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/index.html @@ -0,0 +1,13 @@ + + + + + + + Metrics console + + +
+ + + diff --git a/packages/cli/test/adopt/fixtures/app/expected/package.json b/packages/cli/test/adopt/fixtures/app/expected/package.json new file mode 100644 index 0000000..80c228f --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/package.json @@ -0,0 +1,27 @@ +{ + "name": "metrics-console", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "theme": "astryx theme build src/brand.ts -o src/brand.css", + "agents": "astryx init --features agents" + }, + "dependencies": { + "@astryxdesign/core": "0.6.2", + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@lablup/ui-common": "0.2.0", + "@stylexjs/stylex": "0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@astryxdesign/cli": "0.6.2", + "@lablup/ui-common-cli": "0.2.0", + "@stylexjs/unplugin": "0.19.0", + "vite": "^8.0.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch b/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch new file mode 100644 index 0000000..c17af07 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/patches/@astryxdesign__core@0.6.2.patch @@ -0,0 +1,8 @@ +diff --git a/dist/ComplexSelector/ComplexSelector.js b/dist/ComplexSelector/ComplexSelector.js +index 1111111..2222222 100644 +--- a/dist/ComplexSelector/ComplexSelector.js ++++ b/dist/ComplexSelector/ComplexSelector.js +@@ -1,3 +1,3 @@ +-const clearable = false; ++const clearable = true; + export {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml b/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml new file mode 100644 index 0000000..52c00b1 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/pnpm-workspace.yaml @@ -0,0 +1,8 @@ +minimumReleaseAge: 1440 + +patchedDependencies: + "@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch + +allowBuilds: + "@astryxdesign/core": false + "@astryxdesign/cli": false diff --git a/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs b/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs new file mode 100644 index 0000000..5df3d95 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/scripts/append-rules.mjs @@ -0,0 +1,6 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +// Appends our rules right after the managed agent block. +const text = readFileSync("AGENTS.md", "utf8"); +const at = text.indexOf(""); +writeFileSync("AGENTS.md", `${text.slice(0, at)}\n\nOur rules.\n`); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx new file mode 100644 index 0000000..d8a8859 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/App.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@lablup/ui-common/Toast", async () => ({ + ...(await vi.importActual("@lablup/ui-common/Toast")), + ToastViewport: () => null, +})); + +import { App } from "./App"; + +describe("App", () => { + it("renders", () => { + render(); + expect(screen.getByText("Metrics")).toBeTruthy(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx new file mode 100644 index 0000000..fb3f1be --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/App.tsx @@ -0,0 +1,39 @@ +import { Button, Modal, Text, type ModalProps } from "@lablup/ui-common"; +import { ToastViewport } from "@lablup/ui-common/Toast"; +import { Drawer } from "@lablup/ui-common/lab"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { ConfirmDelete } from "./ConfirmDelete"; + +const purpose: ModalProps["purpose"] = "info"; + +export function App() { + const [open, setOpen] = useState(false); + const [drawer, setDrawer] = useState(false); + const dialogRef = useRef(null); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "k" && event.metaKey) setOpen(true); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( +
+ Metrics +
+ ); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx new file mode 100644 index 0000000..9afa943 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/ConfirmDelete.tsx @@ -0,0 +1,21 @@ +import { AlertModal, type AlertModalProps } from "@lablup/ui-common/AlertModal"; +import { useImperativeDialog } from "@astryxdesign/core/Dialog"; +import { useState } from "react"; + +type Props = Pick & { name: string }; + +export function ConfirmDelete({ name }: Props) { + const [open, setOpen] = useState(false); + const imperative = useImperativeDialog(); + void imperative; + return ( + { + if (event.key === "Escape") setOpen(false); + }} + /> + ); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts b/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts new file mode 100644 index 0000000..f47ab16 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/augment.ts @@ -0,0 +1,8 @@ +// Module augmentation names the real module, so it stays on Astryx. +declare module "@astryxdesign/core/Dialog" { + interface DialogVariantMap { + sheet: true; + } +} + +export {}; diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/index.css b/packages/cli/test/adopt/fixtures/app/expected/src/index.css new file mode 100644 index 0000000..1b0ecf4 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/index.css @@ -0,0 +1,13 @@ +/* App entry stylesheet. */ +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; +@import "@lablup/ui-common/astryx.css"; +@import "@lablup/ui-common/theme/neutral/theme.css"; +@import "@lablup/ui-common/lab/lab.css"; + +@layer components { + .console-shell { + min-height: 100vh; + } +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs b/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs new file mode 100644 index 0000000..16c7a81 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/legacy.cjs @@ -0,0 +1,4 @@ +const { Text } = require("@lablup/ui-common/Text"); +const cssPath = require.resolve("@lablup/ui-common/astryx.css"); + +module.exports = { Text, cssPath }; diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx b/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx new file mode 100644 index 0000000..69ade38 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/main.tsx @@ -0,0 +1,20 @@ +import "./index.css"; + +import { Theme } from "@lablup/ui-common"; +import { InternationalizationProvider } from "@lablup/ui-common/i18n"; +import astryxKo from "@lablup/ui-common/locales/ko-KR.json"; +import { neutralTheme } from "@lablup/ui-common/theme/neutral/built"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss b/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss new file mode 100644 index 0000000..e533718 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/panel.scss @@ -0,0 +1,9 @@ +@use "sass:math"; + +@layer reset, theme, base, astryx-base, astryx-theme, ui-common, components, utilities; + +@import "@lablup/ui-common/reset.css"; + +.panel { + padding: math.div(16px, 2); +} diff --git a/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts b/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts new file mode 100644 index 0000000..65c2984 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/src/tokens.ts @@ -0,0 +1,16 @@ +import * as stylex from "@stylexjs/stylex"; +import { colorVars, spacingVars } from "@lablup/ui-common/theme/tokens.stylex"; + +export type { ButtonProps } from "@lablup/ui-common/Button"; +export { Badge as StatusBadge } from "@lablup/ui-common/Badge"; + +export type ToastModule = typeof import("@lablup/ui-common/Toast"); + +export const loadTable = () => import("@lablup/ui-common/Table"); + +// The package names this file talks about are data, not imports. +export const ASTRYX_PACKAGES = ["@astryxdesign/core", "@astryxdesign/lab"]; + +export const styles = stylex.create({ + panel: { color: colorVars["--color-text"], padding: spacingVars["--spacing-4"] }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md b/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md new file mode 100644 index 0000000..d6643da --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/ui-common-adopt-report.md @@ -0,0 +1,141 @@ +# ui-common adopt report + +`ui-common adopt` astryx → @lablup/ui-common 0.2.0 (@lablup/ui-common-cli 0.2.0). + +Ran the codemods over 13 files under `.`; searched 17 files under the project root for manual-review findings. + +## Summary + +| | Count | +|---|---:| +| Files changed | 12 | +| package.json changed | yes | +| TODO markers left in code | 0 | +| Manual-review findings | 13 | + +## Steps + +- 0.2.0: Astryx (`@astryxdesign/*`) → @lablup/ui-common + +## Rewritten specifiers + +| Kind | Count | +|---|---:| +| core/ | 10 | +| core stylesheets | 6 | +| core (root) | 2 | +| core/locales/*.json | 1 | +| core/theme/tokens.stylex | 1 | +| lab | 1 | +| lab/lab.css | 1 | +| theme-neutral/ | 1 | +| theme-neutral/theme.css | 1 | + +## Changed files + +- `.storybook/preview.ts`: +2 −2, specifiers +- `index.html`: +1 −0, layer-order +- `package.json`: +3 −2, package-json +- `pnpm-workspace.yaml`: +4 −0, package-json +- `src/App.test.tsx`: +2 −2, specifiers +- `src/App.tsx`: +6 −6, dialog-to-modal, specifiers +- `src/ConfirmDelete.tsx`: +3 −3, dialog-to-modal +- `src/index.css`: +5 −5, specifiers, layer-order +- `src/legacy.cjs`: +2 −2, specifiers +- `src/main.tsx`: +4 −4, specifiers +- `src/panel.scss`: +3 −1, specifiers, layer-order +- `src/tokens.ts`: +5 −5, specifiers + +## package.json + +- added @lablup/ui-common 0.2.0 to dependencies: the one dependency that brings Astryx (core, theme-neutral) at the versions it pins. +- added @lablup/ui-common-cli 0.2.0 to devDependencies: the `ui-common` bin (`ui-common doctor`, `ui-common agents`, the Astryx CLI in @lablup/ui-common terms), released at the same version as @lablup/ui-common. +- dependencies["@astryxdesign/core"]: "^0.6.2" → "0.6.2", the version @lablup/ui-common 0.2.0 pins. +- removed @astryxdesign/theme-neutral from dependencies: @lablup/ui-common depends on it and mirrors it as @lablup/ui-common/theme/neutral. +- added allowBuilds declining @astryxdesign/core, @astryxdesign/cli to pnpm-workspace.yaml: their postinstall only prints an `astryx init` nudge, and pnpm 11 stops the install (ERR_PNPM_IGNORED_BUILDS) until each is allowed or declined. + +## Manual review + +### TODO markers (0) + +None. + +### `@astryxdesign/*` imports left in place (1) + +ui-common has no mirror for these, or adopt could not move them. Replace each with a ui-common export (the detail says which), or keep Astryx for it on purpose. `adopt --check` and `ui-common doctor` fail while any is left. + +| Where | What | Detail | +|---|---|---| +| `src/ConfirmDelete.tsx:2` | `import { useImperativeDialog } from "@astryxdesign/core/Dialog";` | @lablup/ui-common hides Dialog: import Modal from @lablup/ui-common/Modal (adopt renames named imports; this one it could not). | + +### Dialog → Modal: refs (2) + +A `Modal` / `AlertModal` ref reaches the element with `role="dialog"`, not an `HTMLDialogElement`. Check each use: retype it, and drive the modal with `isOpen` / `onOpenChange` instead of `showModal()` / `close()`. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:14` | `const dialogRef = useRef(null);` | This module moved from Dialog to Modal, whose ref is an HTMLDivElement (role="dialog"). Retype the ref and drop dialog-element calls. | +| `src/App.tsx:29` | `` | `ref` on Modal reaches the element with role="dialog", not an HTMLDialogElement: `showModal()`, `close()` and `open` are not there. Open and close it with `isOpen` / `onOpenChange`. | + +### Local patches on Astryx (1) + +A pnpm or patch-package patch on `@astryxdesign/*` applies only in this project, and ui-common's own copies of some components do not see it. Drop each patch ui-common's fork covers; for the rest decide whether the fix still matters on the Astryx version ui-common pins, and keep the patch at that version or drop it. + +| Where | What | Detail | +|---|---|---| +| `pnpm-workspace.yaml:4` | `"@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch` | touches dist/ComplexSelector/ComplexSelector.js; ui-common ships its own ComplexSelector, so this patch does not reach it: drop it unless the rest still matters | + +### Overlays rendered into document.body (1) + +While a `Modal` is open, every other child of `` is `inert`. A toast viewport, notification area or debug overlay that must stay usable over a modal needs `MODAL_LIVE_ATTRIBUTE` (from `@lablup/ui-common/Modal`) on its root, and `refreshModalBackground()` if it mounts while a modal is open. Check in a real browser: jsdom ignores `inert`. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:36` | `{createPortal(, document.body)}` | a portal into document.body; Astryx's toast viewport does not mark itself live | + +### Global keyboard shortcuts (1) + +A document- or window-level shortcut still fires while a modal is open. Skip it while an element with `MODAL_OPEN_ATTRIBUTE` (from `@lablup/ui-common/Modal`) that is not `inert` exists. + +| Where | What | Detail | +|---|---|---| +| `src/App.tsx:20` | `window.addEventListener("keydown", onKey);` | | + +### Escape handlers of your own (1) + +`Modal`, the lab `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too. + +| Where | What | Detail | +|---|---|---| +| `src/ConfirmDelete.tsx:17` | `if (event.key === "Escape") setOpen(false);` | no preventDefault() in this module | + +### Astryx agent blocks, and tools anchored on them (2) + +Replace each `` block with ui-common's: `ui-common agents --write ` adds the `UI-COMMON` block (keep your own lines outside its markers), then delete the ASTRYX block. A script or workflow that finds its place by the ASTRYX markers breaks when they go: point it at `` or at your own marker. + +| Where | What | Detail | +|---|---|---| +| `AGENTS.md:5` | `` | an Astryx agent block | +| `scripts/append-rules.mjs:5` | `const at = text.indexOf("");` | anchors on the ASTRYX markers | + +### `astryx` CLI invocations (3) + +`ui-common ` runs the Astryx CLI ui-common pins and rewrites its output to ui-common paths; `ui-common astryx ` runs it unrewritten. Switch each invocation, or keep `@astryxdesign/cli` as a devDependency at the version `@lablup/ui-common-cli` pins. `astryx theme build` keeps working either way (a recipe that imports ui-common components needs the `.css` stub in docs/adopting-from-astryx.md). + +| Where | What | Detail | +|---|---|---| +| `.github/workflows/ci.yml:9` | `- run: pnpm exec astryx upgrade --from 0.6.0` | | +| `package.json:9` | `"theme": "astryx theme build src/brand.ts -o src/brand.css",` | | +| `package.json:10` | `"agents": "astryx init --features agents"` | | + +### InternationalizationProvider without ui-common's strings (1) + +ui-common's built-in strings resolve through Astryx's `InternationalizationProvider`. Merge `uiCommonMessages` (from `@lablup/ui-common/i18n-catalog`) into its `messages` with `mergeMessages`, or ui-common's components stay in English. Map your language codes to Astryx locale names (`ko` → `ko-KR`). + +| Where | What | Detail | +|---|---|---| +| `src/main.tsx:14` | `` | | + +## Notes + +- pnpm-workspace.yaml sets `minimumReleaseAge`: a @lablup/ui-common release younger than that will not install. Until it ages out, list `@lablup/ui-common@0.2.0`, `@lablup/ui-common-cli@0.2.0`, `@astryxdesign/lab@0.6.2-canary.c9fb1ad` under `minimumReleaseAgeExclude` (a lab canary never ages out). diff --git a/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts b/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts new file mode 100644 index 0000000..546a733 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/expected/vite.config.ts @@ -0,0 +1,10 @@ +import stylex from "@stylexjs/unplugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [stylex({ useCSSLayers: false }), react()], + test: { + environment: "jsdom", + }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml b/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml new file mode 100644 index 0000000..0ddcb1a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/.github/workflows/ci.yml @@ -0,0 +1,10 @@ +name: ci +on: [push] +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - run: pnpm install --frozen-lockfile + - run: pnpm exec astryx upgrade --from 0.6.0 + - run: pnpm test diff --git a/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts b/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts new file mode 100644 index 0000000..9583804 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/.storybook/preview.ts @@ -0,0 +1,4 @@ +import "@astryxdesign/core/reset.css"; +import "@astryxdesign/core/astryx.css"; + +export default {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/AGENTS.md b/packages/cli/test/adopt/fixtures/app/input/AGENTS.md new file mode 100644 index 0000000..46f23f2 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/AGENTS.md @@ -0,0 +1,8 @@ +# Agents + +Project rules stay outside the managed block. + + +Astryx v0.6.2 · 90+ components +CLI: run every command as `pnpm exec astryx ` (shown below as `astryx ...`). + diff --git a/packages/cli/test/adopt/fixtures/app/input/index.html b/packages/cli/test/adopt/fixtures/app/input/index.html new file mode 100644 index 0000000..8d8aa42 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/index.html @@ -0,0 +1,12 @@ + + + + + + Metrics console + + +
+ + + diff --git a/packages/cli/test/adopt/fixtures/app/input/package.json b/packages/cli/test/adopt/fixtures/app/input/package.json new file mode 100644 index 0000000..50c6ec6 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/package.json @@ -0,0 +1,26 @@ +{ + "name": "metrics-console", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest run", + "theme": "astryx theme build src/brand.ts -o src/brand.css", + "agents": "astryx init --features agents" + }, + "dependencies": { + "@astryxdesign/core": "^0.6.2", + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@astryxdesign/theme-neutral": "0.6.2", + "@stylexjs/stylex": "0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@astryxdesign/cli": "0.6.2", + "@stylexjs/unplugin": "0.19.0", + "vite": "^8.0.0", + "vitest": "^4.1.0" + } +} diff --git a/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch b/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch new file mode 100644 index 0000000..c17af07 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/patches/@astryxdesign__core@0.6.2.patch @@ -0,0 +1,8 @@ +diff --git a/dist/ComplexSelector/ComplexSelector.js b/dist/ComplexSelector/ComplexSelector.js +index 1111111..2222222 100644 +--- a/dist/ComplexSelector/ComplexSelector.js ++++ b/dist/ComplexSelector/ComplexSelector.js +@@ -1,3 +1,3 @@ +-const clearable = false; ++const clearable = true; + export {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml b/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml new file mode 100644 index 0000000..8b074e5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +minimumReleaseAge: 1440 + +patchedDependencies: + "@astryxdesign/core@0.6.2": patches/@astryxdesign__core@0.6.2.patch diff --git a/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs b/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs new file mode 100644 index 0000000..5df3d95 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/scripts/append-rules.mjs @@ -0,0 +1,6 @@ +import { readFileSync, writeFileSync } from "node:fs"; + +// Appends our rules right after the managed agent block. +const text = readFileSync("AGENTS.md", "utf8"); +const at = text.indexOf(""); +writeFileSync("AGENTS.md", `${text.slice(0, at)}\n\nOur rules.\n`); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx b/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx new file mode 100644 index 0000000..fbb5bd1 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/App.test.tsx @@ -0,0 +1,16 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@astryxdesign/core/Toast", async () => ({ + ...(await vi.importActual("@astryxdesign/core/Toast")), + ToastViewport: () => null, +})); + +import { App } from "./App"; + +describe("App", () => { + it("renders", () => { + render(); + expect(screen.getByText("Metrics")).toBeTruthy(); + }); +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/App.tsx b/packages/cli/test/adopt/fixtures/app/input/src/App.tsx new file mode 100644 index 0000000..090e576 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/App.tsx @@ -0,0 +1,39 @@ +import { Button, Dialog, Text, type DialogProps } from "@astryxdesign/core"; +import { ToastViewport } from "@astryxdesign/core/Toast"; +import { Drawer } from "@astryxdesign/lab"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { ConfirmDelete } from "./ConfirmDelete"; + +const purpose: DialogProps["purpose"] = "info"; + +export function App() { + const [open, setOpen] = useState(false); + const [drawer, setDrawer] = useState(false); + const dialogRef = useRef(null); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "k" && event.metaKey) setOpen(true); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + return ( +
+ Metrics +
+ ); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx b/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx new file mode 100644 index 0000000..095bc80 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/ConfirmDelete.tsx @@ -0,0 +1,21 @@ +import { AlertDialog, type AlertDialogProps } from "@astryxdesign/core/AlertDialog"; +import { useImperativeDialog } from "@astryxdesign/core/Dialog"; +import { useState } from "react"; + +type Props = Pick & { name: string }; + +export function ConfirmDelete({ name }: Props) { + const [open, setOpen] = useState(false); + const imperative = useImperativeDialog(); + void imperative; + return ( + { + if (event.key === "Escape") setOpen(false); + }} + /> + ); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/augment.ts b/packages/cli/test/adopt/fixtures/app/input/src/augment.ts new file mode 100644 index 0000000..f47ab16 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/augment.ts @@ -0,0 +1,8 @@ +// Module augmentation names the real module, so it stays on Astryx. +declare module "@astryxdesign/core/Dialog" { + interface DialogVariantMap { + sheet: true; + } +} + +export {}; diff --git a/packages/cli/test/adopt/fixtures/app/input/src/index.css b/packages/cli/test/adopt/fixtures/app/input/src/index.css new file mode 100644 index 0000000..b16710a --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/index.css @@ -0,0 +1,13 @@ +/* App entry stylesheet. */ +@layer reset, theme, base, astryx-base, astryx-theme, components, utilities; + +@import "@astryxdesign/core/reset.css"; +@import "@astryxdesign/core/astryx.css"; +@import "@astryxdesign/theme-neutral/theme.css"; +@import "@astryxdesign/lab/lab.css"; + +@layer components { + .console-shell { + min-height: 100vh; + } +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs b/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs new file mode 100644 index 0000000..4b947b5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/legacy.cjs @@ -0,0 +1,4 @@ +const { Text } = require("@astryxdesign/core/Text"); +const cssPath = require.resolve("@astryxdesign/core/astryx.css"); + +module.exports = { Text, cssPath }; diff --git a/packages/cli/test/adopt/fixtures/app/input/src/main.tsx b/packages/cli/test/adopt/fixtures/app/input/src/main.tsx new file mode 100644 index 0000000..3dac895 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/main.tsx @@ -0,0 +1,20 @@ +import "./index.css"; + +import { Theme } from "@astryxdesign/core"; +import { InternationalizationProvider } from "@astryxdesign/core/i18n"; +import astryxKo from "@astryxdesign/core/locales/ko-KR.json"; +import { neutralTheme } from "@astryxdesign/theme-neutral/built"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/packages/cli/test/adopt/fixtures/app/input/src/panel.scss b/packages/cli/test/adopt/fixtures/app/input/src/panel.scss new file mode 100644 index 0000000..a4ecc7f --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/panel.scss @@ -0,0 +1,7 @@ +@use "sass:math"; + +@import "@astryxdesign/core/reset.css"; + +.panel { + padding: math.div(16px, 2); +} diff --git a/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts b/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts new file mode 100644 index 0000000..b5aada5 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/src/tokens.ts @@ -0,0 +1,16 @@ +import * as stylex from "@stylexjs/stylex"; +import { colorVars, spacingVars } from "@astryxdesign/core/theme/tokens.stylex"; + +export type { ButtonProps } from "@astryxdesign/core/Button"; +export { Badge as StatusBadge } from "@astryxdesign/core/Badge"; + +export type ToastModule = typeof import("@astryxdesign/core/Toast"); + +export const loadTable = () => import("@astryxdesign/core/Table"); + +// The package names this file talks about are data, not imports. +export const ASTRYX_PACKAGES = ["@astryxdesign/core", "@astryxdesign/lab"]; + +export const styles = stylex.create({ + panel: { color: colorVars["--color-text"], padding: spacingVars["--spacing-4"] }, +}); diff --git a/packages/cli/test/adopt/fixtures/app/input/vite.config.ts b/packages/cli/test/adopt/fixtures/app/input/vite.config.ts new file mode 100644 index 0000000..546a733 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/app/input/vite.config.ts @@ -0,0 +1,10 @@ +import stylex from "@stylexjs/unplugin/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [stylex({ useCSSLayers: false }), react()], + test: { + environment: "jsdom", + }, +}); diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json b/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json new file mode 100644 index 0000000..92f247d --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "drawer-demo", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json b/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json new file mode 100644 index 0000000..7d45058 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/package.json @@ -0,0 +1,19 @@ +{ + "name": "drawer-demo", + "private": true, + "dependencies": { + "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", + "@lablup/ui-common": "0.2.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0" + }, + "overrides": { + "@astryxdesign/lab": { + "@astryxdesign/core": "0.6.2" + } + } +} diff --git a/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx b/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx new file mode 100644 index 0000000..cc3d834 --- /dev/null +++ b/packages/cli/test/adopt/fixtures/npm-lab/expected/src/Filters.jsx @@ -0,0 +1,14 @@ +import { Button } from '@lablup/ui-common/Button'; +import { Drawer, Tour } from '@lablup/ui-common/lab'; +import { useState } from 'react'; + +export function Filters() { + const [open, setOpen] = useState(false); + return ( + <> +