From 88d749c5a0efe1eeb1cf780b4d6e043e8cb455c3 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:06:17 +0000 Subject: [PATCH 01/25] refactor(cli): move the CLI into the @lablup/ui-common-cli workspace package @lablup/ui-common carried the ui-common bin, its codemods and the migration map, and with them @astryxdesign/cli, jscodeshift, postcss and postcss-selector-parser as production dependencies: about 168 MB of a dev-time toolchain in every consumer's production install. The CLI now lives in packages/cli as @lablup/ui-common-cli, the way Astryx splits @astryxdesign/core from @astryxdesign/cli: one repository, two packages at one version. The CLI takes @lablup/ui-common as a peer (workspace:*, published as the exact version). The repository root stays the library; it keeps @astryxdesign/cli as a devDependency for its own theme and integration scripts. cli/paths.mjs no longer treats one directory as both the CLI and the library it reads. The CLI root resolves the Astryx CLI and jscodeshift; the ui-common root, found from the project first and then from the CLI's peer, supplies the version, the export lists and the Astryx packages it pins. upgrade reads its target's pins from the CLI's peer, since the project may still be on the version it upgrades from. sync-astryx finds the repository from anywhere under it and moves the Astryx CLI pin in both package.json files. The CLI tests move beside the package and still run under the root vitest run. --- .prettierignore | 2 +- cli/paths.mjs | 180 ----------- eslint.config.js | 6 +- package.json | 14 +- packages/cli/LICENSE | 202 ++++++++++++ packages/cli/NOTICE | 8 + packages/cli/README.md | 23 ++ {bin => packages/cli/bin}/ui-common.mjs | 0 {cli => packages/cli/cli}/agents.mjs | 4 +- {cli => packages/cli/cli}/diff.mjs | 0 {cli => packages/cli/cli}/lab-peer.mjs | 0 {cli => packages/cli/cli}/main.mjs | 31 +- {cli => packages/cli/cli}/passthrough.mjs | 0 packages/cli/cli/paths.mjs | 290 ++++++++++++++++++ {cli => packages/cli/cli}/report.mjs | 2 +- {cli => packages/cli/cli}/rewrite.mjs | 0 {cli => packages/cli/cli}/semver.mjs | 0 {cli => packages/cli/cli}/shadow.mjs | 0 {cli => packages/cli/cli}/sync-astryx.mjs | 92 ++++-- {cli => packages/cli/cli}/upgrade.mjs | 9 +- .../cli/codemods}/0.2/components.mjs | 0 .../cli/codemods}/0.2/elements.mjs | 0 .../cli/codemods}/0.2/index.mjs | 0 .../cli/codemods}/0.2/legacy-classes.json | 0 .../cli/codemods}/0.2/map.mjs | 16 +- .../cli/codemods}/0.2/package-json.mjs | 6 +- .../cli/codemods}/0.2/scan.mjs | 8 +- .../cli/codemods}/0.2/stylesheets.mjs | 0 .../cli/codemods}/lib/jsx.mjs | 0 .../cli/codemods}/lib/todo.mjs | 0 .../cli/codemods}/registry.mjs | 0 .../cli/codemods}/upstream.mjs | 0 .../cli/migration}/0.1-to-0.2.json | 0 packages/cli/package.json | 50 +++ .../cli/scripts}/extract-legacy-classes.mjs | 3 +- {test => packages/cli/test}/cli/cli.test.ts | 10 +- .../cli/test}/cli/lab-peer.test.ts | 7 +- .../cli/test}/upgrade/codemods.test.ts | 0 .../fixtures/adapter/expected/package.json | 0 .../src/design-system/common-adapters.tsx | 0 .../src/design-system/common-components.css | 0 .../fixtures/adapter/expected/src/index.scss | 0 .../fixtures/adapter/expected/src/main.tsx | 0 .../adapter/expected/src/ui-common-entry.css | 0 .../expected/ui-common-upgrade-report.md | 2 +- .../fixtures/adapter/input/package.json | 0 .../src/design-system/common-adapters.tsx | 0 .../src/design-system/common-components.css | 0 .../fixtures/adapter/input/src/index.scss | 0 .../fixtures/adapter/input/src/main.tsx | 0 .../fixtures/css-entry/expected/package.json | 0 .../fixtures/css-entry/expected/src/index.css | 0 .../fixtures/css-entry/expected/src/print.css | 0 .../expected/ui-common-upgrade-report.md | 2 +- .../fixtures/css-entry/input/package.json | 0 .../fixtures/css-entry/input/src/index.css | 0 .../fixtures/css-entry/input/src/print.css | 0 .../fixtures/library/expected/package.json | 0 .../expected/src/chat/ReasoningBlock.tsx | 0 .../expected/ui-common-upgrade-report.md | 2 +- .../fixtures/library/input/package.json | 0 .../library/input/src/chat/ReasoningBlock.tsx | 0 .../root-barrel/expected/package.json | 0 .../expected/src/chat/InputPopup.tsx | 0 .../expected/src/pages/ModelsPage.test.tsx | 0 .../expected/src/pages/ModelsPage.tsx | 0 .../expected/src/themes/violet.css | 0 .../expected/ui-common-upgrade-report.md | 2 +- .../fixtures/root-barrel/input/package.json | 0 .../root-barrel/input/src/chat/InputPopup.tsx | 0 .../input/src/pages/ModelsPage.test.tsx | 0 .../input/src/pages/ModelsPage.tsx | 0 .../root-barrel/input/src/themes/violet.css | 0 .../subpath-barrel/expected/package.json | 0 .../components/common/DataTableWrapper.tsx | 0 .../src/components/common/Select.test.tsx | 0 .../expected/src/components/common/index.ts | 0 .../src/reports/ReportPreviewFrame.tsx | 0 .../expected/src/styles/families.css | 0 .../expected/src/styles/theme.ts | 0 .../expected/src/styles/ui-common-entry.css | 0 .../expected/ui-common-upgrade-report.md | 2 +- .../subpath-barrel/input/package.json | 0 .../components/common/DataTableWrapper.tsx | 0 .../src/components/common/Select.test.tsx | 0 .../input/src/components/common/index.ts | 0 .../input/src/reports/ReportPreviewFrame.tsx | 0 .../input/src/styles/families.css | 0 .../subpath-barrel/input/src/styles/theme.ts | 0 .../cli/test}/upgrade/stylesheets.test.ts | 0 .../cli/test}/upgrade/upgrade.test.ts | 10 +- pnpm-lock.yaml | 34 +- pnpm-workspace.yaml | 6 + src/migrationMap.test.ts | 5 +- vite.config.ts | 6 +- 95 files changed, 755 insertions(+), 279 deletions(-) delete mode 100644 cli/paths.mjs create mode 100644 packages/cli/LICENSE create mode 100644 packages/cli/NOTICE create mode 100644 packages/cli/README.md rename {bin => packages/cli/bin}/ui-common.mjs (100%) rename {cli => packages/cli/cli}/agents.mjs (99%) rename {cli => packages/cli/cli}/diff.mjs (100%) rename {cli => packages/cli/cli}/lab-peer.mjs (100%) rename {cli => packages/cli/cli}/main.mjs (68%) rename {cli => packages/cli/cli}/passthrough.mjs (100%) create mode 100644 packages/cli/cli/paths.mjs rename {cli => packages/cli/cli}/report.mjs (98%) rename {cli => packages/cli/cli}/rewrite.mjs (100%) rename {cli => packages/cli/cli}/semver.mjs (100%) rename {cli => packages/cli/cli}/shadow.mjs (100%) rename {cli => packages/cli/cli}/sync-astryx.mjs (81%) rename {cli => packages/cli/cli}/upgrade.mjs (98%) rename {codemods => packages/cli/codemods}/0.2/components.mjs (100%) rename {codemods => packages/cli/codemods}/0.2/elements.mjs (100%) rename {codemods => packages/cli/codemods}/0.2/index.mjs (100%) rename {codemods => packages/cli/codemods}/0.2/legacy-classes.json (100%) rename {codemods => packages/cli/codemods}/0.2/map.mjs (94%) rename {codemods => packages/cli/codemods}/0.2/package-json.mjs (96%) rename {codemods => packages/cli/codemods}/0.2/scan.mjs (97%) rename {codemods => packages/cli/codemods}/0.2/stylesheets.mjs (100%) rename {codemods => packages/cli/codemods}/lib/jsx.mjs (100%) rename {codemods => packages/cli/codemods}/lib/todo.mjs (100%) rename {codemods => packages/cli/codemods}/registry.mjs (100%) rename {codemods => packages/cli/codemods}/upstream.mjs (100%) rename {migration => packages/cli/migration}/0.1-to-0.2.json (100%) create mode 100644 packages/cli/package.json rename {scripts => packages/cli/scripts}/extract-legacy-classes.mjs (94%) rename {test => packages/cli/test}/cli/cli.test.ts (97%) rename {test => packages/cli/test}/cli/lab-peer.test.ts (96%) rename {test => packages/cli/test}/upgrade/codemods.test.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/design-system/common-components.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/index.scss (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/main.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/src/ui-common-entry.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md (98%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/design-system/common-components.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/index.scss (100%) rename {test => packages/cli/test}/upgrade/fixtures/adapter/input/src/main.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/src/index.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/src/print.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md (95%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/src/index.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/css-entry/input/src/print.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/expected/ui-common-upgrade-report.md (96%) rename {test => packages/cli/test}/upgrade/fixtures/library/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/src/themes/violet.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md (98%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/root-barrel/input/src/themes/violet.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md (98%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/package.json (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/styles/families.css (100%) rename {test => packages/cli/test}/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts (100%) rename {test => packages/cli/test}/upgrade/stylesheets.test.ts (100%) rename {test => packages/cli/test}/upgrade/upgrade.test.ts (97%) diff --git a/.prettierignore b/.prettierignore index 4876889..237f7a2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,4 +3,4 @@ coverage/ pnpm-lock.yaml LICENSE src/theme/*/built/ -test/upgrade/fixtures/ +packages/cli/test/upgrade/fixtures/ diff --git a/cli/paths.mjs b/cli/paths.mjs deleted file mode 100644 index 1554fb2..0000000 --- a/cli/paths.mjs +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Where things live, resolved from this package rather than from the working - * directory. A consumer depends on @lablup/ui-common only, so under pnpm's - * isolated layout @astryxdesign/* is reachable from here and not from the - * consumer's own directory. - */ -import { existsSync, readFileSync } from "node:fs"; -import { createRequire } from "node:module"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -/** Root of the installed @lablup/ui-common package. */ -export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - -const require = createRequire(join(PACKAGE_ROOT, "package.json")); - -/** @param {string} file */ -export function readJson(file) { - return JSON.parse(readFileSync(file, "utf8")); -} - -/** @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record, devDependencies?: Record}} */ -export function ownPackageJson() { - return readJson(join(PACKAGE_ROOT, "package.json")); -} - -/** Walk up from a resolved file to the directory holding its package.json. */ -function packageDirOf(file, name) { - let dir = dirname(file); - for (;;) { - const pkg = join(dir, "package.json"); - if (existsSync(pkg)) { - try { - if (readJson(pkg).name === name) return dir; - } catch { - // keep walking - } - } - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * The directory of a dependency of ui-common, or null. Resolution goes through - * the package's main entry, because not every Astryx package exports its - * package.json. - * - * @param {string} name - */ -export function dependencyDir(name) { - try { - return packageDirOf(require.resolve(name), name); - } catch { - return null; - } -} - -/** Root of the exact-pinned @astryxdesign/cli. */ -export function astryxCliRoot() { - const dir = dependencyDir("@astryxdesign/cli"); - if (!dir) { - throw new Error( - "@astryxdesign/cli is not installed next to @lablup/ui-common. Reinstall @lablup/ui-common.", - ); - } - return dir; -} - -/** The `astryx` bin script of the pinned CLI. */ -export function astryxBin() { - const root = astryxCliRoot(); - const bin = readJson(join(root, "package.json")).bin?.astryx; - return join(root, bin ?? "clients/cli/bin/astryx.mjs"); -} - -/** - * Import a module file from inside the pinned Astryx CLI. These are internals, - * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the - * tests that use them on every bump. - * - * @param {string} relative path under the CLI root - */ -export async function importAstryxInternal(relative) { - const file = join(astryxCliRoot(), relative); - return import(pathToFileURL(file).href); -} - -/** Version of an installed dependency of ui-common, or null. */ -export function dependencyVersion(name) { - const dir = dependencyDir(name); - return dir ? readJson(join(dir, "package.json")).version : null; -} - -/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ -export function excludedExports() { - const file = join(PACKAGE_ROOT, "exports.exclude.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( - readJson(file) - ); -} - -/** `exports.customs.json`: ui-common's own exports. */ -export function customExports() { - const file = join(PACKAGE_ROOT, "exports.customs.json"); - if (!existsSync(file)) return []; - return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( - readJson(file) - ); -} - -/** - * ui-common's own copies of Astryx components (src/forks/): same names and - * import paths as Astryx's, which exports.exclude.json hides. `names` are the - * names a reader may meet it by; `from` is where a consumer imports it. - * - * @returns {Array<{name: string, from: string, names: string[], reason: string}>} - */ -export function forkedExports() { - const exclusions = excludedExports(); - return customExports() - .filter((c) => c.fork !== undefined) - .map((c) => { - const exclusion = exclusions.find((e) => e.replacedBy === c.name); - return { - name: c.name, - from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, - names: exclusion?.exports ?? [c.name], - reason: exclusion?.reason ?? "", - }; - }); -} - -/** - * The exclusions that hide an Astryx name behind a different one. A fork's - * exclusion is not one: the name stays, now ui-common's. - */ -export function hiddenExports() { - const forks = new Set(forkedExports().map((f) => f.name)); - return excludedExports().filter( - (e) => e.replacedBy === null || !forks.has(e.replacedBy), - ); -} - -/** - * The nearest directory at or above `start` holding a package.json. - * - * @param {string} start - */ -export function findProjectDir(start) { - let dir = resolve(start); - for (;;) { - if (existsSync(join(dir, "package.json"))) return dir; - const parent = dirname(dir); - if (parent === dir) return null; - dir = parent; - } -} - -/** - * How this project runs a locally installed bin, from its lockfile. - * - * @param {string} projectDir - */ -export function binInvocation(projectDir, bin = "ui-common") { - let dir = resolve(projectDir); - for (;;) { - if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; - if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; - if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { - return `bunx ${bin}`; - } - if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; - const parent = dirname(dir); - if (parent === dir) return `npx ${bin}`; - dir = parent; - } -} diff --git a/eslint.config.js b/eslint.config.js index f748fa6..58a4916 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,7 +13,7 @@ export default tseslint.config( // `astryx theme build` output, committed as generated. "src/theme/*/built/**", // Consumer code the upgrade codemods run on, and their expected output. - "test/upgrade/fixtures/**", + "packages/cli/test/upgrade/fixtures/**", ], }, js.configs.recommended, @@ -75,9 +75,7 @@ export default tseslint.config( { files: [ "scripts/**/*.mjs", - "bin/**/*.mjs", - "cli/**/*.mjs", - "codemods/**/*.mjs", + "packages/cli/{bin,cli,codemods,scripts}/**/*.mjs", "*.config.{js,ts}", ], languageOptions: { globals: globals.node }, diff --git a/package.json b/package.json index be81bd3..e8768fd 100644 --- a/package.json +++ b/package.json @@ -24,18 +24,11 @@ "engines": { "node": ">=20" }, - "bin": { - "ui-common": "./bin/ui-common.mjs" - }, "packageManager": "pnpm@11.19.0", "files": [ "dist", "astryx", "astryx.integration.mjs", - "bin", - "cli", - "codemods", - "migration", "exports.exclude.json", "exports.customs.json", "NOTICE" @@ -589,7 +582,6 @@ "verify": "pnpm run typecheck && pnpm run lint && pnpm run format:check && pnpm run check:boundary && pnpm run theme:check && pnpm run test && pnpm run build && pnpm run check:pack && pnpm run check:integration" }, "dependencies": { - "@astryxdesign/cli": "0.6.2", "@astryxdesign/core": "0.6.2", "@astryxdesign/theme-neutral": "0.6.2", "@dnd-kit/core": "6.3.1", @@ -597,10 +589,7 @@ "@dnd-kit/sortable": "10.0.0", "@dnd-kit/utilities": "3.2.2", "intl-messageformat": "^11.2.9", - "jscodeshift": "^17.4.0", - "lucide-react": "^1.18.0", - "postcss": "^8.5.25", - "postcss-selector-parser": "^7.1.6" + "lucide-react": "^1.18.0" }, "peerDependencies": { "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", @@ -614,6 +603,7 @@ } }, "devDependencies": { + "@astryxdesign/cli": "0.6.2", "@astryxdesign/lab": "0.6.2-canary.c9fb1ad", "@eslint/js": "^9.39.0", "@stylexjs/stylex": "0.19.0", diff --git a/packages/cli/LICENSE b/packages/cli/LICENSE new file mode 100644 index 0000000..57bc88a --- /dev/null +++ b/packages/cli/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/packages/cli/NOTICE b/packages/cli/NOTICE new file mode 100644 index 0000000..95c0116 --- /dev/null +++ b/packages/cli/NOTICE @@ -0,0 +1,8 @@ +@lablup/ui-common-cli +Copyright 2026 Lablup Inc. + +This product includes software developed at Lablup Inc. +(https://www.lablup.com/). + +It runs the Astryx CLI (@astryxdesign/cli, MIT-licensed by Meta Platforms, +Inc.), which it depends on and does not vendor. diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 0000000..60a83a0 --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,23 @@ +# @lablup/ui-common-cli + +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. + +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 +same version as `@lablup/ui-common` and takes it as a peer. + +``` +pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 # one-off (npx @lablup/ui-common-cli …) + +pnpm add -D @lablup/ui-common-cli # or keep it next to ui-common +pnpm exec ui-common --help +``` + +Documentation: [The ui-common CLI](https://github.com/lablup/ui-common#the-ui-common-cli) +in the repository README. + +## License + +[Apache-2.0](LICENSE). See [NOTICE](NOTICE). diff --git a/bin/ui-common.mjs b/packages/cli/bin/ui-common.mjs similarity index 100% rename from bin/ui-common.mjs rename to packages/cli/bin/ui-common.mjs diff --git a/cli/agents.mjs b/packages/cli/cli/agents.mjs similarity index 99% rename from cli/agents.mjs rename to packages/cli/cli/agents.mjs index 8986d47..575e02b 100644 --- a/cli/agents.mjs +++ b/packages/cli/cli/agents.mjs @@ -19,7 +19,7 @@ import { forkedExports, hiddenExports, importAstryxInternal, - ownPackageJson, + uiCommonPackageJson, } from "./paths.mjs"; import { rewriteOutput } from "./rewrite.mjs"; @@ -178,7 +178,7 @@ export async function generateBlock(cwd) { const projectDir = findProjectDir(cwd) ?? cwd; const astryxBlock = await renderAstryxBlock(projectDir); return transformBlock(astryxBlock, { - version: ownPackageJson().version, + version: uiCommonPackageJson().version, astryxVersion: dependencyVersion("@astryxdesign/core") ?? "unknown", invocation: binInvocation(projectDir), componentCount: await coreComponentCount(), diff --git a/cli/diff.mjs b/packages/cli/cli/diff.mjs similarity index 100% rename from cli/diff.mjs rename to packages/cli/cli/diff.mjs diff --git a/cli/lab-peer.mjs b/packages/cli/cli/lab-peer.mjs similarity index 100% rename from cli/lab-peer.mjs rename to packages/cli/cli/lab-peer.mjs diff --git a/cli/main.mjs b/packages/cli/cli/main.mjs similarity index 68% rename from cli/main.mjs rename to packages/cli/cli/main.mjs index 1c3adef..4679840 100644 --- a/cli/main.mjs +++ b/packages/cli/cli/main.mjs @@ -4,15 +4,25 @@ */ import { AGENTS_HELP, agentsCommand } from "./agents.mjs"; import { passthrough } from "./passthrough.mjs"; -import { dependencyVersion, ownPackageJson } from "./paths.mjs"; +import { + cliDependencyVersion, + cliPackageJson, + uiCommonPackageJson, + uiCommonRoot, +} from "./paths.mjs"; import { ASTRYX_COMMANDS } from "./rewrite.mjs"; import { SYNC_HELP, syncAstryxCommand } from "./sync-astryx.mjs"; import { UPGRADE_HELP, upgradeCommand } from "./upgrade.mjs"; export const HELP = `Usage: ui-common [options] -@lablup/ui-common's CLI. Astryx commands run the Astryx CLI that ui-common -pins, with its output rewritten to ui-common import paths. +@lablup/ui-common's CLI (@lablup/ui-common-cli). Astryx commands run the +Astryx CLI it pins, with its output rewritten to ui-common import paths. + +One-off, before ui-common is installed or bumped: + pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 (npx: npx @lablup/ui-common-cli …) +Installed as a devDependency next to @lablup/ui-common: + pnpm exec ui-common ui-common commands: agents [--write ] [--check] @@ -32,7 +42,9 @@ Astryx commands (rewritten to @lablup/ui-common paths): Options: -h, --help Show this help (\`ui-common --help\` for one command) - -V, --version Print the @lablup/ui-common version + -V, --version Print the @lablup/ui-common-cli version (the same as the + @lablup/ui-common it ships with); --verbose adds the + project's ui-common and the Astryx CLI Exit codes: a passed-through command exits with the Astryx CLI's code. ui-common commands exit 0 on success, 1 on a failed check or run, 2 on bad arguments. @@ -54,10 +66,17 @@ export async function main(argv) { return 0; case "-V": case "--version": - process.stdout.write(`${ownPackageJson().version}\n`); + process.stdout.write(`${cliPackageJson().version}\n`); if (rest.includes("--verbose")) { + let uiCommon = "not installed"; + try { + const root = uiCommonRoot(); + uiCommon = `${uiCommonPackageJson(root).version} (${root})`; + } catch { + // reported as not installed + } process.stdout.write( - `astryx ${dependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, + `@lablup/ui-common ${uiCommon}\nastryx ${cliDependencyVersion("@astryxdesign/cli") ?? "not installed"}\n`, ); } return 0; diff --git a/cli/passthrough.mjs b/packages/cli/cli/passthrough.mjs similarity index 100% rename from cli/passthrough.mjs rename to packages/cli/cli/passthrough.mjs diff --git a/packages/cli/cli/paths.mjs b/packages/cli/cli/paths.mjs new file mode 100644 index 0000000..a787f62 --- /dev/null +++ b/packages/cli/cli/paths.mjs @@ -0,0 +1,290 @@ +/** + * Where things live. Two packages are involved, and they are found apart: + * + * - The CLI's own package, @lablup/ui-common-cli: the Astryx CLI it pins, + * jscodeshift, the codemods and the migration map. + * - The @lablup/ui-common package the CLI works for: its version, its export + * lists (exports.exclude.json, exports.customs.json) and the Astryx packages + * it pins (core, theme-neutral, lab). A consumer depends on ui-common, so + * under pnpm's isolated layout those are reachable from ui-common's install + * location and not from the consumer's own directory. + * + * Nothing is resolved from the working directory except the project's own + * @lablup/ui-common. + */ +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const UI_COMMON = "@lablup/ui-common"; + +/** Root of the installed @lablup/ui-common-cli package. */ +export const CLI_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +/** @param {string} file */ +export function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +/** @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record}} */ +export function cliPackageJson() { + return readJson(join(CLI_ROOT, "package.json")); +} + +/** + * The @lablup/ui-common package root `fromDir` resolves, or null. Its + * `exports` map lists `./package.json`, so this works from a consumer, from + * the CLI package (its peer) and, by self-reference, from this repository. + * + * @param {string} fromDir + */ +function resolveUiCommonFrom(fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return dirname(req.resolve(`${UI_COMMON}/package.json`)); + } catch { + return null; + } +} + +/** + * Root of the @lablup/ui-common a command works on: the project's own install + * first, so the CLI reads the ui-common the project actually has, then the one + * beside the CLI (its peer, which a `pnpm dlx` run installs). + * + * @param {string} [cwd] + */ +export function uiCommonRoot(cwd = process.cwd()) { + const project = findProjectDir(cwd); + const dir = + (project && resolveUiCommonFrom(project)) ?? resolveUiCommonFrom(CLI_ROOT); + if (!dir) { + throw new Error( + `${UI_COMMON} is not installed. Add it to the project (the CLI is @lablup/ui-common-cli, a devDependency next to it).`, + ); + } + return dir; +} + +/** + * Root of the @lablup/ui-common this CLI release ships with: its peer, at the + * same version. `upgrade` reads its target's pins from here, since the project + * may still be on the version it is upgrading from. Falls back to the + * project's when the peer is not reachable from the CLI. + * + * @param {string} [cwd] + */ +export function targetUiCommonRoot(cwd = process.cwd()) { + return resolveUiCommonFrom(CLI_ROOT) ?? uiCommonRoot(cwd); +} + +/** + * package.json of the @lablup/ui-common a command works on (see uiCommonRoot). + * + * @param {string} [root] + * @returns {{name: string, version: string, dependencies?: Record, peerDependencies?: Record, devDependencies?: Record}} + */ +export function uiCommonPackageJson(root = uiCommonRoot()) { + return readJson(join(root, "package.json")); +} + +/** Walk up from a resolved file to the directory holding its package.json. */ +function packageDirOf(file, name) { + let dir = dirname(file); + for (;;) { + const pkg = join(dir, "package.json"); + if (existsSync(pkg)) { + try { + if (readJson(pkg).name === name) return dir; + } catch { + // keep walking + } + } + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * The directory of a package as `fromDir` resolves it, or null. Resolution + * goes through the package's main entry, because not every Astryx package + * exports its package.json. + * + * @param {string} name + * @param {string} fromDir + */ +function packageDirFrom(name, fromDir) { + try { + const req = createRequire(join(fromDir, "package.json")); + return packageDirOf(req.resolve(name), name); + } catch { + return null; + } +} + +/** + * The directory of a dependency of @lablup/ui-common (an Astryx package it + * pins), or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyDir(name, root) { + let from = root; + if (!from) { + try { + from = uiCommonRoot(); + } catch { + return null; + } + } + return packageDirFrom(name, from); +} + +/** + * The directory of a dependency of the CLI package itself, or null. + * + * @param {string} name + */ +export function cliDependencyDir(name) { + return packageDirFrom(name, CLI_ROOT); +} + +/** Root of the exact-pinned @astryxdesign/cli. */ +export function astryxCliRoot() { + const dir = cliDependencyDir("@astryxdesign/cli"); + if (!dir) { + throw new Error( + "@astryxdesign/cli is not installed next to @lablup/ui-common-cli. Reinstall @lablup/ui-common-cli.", + ); + } + return dir; +} + +/** The `astryx` bin script of the pinned CLI. */ +export function astryxBin() { + const root = astryxCliRoot(); + const bin = readJson(join(root, "package.json")).bin?.astryx; + return join(root, bin ?? "clients/cli/bin/astryx.mjs"); +} + +/** + * Import a module file from inside the pinned Astryx CLI. These are internals, + * not the CLI's public API; the pin is exact and `sync-astryx` re-runs the + * tests that use them on every bump. + * + * @param {string} relative path under the CLI root + */ +export async function importAstryxInternal(relative) { + const file = join(astryxCliRoot(), relative); + return import(pathToFileURL(file).href); +} + +/** + * Version of an installed dependency of ui-common, or null. + * + * @param {string} name + * @param {string} [root] the ui-common root to resolve from + */ +export function dependencyVersion(name, root) { + const dir = dependencyDir(name, root); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** + * Version of an installed dependency of the CLI package, or null. + * + * @param {string} name + */ +export function cliDependencyVersion(name) { + const dir = cliDependencyDir(name); + return dir ? readJson(join(dir, "package.json")).version : null; +} + +/** `exports.exclude.json`: Astryx subpaths ui-common hides, with replacements. */ +export function excludedExports(root = uiCommonRoot()) { + const file = join(root, "exports.exclude.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, exports?: string[], replacedBy: string|null, reason: string}>} */ ( + readJson(file) + ); +} + +/** `exports.customs.json`: ui-common's own exports. */ +export function customExports(root = uiCommonRoot()) { + const file = join(root, "exports.customs.json"); + if (!existsSync(file)) return []; + return /** @type {Array<{name: string, source: string, subpath?: string, fork?: string, legacy?: object}>} */ ( + readJson(file) + ); +} + +/** + * ui-common's own copies of Astryx components (src/forks/): same names and + * import paths as Astryx's, which exports.exclude.json hides. `names` are the + * names a reader may meet it by; `from` is where a consumer imports it. + * + * @returns {Array<{name: string, from: string, names: string[], reason: string}>} + */ +export function forkedExports() { + const exclusions = excludedExports(); + return customExports() + .filter((c) => c.fork !== undefined) + .map((c) => { + const exclusion = exclusions.find((e) => e.replacedBy === c.name); + return { + name: c.name, + from: `@lablup/ui-common/${c.subpath ?? exclusion?.name ?? ""}`, + names: exclusion?.exports ?? [c.name], + reason: exclusion?.reason ?? "", + }; + }); +} + +/** + * The exclusions that hide an Astryx name behind a different one. A fork's + * exclusion is not one: the name stays, now ui-common's. + */ +export function hiddenExports() { + const forks = new Set(forkedExports().map((f) => f.name)); + return excludedExports().filter( + (e) => e.replacedBy === null || !forks.has(e.replacedBy), + ); +} + +/** + * The nearest directory at or above `start` holding a package.json. + * + * @param {string} start + */ +export function findProjectDir(start) { + let dir = resolve(start); + for (;;) { + if (existsSync(join(dir, "package.json"))) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +/** + * How this project runs a locally installed bin, from its lockfile. + * + * @param {string} projectDir + */ +export function binInvocation(projectDir, bin = "ui-common") { + let dir = resolve(projectDir); + for (;;) { + if (existsSync(join(dir, "pnpm-lock.yaml"))) return `pnpm exec ${bin}`; + if (existsSync(join(dir, "yarn.lock"))) return `yarn ${bin}`; + if (existsSync(join(dir, "bun.lock")) || existsSync(join(dir, "bun.lockb"))) { + return `bunx ${bin}`; + } + if (existsSync(join(dir, "package-lock.json"))) return `npx ${bin}`; + const parent = dirname(dir); + if (parent === dir) return `npx ${bin}`; + dir = parent; + } +} diff --git a/cli/report.mjs b/packages/cli/cli/report.mjs similarity index 98% rename from cli/report.mjs rename to packages/cli/cli/report.mjs index b767475..71873d5 100644 --- a/cli/report.mjs +++ b/packages/cli/cli/report.mjs @@ -41,7 +41,7 @@ export function renderReport(data) { const out = []; out.push(REPORT_HEADING, ""); out.push( - `\`ui-common upgrade\` ${data.from} → ${data.to} (installed @lablup/ui-common ${data.version})` + + `\`ui-common upgrade\` ${data.from} → ${data.to} (@lablup/ui-common-cli ${data.version})` + `${data.dryRun ? ", **dry run: nothing was written**" : ""}.`, "", `Scanned ${data.fileCount} file${data.fileCount === 1 ? "" : "s"} under ${data.roots.map(code).join(", ")}.`, diff --git a/cli/rewrite.mjs b/packages/cli/cli/rewrite.mjs similarity index 100% rename from cli/rewrite.mjs rename to packages/cli/cli/rewrite.mjs diff --git a/cli/semver.mjs b/packages/cli/cli/semver.mjs similarity index 100% rename from cli/semver.mjs rename to packages/cli/cli/semver.mjs diff --git a/cli/shadow.mjs b/packages/cli/cli/shadow.mjs similarity index 100% rename from cli/shadow.mjs rename to packages/cli/cli/shadow.mjs diff --git a/cli/sync-astryx.mjs b/packages/cli/cli/sync-astryx.mjs similarity index 81% rename from cli/sync-astryx.mjs rename to packages/cli/cli/sync-astryx.mjs index 9c2bf7e..fc84f6c 100644 --- a/cli/sync-astryx.mjs +++ b/packages/cli/cli/sync-astryx.mjs @@ -2,13 +2,14 @@ * `ui-common sync-astryx `: the maintainer's Astryx bump, inside the * ui-common repository. * - * 1. Move the exact pins (core, theme-neutral, cli; lab with --lab), and the + * 1. Move the exact pins (core, theme-neutral and the cli devDependency in the + * root package.json, the cli in packages/cli's; lab with --lab), and the * core version README and pnpm-workspace.yaml override lab's core peer to. * 2. Install, regenerate the export mirror and the built Lablup theme. * 3. Run Astryx's codemods on ui-common's own src/ (dry run, then applied). * 4. Run the tests, which include the export and theme drift checks. * 5. Record the Astryx codemods consumers need for this bump under the next - * ui-common version: codemods//upstream.json. `ui-common upgrade` + * ui-common version: packages/cli/codemods//upstream.json. `ui-common upgrade` * runs them for consumers, with the import specifiers swapped. * * `--dry-run` changes nothing: it prints the plan, runs Astryx's codemods on @@ -21,17 +22,22 @@ */ import { execFileSync, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { syncLabOverrideDocs } from "./lab-peer.mjs"; import { findProjectDir, readJson } from "./paths.mjs"; import { compare, nextBreaking, parse } from "./semver.mjs"; -const PINNED = [ - "@astryxdesign/core", - "@astryxdesign/theme-neutral", - "@astryxdesign/cli", -]; +/** Exact pins in the root package.json (@lablup/ui-common), by field. */ +const PINNED = /** @type {const} */ ([ + ["dependencies", "@astryxdesign/core"], + ["dependencies", "@astryxdesign/theme-neutral"], + // The root's own scripts (theme:build, check:integration) run it. + ["devDependencies", "@astryxdesign/cli"], +]); +/** The CLI package, which carries the Astryx CLI as a dependency. */ +export const CLI_PACKAGE_DIR = join("packages", "cli"); +const CLI_PINNED = /** @type {const} */ ([["dependencies", "@astryxdesign/cli"]]); const LAB = "@astryxdesign/lab"; export const SYNC_HELP = `Usage: ui-common sync-astryx [--lab ] [--as ] [--dry-run] @@ -39,7 +45,9 @@ export const SYNC_HELP = `Usage: ui-common sync-astryx [--lab , regenerates the export mirror and the Lablup theme, runs Astryx's codemods on src/, runs the tests, and records the Astryx codemods -consumers need under the next ui-common version (codemods//upstream.json). +consumers need under the next ui-common version +(packages/cli/codemods//upstream.json). Run it from the repository root +or anywhere under it. --lab Also move the @astryxdesign/lab canary pin (dev + peer). --as The ui-common version to record the codemods under. @@ -52,26 +60,37 @@ Exit codes: 0 done, 1 a step failed (the tree is left as it was at that step), `; /** + * The repository root at or above `cwd`: the @lablup/ui-common package with + * its scripts and the CLI package beside it. A directory inside the + * repository (packages/cli) finds it too. + * * @param {string} cwd * @returns {string | null} the repository root, or null outside it */ export function findUiCommonRepo(cwd) { - const dir = findProjectDir(cwd); - if (!dir) return null; + for (let dir = findProjectDir(cwd); dir;) { + if (isUiCommonRepo(dir)) return dir; + const parent = dirname(dir); + if (parent === dir) return null; + dir = findProjectDir(parent); + } + return null; +} + +/** @param {string} dir */ +function isUiCommonRepo(dir) { try { const pkg = readJson(join(dir, "package.json")); - if (pkg.name !== "@lablup/ui-common") return null; + if (pkg.name !== "@lablup/ui-common") return false; } catch { - return null; + return false; } // A consumer's node_modules copy has the name but not the repository. - if ( - !existsSync(join(dir, "scripts/gen-exports.mjs")) || - !existsSync(join(dir, "exports.exclude.json")) - ) { - return null; - } - return dir; + return ( + existsSync(join(dir, "scripts/gen-exports.mjs")) && + existsSync(join(dir, "exports.exclude.json")) && + existsSync(join(dir, CLI_PACKAGE_DIR, "package.json")) + ); } /** @@ -108,7 +127,7 @@ function listAstryxCodemods(repo, from, to) { * @param {{codemods: Array<{id: string, version: string, title: string}>, optional: string[]}} listed */ export function upstreamManifest(repo, version, astryx, listed) { - const file = join(repo, "codemods", version, "upstream.json"); + const file = join(repo, CLI_PACKAGE_DIR, "codemods", version, "upstream.json"); /** @type {any} */ let manifest = { $comment: @@ -214,8 +233,9 @@ export async function syncAstryxCommand(argv) { } const pkgFile = join(repo, "package.json"); - const pkgText = readFileSync(pkgFile, "utf8"); - const pkg = JSON.parse(pkgText); + const pkg = JSON.parse(readFileSync(pkgFile, "utf8")); + const cliPkgFile = join(repo, CLI_PACKAGE_DIR, "package.json"); + const cliPkg = JSON.parse(readFileSync(cliPkgFile, "utf8")); const current = pkg.dependencies?.["@astryxdesign/core"]; if (!current || !parse(current)) { process.stderr.write( @@ -231,9 +251,9 @@ export async function syncAstryxCommand(argv) { ); out(` repository: ${repo}`); const edits = []; - for (const name of PINNED) { - const from = pkg.dependencies?.[name]; - if (from !== target) edits.push(`dependencies["${name}"]: ${from} → ${target}`); + for (const [field, name] of PINNED) { + const from = pkg[field]?.[name]; + if (from !== target) edits.push(`${field}["${name}"]: ${from} → ${target}`); } if (lab) { for (const field of ["devDependencies", "peerDependencies"]) { @@ -246,6 +266,16 @@ export async function syncAstryxCommand(argv) { ? ` package.json: ${edits.join("; ")}` : " package.json: pins already at the target", ); + const cliEdits = []; + for (const [field, name] of CLI_PINNED) { + const from = cliPkg[field]?.[name]; + if (from !== target) cliEdits.push(`${field}["${name}"]: ${from} → ${target}`); + } + out( + cliEdits.length > 0 + ? ` ${CLI_PACKAGE_DIR}/package.json: ${cliEdits.join("; ")}` + : ` ${CLI_PACKAGE_DIR}/package.json: pins already at the target`, + ); for (const edit of labOverrideEdits(repo, target)) { out(` ${edit.name}: lab's core override → ${target}`); } @@ -307,15 +337,19 @@ export async function syncAstryxCommand(argv) { } // Pins. - for (const name of PINNED) { - pkg.dependencies[name] = target; + for (const [field, name] of PINNED) { + pkg[field] = { ...pkg[field], [name]: target }; + } + for (const [field, name] of CLI_PINNED) { + cliPkg[field] = { ...cliPkg[field], [name]: target }; } if (lab) { pkg.devDependencies = { ...pkg.devDependencies, [LAB]: lab }; pkg.peerDependencies = { ...pkg.peerDependencies, [LAB]: lab }; } writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); - out(" package.json written"); + writeFileSync(cliPkgFile, `${JSON.stringify(cliPkg, null, 2)}\n`); + out(` package.json and ${CLI_PACKAGE_DIR}/package.json written`); for (const edit of labOverrideEdits(repo, target)) { writeFileSync(edit.file, edit.after); out(` ${edit.name}: lab's core override moved to ${target}`); diff --git a/cli/upgrade.mjs b/packages/cli/cli/upgrade.mjs similarity index 98% rename from cli/upgrade.mjs rename to packages/cli/cli/upgrade.mjs index d9d4b6b..525d1f6 100644 --- a/cli/upgrade.mjs +++ b/packages/cli/cli/upgrade.mjs @@ -21,7 +21,7 @@ import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { registeredVersions, stepsBetween } from "../codemods/registry.mjs"; import { TODO_TAG } from "../codemods/lib/jsx.mjs"; import { diffStat, unifiedDiff } from "./diff.mjs"; -import { findProjectDir, ownPackageJson } from "./paths.mjs"; +import { cliPackageJson, findProjectDir } from "./paths.mjs"; import { renderReport, REPORT_HEADING } from "./report.mjs"; import { coerce, compare, parse } from "./semver.mjs"; @@ -123,7 +123,9 @@ export async function runUpgrade(options) { const pkgFile = join(projectDir, "package.json"); const pkgText = existsSync(pkgFile) ? readFileSync(pkgFile, "utf8") : null; const pkg = pkgText ? JSON.parse(pkgText) : {}; - const own = ownPackageJson(); + // The codemods ship with the CLI, which moves in lockstep with ui-common: + // its version is the ui-common version they upgrade to. + const own = cliPackageJson(); const declared = pkg.dependencies?.["@lablup/ui-common"] ?? @@ -478,7 +480,8 @@ package.json, and write a manual-review report. --from The ui-common version the code is written against. Default: the version package.json declares. - --to Default: the installed @lablup/ui-common version. + --to Default: this CLI's version, which is the @lablup/ui-common + version it ships with. --dry-run Write nothing; list what would change and print the report. --diff With --dry-run, also print unified diffs. --report Where to write the report. Default: ui-common-upgrade-report.md, diff --git a/codemods/0.2/components.mjs b/packages/cli/codemods/0.2/components.mjs similarity index 100% rename from codemods/0.2/components.mjs rename to packages/cli/codemods/0.2/components.mjs diff --git a/codemods/0.2/elements.mjs b/packages/cli/codemods/0.2/elements.mjs similarity index 100% rename from codemods/0.2/elements.mjs rename to packages/cli/codemods/0.2/elements.mjs diff --git a/codemods/0.2/index.mjs b/packages/cli/codemods/0.2/index.mjs similarity index 100% rename from codemods/0.2/index.mjs rename to packages/cli/codemods/0.2/index.mjs diff --git a/codemods/0.2/legacy-classes.json b/packages/cli/codemods/0.2/legacy-classes.json similarity index 100% rename from codemods/0.2/legacy-classes.json rename to packages/cli/codemods/0.2/legacy-classes.json diff --git a/codemods/0.2/map.mjs b/packages/cli/codemods/0.2/map.mjs similarity index 94% rename from codemods/0.2/map.mjs rename to packages/cli/codemods/0.2/map.mjs index d800c30..a754b7f 100644 --- a/codemods/0.2/map.mjs +++ b/packages/cli/codemods/0.2/map.mjs @@ -6,10 +6,15 @@ */ import { join } from "node:path"; -import { ownPackageJson, PACKAGE_ROOT, readJson } from "../../cli/paths.mjs"; +import { + CLI_ROOT, + readJson, + targetUiCommonRoot, + uiCommonPackageJson, +} from "../../cli/paths.mjs"; export const UIC = "@lablup/ui-common"; -export const MIGRATION_FILE = join(PACKAGE_ROOT, "migration", "0.1-to-0.2.json"); +export const MIGRATION_FILE = join(CLI_ROOT, "migration", "0.1-to-0.2.json"); /** * @typedef {object} PropRename @@ -113,11 +118,14 @@ export const STYLESHEETS = { export const LAB_PACKAGE = "@astryxdesign/lab"; export const LAB_CSS = `${UIC}/lab/lab.css`; -/** The StyleX peer range ui-common itself declares. */ +/** The StyleX peer range the target ui-common declares. */ export function stylexPeer() { return { name: "@stylexjs/stylex", - range: ownPackageJson().peerDependencies?.["@stylexjs/stylex"] ?? "^0.19.0", + range: + uiCommonPackageJson(targetUiCommonRoot()).peerDependencies?.[ + "@stylexjs/stylex" + ] ?? "^0.19.0", }; } diff --git a/codemods/0.2/package-json.mjs b/packages/cli/codemods/0.2/package-json.mjs similarity index 96% rename from codemods/0.2/package-json.mjs rename to packages/cli/codemods/0.2/package-json.mjs index 003cd05..6537ada 100644 --- a/codemods/0.2/package-json.mjs +++ b/packages/cli/codemods/0.2/package-json.mjs @@ -8,7 +8,7 @@ * override (../../cli/lab-peer.mjs). */ import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; +import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs"; import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; const FIELDS = /** @type {const} */ ([ @@ -61,7 +61,9 @@ function bumpSpec(spec, to) { * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx */ function addLabOverride(pkg, ctx) { - const pin = ownPackageJson().dependencies?.[CORE]; + const pin = uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).dependencies?.[ + CORE + ]; if (!pin || !ctx.projectDir || !ctx.editFile) return; const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg); if (manager === "npm" && root !== ctx.projectDir) { diff --git a/codemods/0.2/scan.mjs b/packages/cli/codemods/0.2/scan.mjs similarity index 97% rename from codemods/0.2/scan.mjs rename to packages/cli/codemods/0.2/scan.mjs index 70b52dd..0681740 100644 --- a/codemods/0.2/scan.mjs +++ b/packages/cli/codemods/0.2/scan.mjs @@ -16,7 +16,7 @@ import { fileURLToPath } from "node:url"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; -import { dependencyDir } from "../../cli/paths.mjs"; +import { dependencyDir, targetUiCommonRoot } from "../../cli/paths.mjs"; import { keptClassRename } from "./map.mjs"; const legacy = JSON.parse( @@ -68,9 +68,11 @@ export function astryxCustomProperties() { if (astryxProperties) return astryxProperties; const names = new Set(); const sheets = []; - const core = dependencyDir("@astryxdesign/core"); + // The Astryx the upgrade moves to: the project may still be on 0.1. + const root = targetUiCommonRoot(); + const core = dependencyDir("@astryxdesign/core", root); if (core) sheets.push(join(core, "dist/astryx.css")); - const neutral = dependencyDir("@astryxdesign/theme-neutral"); + const neutral = dependencyDir("@astryxdesign/theme-neutral", root); if (neutral) sheets.push(join(neutral, "dist/theme.css")); for (const sheet of sheets) { let text; diff --git a/codemods/0.2/stylesheets.mjs b/packages/cli/codemods/0.2/stylesheets.mjs similarity index 100% rename from codemods/0.2/stylesheets.mjs rename to packages/cli/codemods/0.2/stylesheets.mjs diff --git a/codemods/lib/jsx.mjs b/packages/cli/codemods/lib/jsx.mjs similarity index 100% rename from codemods/lib/jsx.mjs rename to packages/cli/codemods/lib/jsx.mjs diff --git a/codemods/lib/todo.mjs b/packages/cli/codemods/lib/todo.mjs similarity index 100% rename from codemods/lib/todo.mjs rename to packages/cli/codemods/lib/todo.mjs diff --git a/codemods/registry.mjs b/packages/cli/codemods/registry.mjs similarity index 100% rename from codemods/registry.mjs rename to packages/cli/codemods/registry.mjs diff --git a/codemods/upstream.mjs b/packages/cli/codemods/upstream.mjs similarity index 100% rename from codemods/upstream.mjs rename to packages/cli/codemods/upstream.mjs diff --git a/migration/0.1-to-0.2.json b/packages/cli/migration/0.1-to-0.2.json similarity index 100% rename from migration/0.1-to-0.2.json rename to packages/cli/migration/0.1-to-0.2.json diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..b14d471 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,50 @@ +{ + "name": "@lablup/ui-common-cli", + "version": "0.2.0-alpha.14", + "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block, and the upgrade codemods", + "license": "Apache-2.0", + "author": "Lablup Inc.", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/lablup/ui-common.git", + "directory": "packages/cli" + }, + "homepage": "https://github.com/lablup/ui-common#the-ui-common-cli", + "bugs": { + "url": "https://github.com/lablup/ui-common/issues" + }, + "publishConfig": { + "access": "public", + "provenance": false + }, + "engines": { + "node": ">=22.13.0" + }, + "bin": { + "ui-common": "./bin/ui-common.mjs" + }, + "files": [ + "bin", + "cli", + "codemods", + "migration", + "README.md", + "NOTICE" + ], + "exports": { + "./package.json": "./package.json" + }, + "dependencies": { + "@astryxdesign/cli": "0.6.2", + "jscodeshift": "^17.4.0", + "postcss": "^8.5.25", + "postcss-selector-parser": "^7.1.6" + }, + "peerDependencies": { + "@lablup/ui-common": "workspace:*" + }, + "devDependencies": { + "@lablup/ui-common": "workspace:*" + } +} diff --git a/scripts/extract-legacy-classes.mjs b/packages/cli/scripts/extract-legacy-classes.mjs similarity index 94% rename from scripts/extract-legacy-classes.mjs rename to packages/cli/scripts/extract-legacy-classes.mjs index 0665c28..fe812e2 100644 --- a/scripts/extract-legacy-classes.mjs +++ b/packages/cli/scripts/extract-legacy-classes.mjs @@ -7,7 +7,7 @@ * checkout of the last 0.1 release: * * git archive v0.1.0-alpha.23 src | tar -x -C /tmp/uc-0.1 - * node scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 + * node packages/cli/scripts/extract-legacy-classes.mjs /tmp/uc-0.1 v0.1.0-alpha.23 * * `ui-common upgrade` reports consumer selectors, DOM queries and tests that * target these names. Astryx does not render them. @@ -26,6 +26,7 @@ if (!checkout) { process.exit(2); } +// The CLI package root (packages/cli). const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const files = globSync("src/components/**/*.css", { cwd: checkout, absolute: true }); diff --git a/test/cli/cli.test.ts b/packages/cli/test/cli/cli.test.ts similarity index 97% rename from test/cli/cli.test.ts rename to packages/cli/test/cli/cli.test.ts index 5376dc0..53f259a 100644 --- a/test/cli/cli.test.ts +++ b/packages/cli/test/cli/cli.test.ts @@ -36,8 +36,11 @@ import { import { registeredVersions, stepsBetween } from "../../codemods/registry.mjs"; import { toAstryxSpecifiers, upstreamStep } from "../../codemods/upstream.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); -const bin = join(root, "bin/ui-common.mjs"); +// The CLI package (packages/cli) and the repository root, which is the +// @lablup/ui-common package the CLI works for. +const cliRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const root = resolve(cliRoot, "../.."); +const bin = join(cliRoot, "bin/ui-common.mjs"); const temps: string[] = []; afterEach(() => { @@ -177,6 +180,9 @@ describe("output rewriting", { timeout: 60_000 }, () => { it("prints its version and help", () => { const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + const cliPkg = JSON.parse(readFileSync(join(cliRoot, "package.json"), "utf8")); + // Lockstep: the CLI is released at the version of the ui-common it serves. + expect(cliPkg.version).toBe(pkg.version); expect(run(["--version"]).stdout.trim()).toBe(pkg.version); const help = run(["--help"]); expect(help.code).toBe(0); diff --git a/test/cli/lab-peer.test.ts b/packages/cli/test/cli/lab-peer.test.ts similarity index 96% rename from test/cli/lab-peer.test.ts rename to packages/cli/test/cli/lab-peer.test.ts index 9fbf458..5c7940c 100644 --- a/test/cli/lab-peer.test.ts +++ b/packages/cli/test/cli/lab-peer.test.ts @@ -18,12 +18,13 @@ import { labOverrideProblems, syncLabOverrideDocs, } from "../../cli/lab-peer.mjs"; -import { ownPackageJson } from "../../cli/paths.mjs"; +import { uiCommonPackageJson } from "../../cli/paths.mjs"; import { labOverrideEdits } from "../../cli/sync-astryx.mjs"; -const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +// The repository root, where README and the ui-common package.json live. +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const readme = readFileSync(join(root, "README.md"), "utf8"); -const corePin = ownPackageJson().dependencies["@astryxdesign/core"] as string; +const corePin = uiCommonPackageJson().dependencies?.["@astryxdesign/core"] as string; describe("the lab core override", () => { it("is documented in README at the core pin whenever lab's core peer differs", () => { diff --git a/test/upgrade/codemods.test.ts b/packages/cli/test/upgrade/codemods.test.ts similarity index 100% rename from test/upgrade/codemods.test.ts rename to packages/cli/test/upgrade/codemods.test.ts diff --git a/test/upgrade/fixtures/adapter/expected/package.json b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json similarity index 100% rename from test/upgrade/fixtures/adapter/expected/package.json rename to packages/cli/test/upgrade/fixtures/adapter/expected/package.json diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-adapters.tsx diff --git a/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/design-system/common-components.css diff --git a/test/upgrade/fixtures/adapter/expected/src/index.scss b/packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/index.scss rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/index.scss diff --git a/test/upgrade/fixtures/adapter/expected/src/main.tsx b/packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/main.tsx rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/main.tsx diff --git a/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css b/packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css similarity index 100% rename from test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css rename to packages/cli/test/upgrade/fixtures/adapter/expected/src/ui-common-entry.css diff --git a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md similarity index 98% rename from test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index 5cf1f13..c645afd 100644 --- a/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -1,6 +1,6 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). Scanned 4 files under `src`. diff --git a/test/upgrade/fixtures/adapter/input/package.json b/packages/cli/test/upgrade/fixtures/adapter/input/package.json similarity index 100% rename from test/upgrade/fixtures/adapter/input/package.json rename to packages/cli/test/upgrade/fixtures/adapter/input/package.json diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx b/packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx rename to packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-adapters.tsx diff --git a/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css b/packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/design-system/common-components.css rename to packages/cli/test/upgrade/fixtures/adapter/input/src/design-system/common-components.css diff --git a/test/upgrade/fixtures/adapter/input/src/index.scss b/packages/cli/test/upgrade/fixtures/adapter/input/src/index.scss similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/index.scss rename to packages/cli/test/upgrade/fixtures/adapter/input/src/index.scss diff --git a/test/upgrade/fixtures/adapter/input/src/main.tsx b/packages/cli/test/upgrade/fixtures/adapter/input/src/main.tsx similarity index 100% rename from test/upgrade/fixtures/adapter/input/src/main.tsx rename to packages/cli/test/upgrade/fixtures/adapter/input/src/main.tsx diff --git a/test/upgrade/fixtures/css-entry/expected/package.json b/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json similarity index 100% rename from test/upgrade/fixtures/css-entry/expected/package.json rename to packages/cli/test/upgrade/fixtures/css-entry/expected/package.json diff --git a/test/upgrade/fixtures/css-entry/expected/src/index.css b/packages/cli/test/upgrade/fixtures/css-entry/expected/src/index.css similarity index 100% rename from test/upgrade/fixtures/css-entry/expected/src/index.css rename to packages/cli/test/upgrade/fixtures/css-entry/expected/src/index.css diff --git a/test/upgrade/fixtures/css-entry/expected/src/print.css b/packages/cli/test/upgrade/fixtures/css-entry/expected/src/print.css similarity index 100% rename from test/upgrade/fixtures/css-entry/expected/src/print.css rename to packages/cli/test/upgrade/fixtures/css-entry/expected/src/print.css diff --git a/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md similarity index 95% rename from test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md index 1b34e43..60877f3 100644 --- a/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md @@ -1,6 +1,6 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.23 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). Scanned 2 files under `src`. diff --git a/test/upgrade/fixtures/css-entry/input/package.json b/packages/cli/test/upgrade/fixtures/css-entry/input/package.json similarity index 100% rename from test/upgrade/fixtures/css-entry/input/package.json rename to packages/cli/test/upgrade/fixtures/css-entry/input/package.json diff --git a/test/upgrade/fixtures/css-entry/input/src/index.css b/packages/cli/test/upgrade/fixtures/css-entry/input/src/index.css similarity index 100% rename from test/upgrade/fixtures/css-entry/input/src/index.css rename to packages/cli/test/upgrade/fixtures/css-entry/input/src/index.css diff --git a/test/upgrade/fixtures/css-entry/input/src/print.css b/packages/cli/test/upgrade/fixtures/css-entry/input/src/print.css similarity index 100% rename from test/upgrade/fixtures/css-entry/input/src/print.css rename to packages/cli/test/upgrade/fixtures/css-entry/input/src/print.css diff --git a/test/upgrade/fixtures/library/expected/package.json b/packages/cli/test/upgrade/fixtures/library/expected/package.json similarity index 100% rename from test/upgrade/fixtures/library/expected/package.json rename to packages/cli/test/upgrade/fixtures/library/expected/package.json diff --git a/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx b/packages/cli/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx similarity index 100% rename from test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx rename to packages/cli/test/upgrade/fixtures/library/expected/src/chat/ReasoningBlock.tsx diff --git a/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md similarity index 96% rename from test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md index 962a51d..854dd2c 100644 --- a/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md @@ -1,6 +1,6 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.0 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.0 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). Scanned 1 file under `src`. diff --git a/test/upgrade/fixtures/library/input/package.json b/packages/cli/test/upgrade/fixtures/library/input/package.json similarity index 100% rename from test/upgrade/fixtures/library/input/package.json rename to packages/cli/test/upgrade/fixtures/library/input/package.json diff --git a/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx b/packages/cli/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx similarity index 100% rename from test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx rename to packages/cli/test/upgrade/fixtures/library/input/src/chat/ReasoningBlock.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json diff --git a/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/src/themes/violet.css diff --git a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md similarity index 98% rename from test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md index 608a87b..99e2a1a 100644 --- a/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -1,6 +1,6 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.7 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). Scanned 4 files under `src`. diff --git a/test/upgrade/fixtures/root-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/input/package.json similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/package.json rename to packages/cli/test/upgrade/fixtures/root-barrel/input/package.json diff --git a/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/chat/InputPopup.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.test.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/pages/ModelsPage.tsx diff --git a/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css b/packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css similarity index 100% rename from test/upgrade/fixtures/root-barrel/input/src/themes/violet.css rename to packages/cli/test/upgrade/fixtures/root-barrel/input/src/themes/violet.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/package.json rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/DataTableWrapper.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/index.ts diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/reports/ReportPreviewFrame.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/families.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/theme.ts diff --git a/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/styles/ui-common-entry.css diff --git a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md similarity index 98% rename from test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md rename to packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md index 8fa34b7..d02adcd 100644 --- a/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md @@ -1,6 +1,6 @@ # ui-common upgrade report -`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (installed @lablup/ui-common ). +`ui-common upgrade` 0.1.0-alpha.19 → 0.2.0-alpha.0 (@lablup/ui-common-cli ). Scanned 6 files under `src`. diff --git a/test/upgrade/fixtures/subpath-barrel/input/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/package.json similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/package.json rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/package.json diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/DataTableWrapper.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/Select.test.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/components/common/index.ts diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/reports/ReportPreviewFrame.tsx diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/families.css diff --git a/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts b/packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts similarity index 100% rename from test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts rename to packages/cli/test/upgrade/fixtures/subpath-barrel/input/src/styles/theme.ts diff --git a/test/upgrade/stylesheets.test.ts b/packages/cli/test/upgrade/stylesheets.test.ts similarity index 100% rename from test/upgrade/stylesheets.test.ts rename to packages/cli/test/upgrade/stylesheets.test.ts diff --git a/test/upgrade/upgrade.test.ts b/packages/cli/test/upgrade/upgrade.test.ts similarity index 97% rename from test/upgrade/upgrade.test.ts rename to packages/cli/test/upgrade/upgrade.test.ts index ebf4fd7..34a5819 100644 --- a/test/upgrade/upgrade.test.ts +++ b/packages/cli/test/upgrade/upgrade.test.ts @@ -53,10 +53,7 @@ function tree(dir: string): string[] { /** The installed version appears in the report header; keep fixtures stable across releases. */ function normalize(file: string, text: string) { return file.endsWith(".md") - ? text.replace( - /installed @lablup\/ui-common [^)]+\)/, - "installed @lablup/ui-common )", - ) + ? text.replace(/@lablup\/ui-common-cli [^)]+\)/, "@lablup/ui-common-cli )") : text; } @@ -159,7 +156,10 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { }); describe("points lab's core peer at ui-common's core when it adds lab", () => { - const own = JSON.parse(readFileSync(join(here, "../../package.json"), "utf8")) as { + // The repository root: the @lablup/ui-common the CLI ships with. + const own = JSON.parse( + readFileSync(join(here, "../../../../package.json"), "utf8"), + ) as { dependencies: Record; }; const pin = own.dependencies["@astryxdesign/core"]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f638bd..af7644e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@astryxdesign/cli': - specifier: 0.6.2 - version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) '@astryxdesign/core': specifier: 0.6.2 version: 0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -32,19 +29,13 @@ importers: intl-messageformat: specifier: ^11.2.9 version: 11.2.15 - jscodeshift: - specifier: ^17.4.0 - version: 17.4.0(supports-color@8.1.1) lucide-react: specifier: ^1.18.0 version: 1.48.0(react@19.2.8) - postcss: - specifier: ^8.5.25 - version: 8.5.25 - postcss-selector-parser: - specifier: ^7.1.6 - version: 7.1.6 devDependencies: + '@astryxdesign/cli': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) '@astryxdesign/lab': specifier: 0.6.2-canary.c9fb1ad version: 0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -118,6 +109,25 @@ importers: specifier: ^4.1.8 version: 4.1.10(@types/node@22.20.1)(jsdom@29.1.1)(vite@8.2.0(@types/node@22.20.1)(jiti@2.7.0)(sass@1.105.0)) + packages/cli: + dependencies: + '@astryxdesign/cli': + specifier: 0.6.2 + version: 0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/lab@0.6.2-canary.c9fb1ad(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@astryxdesign/theme-neutral@0.6.2(@astryxdesign/core@0.6.2(@stylexjs/stylex@0.19.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8))(gpt-tokenizer@3.4.0)(supports-color@8.1.1) + jscodeshift: + specifier: ^17.4.0 + version: 17.4.0(supports-color@8.1.1) + postcss: + specifier: ^8.5.25 + version: 8.5.25 + postcss-selector-parser: + specifier: ^7.1.6 + version: 7.1.6 + devDependencies: + '@lablup/ui-common': + specifier: workspace:* + version: link:../.. + packages: '@adobe/css-tools@4.5.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c8ac3c8..e36de36 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,12 @@ # published package; consumers resolve @lablup/ui-common's dependencies under # their own pnpm settings. +# The library is the repository root; its CLI is packages/cli +# (@lablup/ui-common-cli), published separately at the same version. +packages: + - "." + - "packages/*" + # @astryxdesign/lab is a canary build. Canary releases never age out of a # release-age quarantine by design, so it is excluded for as long as the exact # pin exists (the same exclusion backend.ai-webui carries). diff --git a/src/migrationMap.test.ts b/src/migrationMap.test.ts index 46bc186..67e5a56 100644 --- a/src/migrationMap.test.ts +++ b/src/migrationMap.test.ts @@ -1,5 +1,6 @@ /** - * `migration/0.1-to-0.2.json` is read by `ui-common upgrade`. It has to agree + * `packages/cli/migration/0.1-to-0.2.json` is read by `ui-common upgrade` + * (@lablup/ui-common-cli). It has to agree * with the package it describes: every removed component is gone, every * replacement import resolves to a runtime export, and every kept component's * class rename matches the classes its stylesheet declares. @@ -21,7 +22,7 @@ interface KeptEntry { } const map = JSON.parse( - readFileSync(join(ROOT, "migration", "0.1-to-0.2.json"), "utf8"), + readFileSync(join(ROOT, "packages", "cli", "migration", "0.1-to-0.2.json"), "utf8"), ) as { removedComponents: RemovedEntry[]; keptComponents: KeptEntry[] }; const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")) as { diff --git a/vite.config.ts b/vite.config.ts index 6584a80..97d655a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -256,7 +256,9 @@ export default defineConfig({ globals: true, setupFiles: ["./src/test/setup.ts"], css: true, - // Consumer source the upgrade codemods run on, not tests of this package. - exclude: [...configDefaults.exclude, "test/upgrade/fixtures/**"], + // The CLI package's tests (packages/cli/test) run here too, under the same + // setup. Its upgrade fixtures are consumer source the codemods run on, not + // tests. + exclude: [...configDefaults.exclude, "packages/cli/test/upgrade/fixtures/**"], }, }); From bc4fccf225b22bd75f9df8cf4947d41fd0bd1ced Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:08:06 +0000 Subject: [PATCH 02/25] feat(cli): add @lablup/ui-common-cli to a project upgraded from 0.1 0.1 shipped the ui-common bin inside @lablup/ui-common; from 0.2 it is @lablup/ui-common-cli. The 0.1 -> 0.2 package.json codemod now adds it to devDependencies at exactly the target version, or pins an existing entry there, so pnpm exec ui-common keeps working after the upgrade. The help, the upgrade report header and the agent block name the new entry points: pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 (or npx) for a one-off run, and pnpm exec ui-common with the CLI installed as a devDependency beside ui-common. --- packages/cli/cli/agents.mjs | 3 ++- packages/cli/cli/upgrade.mjs | 7 ++++- packages/cli/codemods/0.2/package-json.mjs | 27 +++++++++++++++++++ .../fixtures/adapter/expected/package.json | 3 +++ .../expected/ui-common-upgrade-report.md | 1 + .../fixtures/css-entry/expected/package.json | 3 +++ .../expected/ui-common-upgrade-report.md | 1 + .../fixtures/library/expected/package.json | 1 + .../expected/ui-common-upgrade-report.md | 1 + .../root-barrel/expected/package.json | 3 +++ .../expected/ui-common-upgrade-report.md | 1 + .../subpath-barrel/expected/package.json | 1 + .../expected/ui-common-upgrade-report.md | 1 + packages/cli/test/upgrade/upgrade.test.ts | 13 +++++++++ 14 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/cli/cli/agents.mjs b/packages/cli/cli/agents.mjs index 575e02b..1b8a437 100644 --- a/packages/cli/cli/agents.mjs +++ b/packages/cli/cli/agents.mjs @@ -99,7 +99,8 @@ function uiCommonSection({ version, astryxVersion, invocation }) { ); } lines.push( - `- After bumping @lablup/ui-common: \`${invocation} upgrade --from \`, then read ui-common-upgrade-report.md.`, + `- 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 \` (or \`npx @lablup/ui-common-cli \`).`, + `- After bumping @lablup/ui-common and @lablup/ui-common-cli: \`${invocation} upgrade --from \`, then read ui-common-upgrade-report.md.`, ); return lines; } diff --git a/packages/cli/cli/upgrade.mjs b/packages/cli/cli/upgrade.mjs index 525d1f6..89fb5e2 100644 --- a/packages/cli/cli/upgrade.mjs +++ b/packages/cli/cli/upgrade.mjs @@ -476,7 +476,12 @@ export const UPGRADE_HELP = `Usage: ui-common upgrade [--from ] [--to < Run the codemods registered between two @lablup/ui-common versions over your source (TS/TSX/JS/JSX through jscodeshift, CSS through postcss), update -package.json, and write a manual-review report. +package.json, and write a manual-review report. From 0.1, package.json also +gains @lablup/ui-common-cli (this bin) as a devDependency at the new version. + +One-off, from a project still on 0.1: + pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 --dry-run + (npx @lablup/ui-common-cli upgrade --from 0.1 --dry-run) --from The ui-common version the code is written against. Default: the version package.json declares. diff --git a/packages/cli/codemods/0.2/package-json.mjs b/packages/cli/codemods/0.2/package-json.mjs index 6537ada..0b03ff5 100644 --- a/packages/cli/codemods/0.2/package-json.mjs +++ b/packages/cli/codemods/0.2/package-json.mjs @@ -1,6 +1,8 @@ /** * 0.1 -> 0.2 package.json edits: * - bump @lablup/ui-common to the target version (keeping `^`/`~`); + * - add @lablup/ui-common-cli, the `ui-common` bin, to devDependencies at + * exactly the target version (0.1 shipped the bin inside ui-common); * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing; * - add @astryxdesign/lab, pinned to the canary ui-common is built against, * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its @@ -11,6 +13,9 @@ import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs"; import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs"; +/** The CLI package, released in lockstep with ui-common. */ +export const CLI_PACKAGE = "@lablup/ui-common-cli"; + const FIELDS = /** @type {const} */ ([ "dependencies", "devDependencies", @@ -111,6 +116,28 @@ export function transformPackageJson(text, ctx) { const has = (/** @type {string} */ name) => FIELDS.some((f) => pkg[f]?.[name] != null); + + // The bin moved out of ui-common into its own package. It is a dev-time + // tool, pinned exactly: it upgrades to and reads the ui-common of its own + // version. + const cliField = FIELDS.find((f) => pkg[f]?.[CLI_PACKAGE] != null); + if (!cliField) { + addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.to); + ctx.note( + `added ${CLI_PACKAGE} ${ctx.to} to devDependencies: the \`ui-common\` bin ships in its own package since 0.2, released at the same version as ${UIC}.`, + ); + } else { + const spec = pkg[cliField][CLI_PACKAGE]; + const bumped = bumpSpec(spec, ctx.to); + if (!bumped) { + ctx.note( + `${cliField}["${CLI_PACKAGE}"] is "${spec}"; not a version, so it was left alone.`, + ); + } else if (spec !== ctx.to) { + pkg[cliField][CLI_PACKAGE] = ctx.to; + ctx.note(`${cliField}["${CLI_PACKAGE}"]: "${spec}" → "${ctx.to}".`); + } + } // A library that takes ui-common as a peer takes StyleX as a peer too; // an application depends on it. const library = fields.includes("peerDependencies"); diff --git a/packages/cli/test/upgrade/fixtures/adapter/expected/package.json b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json index b438c73..31640bd 100644 --- a/packages/cli/test/upgrade/fixtures/adapter/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/package.json @@ -7,5 +7,8 @@ "@lablup/ui-common": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index c645afd..1807da4 100644 --- a/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -28,6 +28,7 @@ Scanned 4 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.19" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. - no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. diff --git a/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json b/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json index 011ed6a..614c00a 100644 --- a/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/package.json @@ -4,5 +4,8 @@ "dependencies": { "@lablup/ui-common": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md index 60877f3..787dddf 100644 --- a/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md @@ -25,6 +25,7 @@ Scanned 2 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.23" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. ## Manual review diff --git a/packages/cli/test/upgrade/fixtures/library/expected/package.json b/packages/cli/test/upgrade/fixtures/library/expected/package.json index 6f132d8..84be42f 100644 --- a/packages/cli/test/upgrade/fixtures/library/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/library/expected/package.json @@ -9,6 +9,7 @@ }, "devDependencies": { "@lablup/ui-common": "0.2.0-alpha.0", + "@lablup/ui-common-cli": "0.2.0-alpha.0", "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0" } diff --git a/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md index 854dd2c..8228206 100644 --- a/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md @@ -26,6 +26,7 @@ Scanned 1 file under `src`. - devDependencies["@lablup/ui-common"]: "0.1.0-alpha.0" → "0.2.0-alpha.0". - peerDependencies["@lablup/ui-common"]: ">=0.1.0-alpha.0 <0.2.0" → "^0.2.0-alpha.0". - peerDependencies["@lablup/ui-common"]: the range ">=0.1.0-alpha.0 <0.2.0" was replaced with "^0.2.0-alpha.0"; widen it again if this package must still accept 0.1. +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to peerDependencies and devDependencies. ## Manual review diff --git a/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json index b672d4e..f6611e0 100644 --- a/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/package.json @@ -7,5 +7,8 @@ "@stylexjs/stylex": "^0.19.0", "react": "^19.2.0", "react-dom": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" } } diff --git a/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md index 99e2a1a..7269493 100644 --- a/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -24,6 +24,7 @@ Scanned 4 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "0.1.0-alpha.7" → "0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. ## Manual review diff --git a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json index 73f1af1..ff94cfb 100644 --- a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/package.json @@ -9,6 +9,7 @@ "react": "^19.2.0" }, "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0", "vitest": "^4.0.0" } } diff --git a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md index d02adcd..0db2698 100644 --- a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/ui-common-upgrade-report.md @@ -27,6 +27,7 @@ Scanned 6 files under `src`. ## package.json - dependencies["@lablup/ui-common"]: "^0.1.0-alpha.19" → "^0.2.0-alpha.0". +- added @lablup/ui-common-cli 0.2.0-alpha.0 to devDependencies: the `ui-common` bin ships in its own package since 0.2, released at the same version as @lablup/ui-common. - added @stylexjs/stylex ^0.19.0 to dependencies. - added @astryxdesign/lab 0.6.2-canary.c9fb1ad to dependencies: a Drawer moved to @lablup/ui-common/lab, and ui-common pins the lab canary exactly. - no package manager was detected, so point @astryxdesign/lab's @astryxdesign/core peer at ui-common's by hand (pnpm: `overrides: { "@astryxdesign/lab>@astryxdesign/core": "0.6.2" }` in pnpm-workspace.yaml; npm: `"overrides": {"@astryxdesign/lab":{"@astryxdesign/core":"0.6.2"}}` in the root package.json) and check that `why @astryxdesign/core` lists one version. diff --git a/packages/cli/test/upgrade/upgrade.test.ts b/packages/cli/test/upgrade/upgrade.test.ts index 34a5819..6cc73d1 100644 --- a/packages/cli/test/upgrade/upgrade.test.ts +++ b/packages/cli/test/upgrade/upgrade.test.ts @@ -155,6 +155,19 @@ describe("ui-common upgrade 0.1 -> 0.2", () => { expect(readFileSync(join(dir, "reports/dry.md"), "utf8")).toContain("dry run"); }); + it("pins @lablup/ui-common-cli to the target, wherever the project lists it", async () => { + const dir = copyFixture("root-barrel"); + const pkgFile = join(dir, "package.json"); + const pkg = JSON.parse(readFileSync(pkgFile, "utf8")); + pkg.dependencies["@lablup/ui-common-cli"] = "^0.1.0"; + writeFileSync(pkgFile, `${JSON.stringify(pkg, null, 2)}\n`); + const result = await runUpgrade({ cwd: dir, paths: ["src"], to: TO, ...quiet }); + expect(result.code, JSON.stringify(result.errors)).toBe(0); + const after = JSON.parse(readFileSync(pkgFile, "utf8")); + expect(after.dependencies["@lablup/ui-common-cli"]).toBe(TO); + expect(after.devDependencies?.["@lablup/ui-common-cli"]).toBeUndefined(); + }); + describe("points lab's core peer at ui-common's core when it adds lab", () => { // The repository root: the @lablup/ui-common the CLI ships with. const own = JSON.parse( From d4e6bef8b2fa8f0a99ec58c6a3f5b1ac9be776cc Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:10:10 +0000 Subject: [PATCH 03/25] build: check both packed tarballs in check:pack check:pack now packs the library and the CLI with pnpm, the way they are published, and reads the packed manifests. The library tarball must not contain bin/, cli/, codemods/ or migration/, declare a bin, or depend on the CLI toolchain. The CLI tarball must contain its bin, modules and migration map and nothing of dist/, sit at the library's version with an exact @lablup/ui-common peer (no workspace: range left), and every import in it must resolve to a packed file or a declared dependency. --- scripts/check-pack.mjs | 150 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 139 insertions(+), 11 deletions(-) diff --git a/scripts/check-pack.mjs b/scripts/check-pack.mjs index da11f49..4c607a9 100644 --- a/scripts/check-pack.mjs +++ b/scripts/check-pack.mjs @@ -13,11 +13,21 @@ * by nothing, and named by no exports entry, so a consumer had no supported * way to load it. Checking that advertised paths resolve says nothing about * files that arrive advertised by no one. + * + * Two tarballs come out of this repository, at one version: the library + * (@lablup/ui-common, the root) and its command line (@lablup/ui-common-cli, + * packages/cli). Both are packed with pnpm, as the publish workflow packs + * them, so the manifests checked are the published ones, with `workspace:` + * ranges already rewritten. The library must carry none of the CLI; the CLI + * must carry nothing of the library's build. */ import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { dirname as posixDirname, join as posixJoin, @@ -25,6 +35,7 @@ import { } from "node:path/posix"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cliRoot = resolve(root, "packages/cli"); const FORBIDDEN_IN_TARBALL = [ { pattern: /(^|\/)src\//, reason: "package source must not ship" }, @@ -45,26 +56,65 @@ const FORBIDDEN_IN_TARBALL = [ }, ]; -function packFileList() { - const raw = execFileSync("npm", ["pack", "--dry-run", "--json"], { - cwd: root, +/** What only the CLI package ships. */ +const CLI_ONLY = [ + { + pattern: /^(bin|cli|codemods|migration)\//, + reason: "the CLI ships in @lablup/ui-common-cli", + }, +]; +/** The CLI's toolchain, which must stay out of a consumer's production install. */ +const CLI_DEPENDENCIES = [ + "@astryxdesign/cli", + "jscodeshift", + "postcss", + "postcss-selector-parser", +]; + +const scratch = mkdtempSync(join(tmpdir(), "uic-check-pack-")); +process.on("exit", () => rmSync(scratch, { recursive: true, force: true })); + +/** + * Pack `dir` with pnpm and return the packed paths and the packed manifest. + * + * @param {string} dir + */ +function pack(dir) { + const raw = execFileSync("pnpm", ["pack", "--pack-destination", scratch, "--json"], { + cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); const parsed = JSON.parse(raw); - return parsed[0].files.map((f) => f.path); + const manifest = JSON.parse( + execFileSync("tar", ["-xzOf", parsed.filename, "package/package.json"], { + encoding: "utf8", + }), + ); + return { files: parsed.files.map((f) => f.path), manifest }; } -const packed = packFileList(); const failures = []; +const { files: packed, manifest: pkg } = pack(root); + for (const file of packed) { - for (const { pattern, reason } of FORBIDDEN_IN_TARBALL) { + for (const { pattern, reason } of [...FORBIDDEN_IN_TARBALL, ...CLI_ONLY]) { if (pattern.test(file)) failures.push(`unexpected file "${file}": ${reason}`); } } +if (pkg.bin) + failures.push( + `@lablup/ui-common declares a bin; it belongs to @lablup/ui-common-cli`, + ); +for (const name of CLI_DEPENDENCIES) { + if (pkg.dependencies?.[name]) { + failures.push( + `@lablup/ui-common depends on "${name}", which only the CLI needs; it belongs to @lablup/ui-common-cli`, + ); + } +} -const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8")); const packedSet = new Set(packed); /** Turn an exports target into a predicate over packed file paths. */ @@ -232,14 +282,92 @@ for (const { what, file, mustContain } of THIRD_PARTY_NOTICES) { } } +/** + * The CLI tarball: its bin, its modules and the data they read, nothing of the + * library's build, and a manifest a consumer can install next to the library. + */ +const { files: cliPacked, manifest: cliPkg } = pack(cliRoot); +const cliPackedSet = new Set(cliPacked); + +for (const file of cliPacked) { + for (const { pattern, reason } of [ + ...FORBIDDEN_IN_TARBALL, + { pattern: /^dist\//, reason: "the library's build ships in @lablup/ui-common" }, + ]) { + if (pattern.test(file)) failures.push(`unexpected CLI file "${file}": ${reason}`); + } +} +for (const required of [ + "migration/0.1-to-0.2.json", + "codemods/registry.mjs", + "cli/main.mjs", + "LICENSE", + "NOTICE", +]) { + if (!cliPackedSet.has(required)) failures.push(`the CLI tarball lacks "${required}"`); +} +const cliBin = cliPkg.bin?.["ui-common"]?.replace(/^\.\//, ""); +if (!cliBin) failures.push(`@lablup/ui-common-cli declares no "ui-common" bin`); +else if (!cliPackedSet.has(cliBin)) + failures.push(`the "ui-common" bin "${cliBin}" is not packed`); +if (cliPkg.version !== pkg.version) { + failures.push( + `@lablup/ui-common-cli is ${cliPkg.version} but @lablup/ui-common is ${pkg.version}; they are released in lockstep`, + ); +} +const cliPeer = cliPkg.peerDependencies?.["@lablup/ui-common"]; +if (cliPeer !== pkg.version) { + failures.push( + `@lablup/ui-common-cli's @lablup/ui-common peer is "${cliPeer}", not exactly ${pkg.version}`, + ); +} +for (const field of ["dependencies", "peerDependencies", "optionalDependencies"]) { + for (const [name, range] of Object.entries(cliPkg[field] ?? {})) { + if (String(range).startsWith("workspace:")) + failures.push(`@lablup/ui-common-cli ${field}["${name}"] is still "${range}"`); + } +} + +// Every import in a packed module resolves: a relative one to a packed file, +// a bare one to a dependency or peer the consumer is guaranteed to install. +const cliDeclared = new Set([ + ...Object.keys(cliPkg.dependencies ?? {}), + ...Object.keys(cliPkg.peerDependencies ?? {}), +]); +const builtins = new Set(builtinModules); +const anyImport = + /(?:\bimport\s*(?:[\w*{}\s,$]+\s*from\s*)?|\bexport\s*[\w*{}\s,$]+\s*from\s*|\bimport\s*\()\s*["']([.@\w][\w@./:-]*)["']/g; +for (const file of cliPacked.filter((f) => f.endsWith(".mjs"))) { + const code = await readFile(resolve(cliRoot, file), "utf8"); + for (const [, specifier] of code.matchAll(anyImport)) { + if (specifier.startsWith(".")) { + const target = posixNormalize(posixJoin(posixDirname(file), specifier)); + if (!cliPackedSet.has(target)) { + failures.push(`CLI "${file}" imports "${specifier}", which is not packed`); + } + continue; + } + if (specifier.startsWith("node:") || builtins.has(specifier)) continue; + const name = packageOf(specifier); + if (!cliDeclared.has(name)) { + failures.push( + `CLI "${file}" imports "${specifier}", but "${name}" is neither a dependency nor a peer`, + ); + } + } +} + if (failures.length > 0) { console.error(`Packed artifact check failed (${failures.length}):\n`); for (const f of failures) console.error(` ${f}`); - console.error(`\nPacked ${packed.length} file(s).`); + console.error( + `\nPacked ${packed.length} file(s) in @lablup/ui-common, ${cliPacked.length} in @lablup/ui-common-cli.`, + ); process.exit(1); } console.log( - `Packed artifact clean: ${packed.length} file(s), ` + - `${Object.keys(pkg.exports ?? {}).length} export path(s) resolve.`, + `Packed artifact clean: @lablup/ui-common ${packed.length} file(s), ` + + `${Object.keys(pkg.exports ?? {}).length} export path(s) resolve; ` + + `@lablup/ui-common-cli ${cliPacked.length} file(s), bin and imports resolve.`, ); From 74fdfae90f8f0c26af0df7b77709b2bd7a9aed8c Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:11:04 +0000 Subject: [PATCH 04/25] ci: publish @lablup/ui-common and @lablup/ui-common-cli in lockstep Both jobs check that the two packages share one version and, on a release, that it matches the tag. pnpm packs both, which rewrites the CLI's workspace:* peer to the exact version; npm then publishes the library tarball first and the CLI second, to each registry, keeping provenance on npmjs and the dry-run path on workflow_dispatch. Any prerelease now publishes under the next dist-tag and a plain version under latest. 0.2's alphas were approved for next, so the alpha tag 0.1 used is no longer moved. --- .github/workflows/publish.yml | 116 +++++++++++++++++++++++++--------- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1734c47..1ea32c2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -56,39 +56,68 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: pnpm publish --no-git-checks --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: pnpm pack && ls -la *.tgz + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://npm.pkg.github.com + done # GitHub Packages requires authentication even for public packages, so an # open-source consumer cannot install from it without every contributor @@ -130,46 +159,75 @@ jobs: - name: Verify before publishing run: pnpm run verify - - name: Confirm the release tag matches the package version - if: github.event_name == 'release' + # @lablup/ui-common (the root) and @lablup/ui-common-cli (packages/cli) + # are released together at one version: the CLI takes the library as an + # exact peer. + - name: Confirm both packages are at one version run: | - PKG_VERSION=$(node -p "require('./package.json').version") - TAG="${GITHUB_REF_NAME#v}" - if [ "$PKG_VERSION" != "$TAG" ]; then - echo "package.json is $PKG_VERSION but the release tag is $TAG" >&2 + LIB=$(node -p "require('./package.json').version") + CLI=$(node -p "require('./packages/cli/package.json').version") + if [ "$LIB" != "$CLI" ]; then + echo "@lablup/ui-common is $LIB but @lablup/ui-common-cli is $CLI" >&2 exit 1 fi + - name: Confirm the release tag matches the package versions + if: github.event_name == 'release' + run: | + TAG="${GITHUB_REF_NAME#v}" + for PKG in package.json packages/cli/package.json; do + PKG_VERSION=$(node -p "require('./$PKG').version") + if [ "$PKG_VERSION" != "$TAG" ]; then + echo "$PKG is $PKG_VERSION but the release tag is $TAG" >&2 + exit 1 + fi + done + + # Any prerelease goes to `next`, a plain version to `latest`. 0.1's + # prereleases went out under `alpha`; that tag is no longer moved. - name: Choose the dist-tag from the version id: tag run: | VERSION=$(node -p "require('./package.json').version") case "$VERSION" in - *-alpha*) TAG=alpha ;; - *-beta*) TAG=beta ;; - *-rc*) TAG=rc ;; - *-*) TAG=next ;; - *) TAG=latest ;; + *-*) TAG=next ;; + *) TAG=latest ;; esac echo "Publishing $VERSION under dist-tag $TAG" echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # pnpm packs both, rewriting the CLI's `workspace:*` peer to the exact + # version; the library goes first so the CLI's peer exists when it lands. + - name: Pack both packages + run: | + mkdir -p "$RUNNER_TEMP/packed" + pnpm pack --pack-destination "$RUNNER_TEMP/packed" + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP/packed" + ls -la "$RUNNER_TEMP/packed" - name: Confirm the credential is valid for npmjs run: npm whoami --registry https://registry.npmjs.org env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - # npm rather than pnpm here: provenance is an npm CLI feature and this - # is the one step that needs it. The tarball is identical either way, - # since both pack from the same files allowlist. + # npm publishes the tarballs pnpm packed: provenance is an npm CLI + # feature, and npm would leave the CLI's `workspace:*` peer unrewritten + # if it packed the directory itself. - name: Publish if: github.event_name == 'release' || inputs.dry_run == false - run: npm publish --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --provenance --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Dry run if: github.event_name == 'workflow_dispatch' && inputs.dry_run - run: npm publish --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + run: | + for PKG in lablup-ui-common lablup-ui-common-cli; do + npm publish "$RUNNER_TEMP/packed/$PKG-${{ steps.tag.outputs.version }}.tgz" --dry-run --access public --tag ${{ steps.tag.outputs.tag }} --registry https://registry.npmjs.org + done env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} From 752473d401de81cc8745e5712078c11e10fac9bd Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:13:22 +0000 Subject: [PATCH 05/25] ci: install the fixture with pnpm, beside the CLI tarball The external-install job installed the packed tarball with npm, which never reports a dependency's unapproved build script. Every consumer installs with pnpm 11, which fails with ERR_PNPM_IGNORED_BUILDS on Astryx's postinstall. The fixture now installs with pnpm as its own workspace, and fixture/pnpm-workspace.yaml carries the allowBuilds block a consumer needs for @astryxdesign/core and @astryxdesign/cli. It also installs the @lablup/ui-common-cli tarball as a devDependency and runs the bin from there: --version, --help, agents, a passed-through component lookup and an upgrade dry run. --- .github/workflows/ci.yml | 34 ++++++++++++++++++++++++++-------- .gitignore | 1 + fixture/pnpm-workspace.yaml | 12 ++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) create mode 100644 fixture/pnpm-workspace.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe235a1..ead0a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,20 +78,38 @@ jobs: run: | pnpm run build pnpm pack --pack-destination "$RUNNER_TEMP" - - - name: Install the tarball into a clean React project + pnpm --filter @lablup/ui-common-cli pack --pack-destination "$RUNNER_TEMP" + + # pnpm, as every consumer installs: pnpm 11 fails the install with + # ERR_PNPM_IGNORED_BUILDS when a dependency's build script is neither + # allowed nor declined, which npm never reports. fixture/pnpm-workspace.yaml + # holds the allowBuilds block a consumer needs, and makes the fixture its + # own workspace, apart from this repository's. + - name: Install the tarballs into a clean React project working-directory: fixture run: | - TARBALL=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-*.tgz' | head -1) - echo "Installing $TARBALL" - npm install --no-package-lock "$TARBALL" - npm install --no-package-lock + LIB=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-[0-9]*.tgz' | head -1) + CLI=$(find "$RUNNER_TEMP" -name 'lablup-ui-common-cli-*.tgz' | head -1) + echo "Installing $LIB and $CLI" + pnpm add "$LIB" + pnpm add --save-dev "$CLI" - name: Type-check and build the fixture against the packed artifact working-directory: fixture run: | - npx tsc --noEmit - npx vite build + pnpm exec tsc --noEmit + pnpm exec vite build + + # The CLI resolves the project's @lablup/ui-common and the Astryx CLI it + # pins from two different packages; this runs it where a consumer would. + - name: Run the ui-common bin from the fixture + working-directory: fixture + run: | + pnpm exec ui-common --version --verbose + pnpm exec ui-common --help > /dev/null + pnpm exec ui-common agents > /dev/null + pnpm exec ui-common component Button --json > /dev/null + pnpm exec ui-common upgrade --from 0.1 --dry-run > /dev/null # Building is not the same as being styled. 0.1.0-alpha.0 built here # green while shipping every component's CSS as an asset nothing could diff --git a/.gitignore b/.gitignore index 2d577cb..1e30d3e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage/ .eslintcache fixture/node_modules/ fixture/dist/ +fixture/pnpm-lock.yaml diff --git a/fixture/pnpm-workspace.yaml b/fixture/pnpm-workspace.yaml new file mode 100644 index 0000000..1f8eed6 --- /dev/null +++ b/fixture/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +# The fixture is a consumer, so it is its own pnpm workspace rather than a +# package of this repository's, and installs under a consumer's settings. +# +# This is the block a pnpm 11 consumer of @lablup/ui-common needs: Astryx's +# packages have a postinstall that only prints an `astryx init` nudge, and +# pnpm refuses to install (ERR_PNPM_IGNORED_BUILDS) until each one is either +# allowed or declined. Nothing needs to run, so both are declined. +allowBuilds: + # A dependency of @lablup/ui-common. + "@astryxdesign/core": false + # A dependency of @lablup/ui-common-cli (the ui-common bin). + "@astryxdesign/cli": false From cbc1ce7931970625964b83d6c625d13323f1ee4d Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:14:10 +0000 Subject: [PATCH 06/25] docs: document @lablup/ui-common-cli as the ui-common bin README's CLI and 0.1-upgrade sections now name the new entry points: a one-off pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 (or npx), and pnpm exec ui-common with the CLI as a devDependency pinned to the library's version, plus the allowBuilds block pnpm 11 needs. CONTRIBUTING and docs/astryx.md follow the moved paths under packages/cli. --- CONTRIBUTING.md | 26 +++++++++++++------------ README.md | 51 +++++++++++++++++++++++++++++++++++++++---------- docs/astryx.md | 35 +++++++++++++++++---------------- 3 files changed, 74 insertions(+), 38 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3bd204b..a79987f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -393,20 +393,22 @@ pre-built CSS at runtime. ## Bumping Astryx `@astryxdesign/core`, `@astryxdesign/theme-neutral` and `@astryxdesign/cli` -move together, exact-pinned. `@astryxdesign/lab` is an exact canary pin, as +move together, exact-pinned. The Astryx CLI is pinned twice: as a dependency +of `packages/cli` (`@lablup/ui-common-cli`, which runs it for consumers) and as +a devDependency of the root (its `theme:*` and `check:integration` scripts). `@astryxdesign/lab` is an exact canary pin, as both a devDependency and an optional peer. `ui-common sync-astryx` does the bump: ``` -node bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run -node bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --dry-run # plan, and Astryx's codemods on src/ as a dry run +node packages/cli/bin/ui-common.mjs sync-astryx 0.6.3 --lab 0.6.3-canary.abc1234 ``` -It moves the pins, runs `pnpm install`, `pnpm run gen:exports` and +It moves the pins in both `package.json` files, runs `pnpm install`, `pnpm run gen:exports` and `pnpm run theme:build`, runs Astryx's own codemods on `src/` (a dry run, then applied), runs the tests, and records the Astryx codemods consumers need in -`codemods//upstream.json` (`--as ` picks the version; +`packages/cli/codemods//upstream.json` (`--as ` picks the version; release under that version). `ui-common upgrade` runs them for a consumer that crosses it, with the `@lablup/ui-common` specifiers swapped for Astryx's so Astryx's codemods recognise them. It runs Astryx's codemods before the tests, @@ -424,26 +426,26 @@ Then, by hand: ## The upgrade tool -`ui-common upgrade` runs the steps in `codemods/registry.mjs`, keyed by the +`ui-common upgrade` runs the steps in `packages/cli/codemods/registry.mjs`, keyed by the ui-common version that made the change, over a consumer's source. -- **0.1 → 0.2** (`codemods/0.2/`) takes all of its data from - [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json): replacement +- **0.1 → 0.2** (`packages/cli/codemods/0.2/`) takes all of its data from + [`migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json): replacement imports, prop renames, value maps, required packages, stylesheet entry points, class renames and the manual notes its TODO markers quote. Change the map, not the codemods, when the migration changes. `codemods/0.2/legacy-classes.json` lists the 0.1 class names; regenerate it - from a 0.1 checkout with `scripts/extract-legacy-classes.mjs`. -- **Upstream steps** are `codemods//upstream.json`, written by + from a 0.1 checkout with `packages/cli/scripts/extract-legacy-classes.mjs`. +- **Upstream steps** are `packages/cli/codemods//upstream.json`, written by `sync-astryx`. A codemod that cannot prove a rewrite safe leaves the code as it was, with a -`TODO(ui-common-upgrade):` comment and a report entry. `test/upgrade/` runs +`TODO(ui-common-upgrade):` comment and a report entry. `packages/cli/test/upgrade/` runs every step over fixture projects and compares the result with `expected/`, report included. After an intended change: ``` -UPDATE_FIXTURES=1 pnpm vitest run test/upgrade +UPDATE_FIXTURES=1 pnpm vitest run packages/cli/test/upgrade ``` and read the diff. Fixtures are consumer code: keep them free of product diff --git a/README.md b/README.md index 5694eb8..0375bdd 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,10 @@ Peer dependencies: `@lablup/ui-common/lab`. It is pinned to the exact canary ui-common is built against, and it needs the override below. -Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`, -`@astryxdesign/cli`) comes in as ui-common's own dependencies, pinned exactly. +Astryx itself (`@astryxdesign/core`, `@astryxdesign/theme-neutral`) comes in +as ui-common's own dependencies, pinned exactly. The `ui-common` bin and the +Astryx CLI it wraps are a separate dev-time package, `@lablup/ui-common-cli` +([The ui-common CLI](#the-ui-common-cli)). `lucide-react` (the icon set Astryx's neutral theme already depends on) and `intl-messageformat` come in the same way. Do not add them to your project. ui-common owns the Astryx version. Two copies @@ -374,17 +376,23 @@ Removed in 0.2, each replaced by Astryx: The kept components keep their 0.1 props. Their class names moved to `uic-` (`page-header` is `uic-page-header`), so CSS or tests that select the old -names need updating. [`migration/0.1-to-0.2.json`](migration/0.1-to-0.2.json) +names need updating. [`migration/0.1-to-0.2.json`](packages/cli/migration/0.1-to-0.2.json) lists every import, prop, class and stylesheet change in a form the upgrade tool reads. -Let the upgrade tool do the mechanical part. After bumping the dependency: +Let the upgrade tool do the mechanical part. It ships in +`@lablup/ui-common-cli`, so run it one-off from the project still on 0.1: ``` -pnpm exec ui-common upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report -pnpm exec ui-common upgrade --from 0.1 # applies it +pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 --dry-run # writes nothing; prints the changes and the report +pnpm dlx @lablup/ui-common-cli upgrade --from 0.1 # applies it ``` +(`npx @lablup/ui-common-cli upgrade --from 0.1` with npm.) It bumps +`@lablup/ui-common` in `package.json` and adds `@lablup/ui-common-cli` as a +devDependency at the same version; then run your install, and later upgrades +are `pnpm exec ui-common upgrade --from `. + It moves the imports, reshapes the props it can prove safe, rewrites the `styles/base.css` import into the 0.2 stylesheet set, and updates `package.json`. Everything else is a `TODO(ui-common-upgrade)` comment in the @@ -413,8 +421,29 @@ Deprecated in 0.2, removed in 0.3: ## The ui-common CLI -ui-common ships a `ui-common` bin. It wraps the Astryx CLI that ui-common pins, -so a project needs no `@astryxdesign/*` dependency of its own to use it. +The `ui-common` bin is its own package, `@lablup/ui-common-cli`, released at +the same version as `@lablup/ui-common` and taking it as a peer. It wraps the +Astryx CLI it pins, so a project needs no `@astryxdesign/*` dependency of its +own to use it. Being separate keeps the Astryx CLI and the codemod toolchain +(jscodeshift, postcss) out of a production install, the way Astryx splits +`@astryxdesign/cli` from `@astryxdesign/core`. Keep it a devDependency pinned +to the same version as `@lablup/ui-common`, and bump the two together: + +``` +pnpm add -D @lablup/ui-common-cli@ +``` + +Under pnpm 11, allow or decline the Astryx packages' postinstall (it only +prints an `astryx init` nudge) in `pnpm-workspace.yaml`, or the install stops +with `ERR_PNPM_IGNORED_BUILDS`: + +```yaml +allowBuilds: + "@astryxdesign/core": false + "@astryxdesign/cli": false +``` + +Then: ``` pnpm exec ui-common component Button # any Astryx command: component, search, @@ -428,14 +457,16 @@ pnpm exec ui-common upgrade --from 0.1 --dry-run | `ui-common …` | Runs the pinned Astryx CLI and rewrites its output to ui-common: `@astryxdesign/core/` is `@lablup/ui-common/`, `@astryxdesign/lab` is `@lablup/ui-common/lab`, `@astryxdesign/theme-neutral` is `@lablup/ui-common/theme/neutral`, and commands read `ui-common …`. A name ui-common hides gets a note ("Use Modal, not Dialog"). `--json` output stays valid JSON; the note goes to stderr. The exit code is Astryx's. | | `ui-common astryx …` | The same, without rewriting. | | `ui-common agents [--write ] [--check]` | Prints the agent block: Astryx's `init --features agents` block, rewritten, plus ui-common's rules. It sits between `` and ``, which `astryx init` never touches. `--write` replaces the block in place and keeps the rest of the file; `--check` exits 1 when it is stale. | -| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). `--from` defaults to the version `package.json` declares, `--to` to the installed one. | +| `ui-common upgrade [--from ] [--to ] [--dry-run] [--diff] [--report ] [paths…]` | Runs the codemods between two ui-common versions over `src/` (or `paths`), updates `package.json`, and writes `ui-common-upgrade-report.md` (a `--dry-run` writes nothing and prints the report, unless `--report` names a file). `--from` defaults to the version `package.json` declares, `--to` to the CLI's own (the ui-common version it ships with). | | `ui-common sync-astryx [--lab ] [--as ] [--dry-run]` | Maintainers only; see [CONTRIBUTING.md](CONTRIBUTING.md#bumping-astryx). | Exit codes: a passed-through command exits with Astryx's code. ui-common's own commands exit 0 on success, 1 on a failed check or run, and 2 on bad arguments. `component`, `search` and the other lookups find `@astryxdesign/core` through -ui-common, so they work in a project that depends on ui-common alone. +the project's `@lablup/ui-common`, so they work in a project that depends on +ui-common (and the CLI) alone. Without the CLI installed, any command runs +one-off as `pnpm dlx @lablup/ui-common-cli ` (or `npx`). ui-common is also an Astryx CLI integration: `ui-common docs ui-common` (or `astryx docs ui-common`) explains the layer, and `ui-common component Modal` diff --git a/docs/astryx.md b/docs/astryx.md index 8d5cf8e..de1d1ba 100644 --- a/docs/astryx.md +++ b/docs/astryx.md @@ -155,9 +155,12 @@ ui-common's own bin. ## The ui-common CLI -`bin/ui-common.mjs` wraps the pinned `@astryxdesign/cli`. It resolves that CLI -from ui-common's own install location, so a consumer needs no Astryx -dependency. +`@lablup/ui-common-cli` (`packages/cli`, released at ui-common's version and +taking it as a peer) wraps the pinned `@astryxdesign/cli`. It resolves that CLI +from its own install location, and the Astryx packages ui-common pins from the +project's `@lablup/ui-common`, so a consumer needs no Astryx dependency. Kept +apart from the library, the CLI toolchain stays out of production installs, as +`@astryxdesign/cli` stays apart from `@astryxdesign/core`. - **Passthrough.** Any Astryx command runs the pinned bin; its output is rewritten from `@astryxdesign/*` to `@lablup/ui-common/*` and from `astryx …` @@ -177,20 +180,20 @@ dependency. `agents` and the upstream codemods use Astryx CLI internals (`foundation/agent-docs`, `assets/codemods/registry.mjs`) that are not its -public API. The pin is exact, and `test/cli/` exercises both, so a bump that +public API. The pin is exact, and `packages/cli/test/cli/` exercises both, so a bump that moves them fails the tests. ## Checks -| Command | What it guards | -| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `src/exports.test.ts` | generated surface matches the installed Astryx | -| `pnpm run theme:check` | built Lablup theme matches its source | -| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | -| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | -| `src/migrationMap.test.ts` | `migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | -| `pnpm run check:pack` | every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored | -| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | -| `test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | -| `test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | -| CI `external-install` | the tarball installs, type-checks and builds in a clean project, with Astryx's sheets in the bundle | +| Command | What it guards | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `src/exports.test.ts` | generated surface matches the installed Astryx | +| `pnpm run theme:check` | built Lablup theme matches its source | +| `src/globalStyles.test.ts` | legacy tokens cover all 122 names; layers; real tokens | +| `src/components/componentStyles.test.ts` | every component sheet: one `@layer ui-common`, `uic-` classes, Astryx tokens, no colour literal, no focus rule | +| `src/migrationMap.test.ts` | `packages/cli/migration/0.1-to-0.2.json` matches what was removed, what replaces it, and the renamed classes | +| `pnpm run check:pack` | both tarballs: every export target is packed; every bare import is a dependency or peer; every Astryx locale is mirrored; the library carries none of the CLI, the CLI none of `dist/`, at one version | +| `pnpm run check:integration` | the CLI accepts the manifest, and the tarball carries it | +| `packages/cli/test/upgrade/` | the upgrade codemods turn each fixture project into its expected output, and a second run changes nothing | +| `packages/cli/test/cli/` | output rewriting, the agent block, the registry, upstream codemods, and `sync-astryx`'s guards | +| CI `external-install` | both tarballs install with pnpm in a clean project, which type-checks and builds with Astryx's sheets in the bundle and runs the `ui-common` bin | From 7bf3b7d5cc87fb12b8560a0f2c478d539e0cbfd3 Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:15:41 +0000 Subject: [PATCH 07/25] build: scan the CLI package in the public boundary check The disclosure scan now covers what @lablup/ui-common-cli publishes: its README and NOTICE and its bin, cli, codemods and migration sources, which ship as written. ui-common-cli joins the allowlist as this repository's own package, and a period that ends a sentence after an allowed name no longer counts as part of the name. --- scripts/check-boundary.mjs | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/scripts/check-boundary.mjs b/scripts/check-boundary.mjs index 9275a5f..98dac32 100644 --- a/scripts/check-boundary.mjs +++ b/scripts/check-boundary.mjs @@ -90,16 +90,25 @@ for (const file of files) { * not been added here fails closed rather than slipping through because nobody * remembered to blocklist it. */ -// Public repositories, plus the sibling package name that the boundary rules -// above have to spell out in order to forbid it. Anything absent fails closed. -const ALLOWED_LABLUP_REFERENCES = ["all-smi", "backend.ai", "ui-common", "ui-ai"]; +// Public repositories, this repository's CLI package, plus the sibling package +// name that the boundary rules above have to spell out in order to forbid it. +// Anything absent fails closed. +const ALLOWED_LABLUP_REFERENCES = [ + "all-smi", + "backend.ai", + "ui-common", + "ui-common-cli", + "ui-ai", +]; const DISCLOSURE = [ { // `theme/lablup/...` is this package's own brand-theme path (source dir // and subpath), not a repository, so a `theme/` prefix is not a match. + // A period that ends a sentence ("depends on @lablup/ui-common.") is not + // part of the name; one followed by a word character is. pattern: new RegExp( - String.raw`(? Date: Tue, 29 Sep 2026 14:29:59 +0000 Subject: [PATCH 08/25] fix(upgrade): drop a local re-export of a removed type with its import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module that re-exports a 0.1 type through its own import (import type { X } from "…/DataTable"; export type { X };) lost the import but kept the export, so the output named an undefined binding and the file was refused as unparseable, leaving it unmigrated. The re-export now goes with the import, the same way a re-export straight from @lablup/ui-common already does, with a TODO on the export (or where it stood) telling importers they need a type of their own. No placeholder alias is declared: an `unknown` type would keep every importer compiling against a type that no longer exists. --- packages/cli/codemods/0.2/components.mjs | 66 ++++++++++++++++++++++ packages/cli/test/upgrade/codemods.test.ts | 51 +++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/packages/cli/codemods/0.2/components.mjs b/packages/cli/codemods/0.2/components.mjs index db4fb35..3e68be7 100644 --- a/packages/cli/codemods/0.2/components.mjs +++ b/packages/cli/codemods/0.2/components.mjs @@ -15,6 +15,7 @@ import { identifierNames, renameElement, tagName, + TODO_TAG, } from "../lib/jsx.mjs"; import { addTodo } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; @@ -270,6 +271,61 @@ function reuseNode(path, decls, kindKey) { } } +/** + * A removed 0.1 type with no Astryx counterpart loses its import. A local + * re-export of it (`import type { X } from …; export type { X };`) goes too, + * the same way a re-export straight from ui-common does: left behind it names + * nothing, and the file no longer parses. No placeholder type is declared in + * its place: an `unknown` alias would keep every importer compiling against a + * type that no longer exists. The TODO goes on the export, or where it stood. + * + * @param {any} j + * @param {any} root + * @param {Map} dropped + * @param {any} programScope + */ +function dropLocalReexports(j, root, dropped, programScope) { + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + if (node.source || node.declaration) return; + const specifiers = node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.local?.name && + dropped.has(s.local.name) && + isModuleBinding(path, s.local.name, programScope), + ); + if (gone.length === 0) return; + const names = gone.map((/** @type {any} */ s) => s.exported?.name ?? s.local.name); + const components = [ + ...new Set( + gone.map((/** @type {any} */ s) => dropped.get(s.local.name)?.component), + ), + ]; + const message = `${names.join(", ")} (removed with ${components.join(", ")} in 0.2, no Astryx counterpart) ${names.length === 1 ? "is" : "are"} no longer re-exported from here; modules importing ${names.length === 1 ? "it" : "them"} from here need a type of their own.`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + node.specifiers = rest; + addTodo(j, path, message); + return; + } + const comment = j.commentLine(` ${TODO_TAG}: ${message}`, true, false); + const comments = [...(node.comments ?? []), comment]; + const body = path.parent.node.body; + const index = body.indexOf(node); + path.prune(); + const next = body[index] ?? null; + if (next) next.comments = [...comments, ...(next.comments ?? [])]; + else if (body[index - 1]) { + const previous = body[index - 1]; + previous.comments = [ + ...(previous.comments ?? []), + ...comments.map((c) => ({ ...c, leading: false, trailing: true })), + ]; + } + }); +} + /** * @param {{source: string, path: string}} file * @param {{jscodeshift: any}} api @@ -302,6 +358,13 @@ export default function transform(file, api, ctx) { const plans = []; /** @type {any[]} */ const extra = []; + /** + * Local bindings of removed 0.1 types whose import was dropped: a local + * `export type { X }` of one goes with it (see dropLocalReexports). + * + * @type {Map} + */ + const droppedTypes = new Map(); /** * The local name to use for `name` from `@lablup/ui-common/`, @@ -413,6 +476,7 @@ export default function transform(file, api, ctx) { path, `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); + droppedTypes.set(local, { imported, component: resolved.component }); continue; } let finalLocal = local; @@ -514,6 +578,8 @@ export default function transform(file, api, ctx) { } }); + if (droppedTypes.size > 0) dropLocalReexports(j, root, droppedTypes, programScope); + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; if (typeof source !== "string" || !classify(source)) return; diff --git a/packages/cli/test/upgrade/codemods.test.ts b/packages/cli/test/upgrade/codemods.test.ts index 981a891..62fea33 100644 --- a/packages/cli/test/upgrade/codemods.test.ts +++ b/packages/cli/test/upgrade/codemods.test.ts @@ -179,3 +179,54 @@ export function D({ StatusTag }: { StatusTag: any }) { expect(out).not.toContain("is used as a value here"); }); }); + +describe("removed types that a module re-exports", () => { + it("drops the local re-export with the import, so the file still parses", () => { + const out = upgrade( + `import { DataTable as BaseDataTable } from "@lablup/ui-common/components/DataTable"; +import type { + DataTableProps as BaseProps, + DataTablePersistedState, + SortDirection, +} from "@lablup/ui-common/components/DataTable"; + +export type { DataTablePersistedState, SortDirection }; + +export function DataTable(props: BaseProps) { + return ; +} +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).not.toMatch(/export type \{[^}]*DataTablePersistedState/); + expect(out).toContain( + "// TODO(ui-common-upgrade): DataTablePersistedState, SortDirection (removed with DataTable in 0.2, no Astryx counterpart) are no longer re-exported from here;", + ); + // The TODO lands above the statement that followed the re-export. + expect(out).toMatch(/no longer re-exported[^\n]*\nexport function DataTable/); + }); + + it("keeps the other names of a mixed re-export, and an alias", () => { + const out = upgrade( + `import type { StatusKind } from "@lablup/ui-common/components/StatusTag"; +type Local = string; +export type { Local, StatusKind as Kind }; +`, + ); + expect(() => j(out)).not.toThrow(); + expect(out).toContain("export type { Local };"); + expect(out).toContain( + "TODO(ui-common-upgrade): Kind (removed with StatusTag in 0.2, no Astryx counterpart) is no longer re-exported from here", + ); + }); + + it("leaves a re-export of a local type with the same name alone", () => { + const out = upgrade( + `import { Badge } from "@lablup/ui-common"; +export type SortDirection = "asc" | "desc"; +export const B = () => x; +`, + ); + expect(out).toContain('export type SortDirection = "asc" | "desc";'); + }); +}); From dffac3a560cddc37ccaf6396eb807da3902c7d3a Mon Sep 17 00:00:00 2001 From: Jongeun Date: Tue, 29 Sep 2026 14:43:39 +0000 Subject: [PATCH 09/25] feat(upgrade): migrate elements imported through the project's own barrels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer that re-exports ui-common components from a module of its own (export { Button } from "@lablup/ui-common/components/Button", export *, or an import then export { … }) had the barrel rewritten under the 0.1 names while every module importing from it kept passing 0.1 props. The codemod now reads the project's modules as they were before the run, resolving relative imports and tsconfig paths/baseUrl, and follows re-exports through barrels. An element of a component imported that way gets the same rewrite as a direct import; the import itself stays, since the barrel keeps the 0.1 name. A removed type imported or re-exported through a barrel goes, as it does from ui-common. An import left unused once every element became another component (BaseCard -> ClickableCard) is removed. A component of the project's own that wraps a 0.1 one and takes its props (its props type is built on the 0.1 props type, or it spreads its props into the element) is not a re-export: its call sites are left alone and the report lists it under "Local wrappers around 0.1 components", with how many scanned modules import it. Imports through aliases the upgrade cannot resolve (bundler aliases) are named in the report's notes. Also fixes two cases the new fixture exposed: `import { StatusTag } …; export { StatusTag };` lost its public name on the rename, and a re-export of only removed types from ui-common was kept, pointing at a subpath 0.2 no longer has. A module the codemod empties keeps its TODOs on an `export {};`. --- README.md | 6 +- packages/cli/cli/resolve.mjs | 255 +++++++++ packages/cli/cli/upgrade.mjs | 42 ++ packages/cli/codemods/0.2/components.mjs | 231 +++++++- packages/cli/codemods/0.2/index.mjs | 2 + packages/cli/codemods/0.2/local-modules.mjs | 501 ++++++++++++++++++ packages/cli/codemods/0.2/scan.mjs | 4 + packages/cli/codemods/registry.mjs | 1 + .../expected/ui-common-upgrade-report.md | 4 + .../expected/ui-common-upgrade-report.md | 4 + .../expected/ui-common-upgrade-report.md | 4 + .../local-barrel/expected/package.json | 13 + .../src/components/common/Select/index.tsx | 11 + .../expected/src/components/common/index.ts | 9 + .../expected/src/components/common/status.ts | 6 + .../expected/src/components/common/table.ts | 3 + .../expected/src/pages/Overview.tsx | 24 + .../expected/src/pages/Settings.tsx | 13 + .../local-barrel/expected/tsconfig.json | 10 + .../expected/ui-common-upgrade-report.md | 96 ++++ .../fixtures/local-barrel/input/package.json | 9 + .../src/components/common/Select/index.tsx | 7 + .../input/src/components/common/index.ts | 7 + .../input/src/components/common/status.ts | 4 + .../input/src/components/common/table.ts | 3 + .../local-barrel/input/src/pages/Overview.tsx | 18 + .../local-barrel/input/src/pages/Settings.tsx | 12 + .../fixtures/local-barrel/input/tsconfig.json | 10 + .../expected/ui-common-upgrade-report.md | 4 + .../src/components/common/Select.test.tsx | 1 + .../expected/src/components/common/index.ts | 8 +- .../expected/ui-common-upgrade-report.md | 23 +- .../cli/test/upgrade/local-modules.test.ts | 159 ++++++ 33 files changed, 1469 insertions(+), 35 deletions(-) create mode 100644 packages/cli/cli/resolve.mjs create mode 100644 packages/cli/codemods/0.2/local-modules.mjs create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Settings.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/tsconfig.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/expected/ui-common-upgrade-report.md create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/package.json create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/Select/index.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/index.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/status.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/components/common/table.ts create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Overview.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/src/pages/Settings.tsx create mode 100644 packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json create mode 100644 packages/cli/test/upgrade/local-modules.test.ts diff --git a/README.md b/README.md index 0375bdd..4d5fff0 100644 --- a/README.md +++ b/README.md @@ -395,7 +395,11 @@ are `pnpm exec ui-common upgrade --from `. It moves the imports, reshapes the props it can prove safe, rewrites the `styles/base.css` import into the 0.2 stylesheet set, and updates -`package.json`. Everything else is a `TODO(ui-common-upgrade)` comment in the +`package.json`. Code that imports a moved component through a module of your +own that re-exports it (a barrel such as `@/components/common`, found through +relative paths and your tsconfig `paths`) gets the same rewrite. A component of +yours that wraps one and takes its props is listed in the report instead: its +props are yours to change. Everything else is a `TODO(ui-common-upgrade)` comment in the code and a line in `ui-common-upgrade-report.md`, together with the CSS, DOM queries, tests and module mocks that still name 0.1 classes, and custom properties of yours that Astryx declares too. diff --git a/packages/cli/cli/resolve.mjs b/packages/cli/cli/resolve.mjs new file mode 100644 index 0000000..2d93d20 --- /dev/null +++ b/packages/cli/cli/resolve.mjs @@ -0,0 +1,255 @@ +/** + * Resolve a project's own import specifiers to files, the way its bundler + * would for the common cases: relative paths, and the `paths` / `baseUrl` + * aliases its tsconfig declares (`@/components/common`). A specifier that + * resolves neither way and is not an installed package is recorded as an + * unresolved alias (a Vite `resolve.alias`, say), for the report. + * + * Only what `ui-common upgrade` needs: file-to-file resolution inside the + * project. Packages are never resolved. + */ +import { existsSync, readFileSync, statSync } from "node:fs"; +import { builtinModules } from "node:module"; +import { dirname, join, resolve } from "node:path"; + +const SCRIPT_EXTENSIONS = [ + ".tsx", + ".ts", + ".jsx", + ".js", + ".mts", + ".mjs", + ".cts", + ".cjs", +]; + +/** + * JSON with comments and trailing commas, as tsconfig allows. + * + * @param {string} text + */ +export function parseJsonc(text) { + let out = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '"') { + const start = i; + for (i++; i < text.length && text[i] !== '"'; i++) if (text[i] === "\\") i++; + out += text.slice(start, i + 1); + } else if (ch === "/" && text[i + 1] === "/") { + while (i < text.length && text[i] !== "\n") i++; + out += "\n"; + } else if (ch === "/" && text[i + 1] === "*") { + const end = text.indexOf("*/", i + 2); + i = end === -1 ? text.length : end + 1; + } else out += ch; + } + return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1")); +} + +/** @param {string} path */ +function isFile(path) { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +/** + * The file an import of `base` (no extension, or a `.js` one standing for a + * `.ts` source) loads: `base` itself, `base.`, or `base/index.`. + * + * @param {string} base absolute + */ +export function resolveFile(base) { + if (isFile(base) && SCRIPT_EXTENSIONS.some((e) => base.endsWith(e))) return base; + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${base}${ext}`)) return `${base}${ext}`; + const js = /\.(m|c)?jsx?$/.exec(base); + if (js) { + const stem = base.slice(0, js.index); + for (const ext of SCRIPT_EXTENSIONS) + if (isFile(`${stem}${ext}`)) return `${stem}${ext}`; + } + for (const ext of SCRIPT_EXTENSIONS) { + const index = join(base, `index${ext}`); + if (isFile(index)) return index; + } + return null; +} + +/** + * `compilerOptions` of a tsconfig, merged over what it extends (relative + * `extends` only), with `paths` and `baseUrl` made absolute. + * + * @param {string} file + * @param {Set} [seen] + * @returns {{baseUrl?: string, paths?: Record, pathsBase?: string}} + */ +function readTsconfig(file, seen = new Set()) { + if (seen.has(file) || !isFile(file)) return {}; + seen.add(file); + let json; + try { + json = parseJsonc(readFileSync(file, "utf8")); + } catch { + return {}; + } + const dir = dirname(file); + /** @type {{baseUrl?: string, paths?: Record, pathsBase?: string}} */ + let merged = {}; + const parents = Array.isArray(json.extends) ? json.extends : [json.extends]; + for (const parent of parents) { + if (typeof parent !== "string" || !parent.startsWith(".")) continue; + const target = resolve(dir, parent); + merged = { + ...merged, + ...readTsconfig(target.endsWith(".json") ? target : `${target}.json`, seen), + }; + } + const options = json.compilerOptions ?? {}; + if (typeof options.baseUrl === "string") + merged.baseUrl = resolve(dir, options.baseUrl); + if (options.paths && typeof options.paths === "object") { + merged.paths = options.paths; + merged.pathsBase = merged.baseUrl ?? dir; + } else if (merged.paths && typeof options.baseUrl === "string") { + merged.pathsBase = merged.baseUrl; + } + return merged; +} + +/** + * Every tsconfig of the project that may declare aliases: `tsconfig.json`, + * the configs it references, and the usual Vite split (`tsconfig.app.json`). + * + * @param {string} projectDir + */ +function projectTsconfigs(projectDir) { + const root = join(projectDir, "tsconfig.json"); + const files = [root, join(projectDir, "tsconfig.app.json")]; + try { + const json = parseJsonc(readFileSync(root, "utf8")); + for (const ref of json.references ?? []) { + if (typeof ref?.path !== "string") continue; + const target = resolve(projectDir, ref.path); + files.push(target.endsWith(".json") ? target : join(target, "tsconfig.json")); + } + } catch { + // No tsconfig, or one this cannot read: relative imports only. + } + return [...new Set(files)].filter(isFile); +} + +/** @param {string} specifier */ +function packageName(specifier) { + const parts = specifier.split("/"); + return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} + +const BUILTINS = new Set(builtinModules); + +/** + * @param {string} projectDir + */ +export function createResolver(projectDir) { + const configs = projectTsconfigs(projectDir).map((f) => readTsconfig(f)); + /** @type {Array<{prefix: string, suffix: string, wildcard: boolean, targets: string[]}>} */ + const aliases = []; + /** @type {string[]} */ + const baseUrls = []; + for (const config of configs) { + if (config.baseUrl) baseUrls.push(config.baseUrl); + if (!config.paths || !config.pathsBase) continue; + for (const [pattern, targets] of Object.entries(config.paths)) { + if (!Array.isArray(targets)) continue; + const star = pattern.indexOf("*"); + aliases.push({ + prefix: star === -1 ? pattern : pattern.slice(0, star), + suffix: star === -1 ? "" : pattern.slice(star + 1), + wildcard: star !== -1, + targets: targets.map((t) => + resolve(/** @type {string} */ (config.pathsBase), t), + ), + }); + } + } + // Longest prefix first, as TypeScript matches. + aliases.sort((a, b) => b.prefix.length - a.prefix.length); + + /** @type {Map} */ + const installed = new Map(); + /** @param {string} name */ + const isInstalled = (name) => { + let hit = installed.get(name); + if (hit !== undefined) return hit; + hit = false; + for (let dir = projectDir; ;) { + if (existsSync(join(dir, "node_modules", name))) { + hit = true; + break; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + installed.set(name, hit); + return hit; + }; + + /** Alias-like specifiers nothing resolved, by their first segment. @type {Map} */ + const unresolved = new Map(); + + /** + * @param {string} fromFile absolute + * @param {string} specifier + * @returns {string | null} + */ + function resolveImport(fromFile, specifier) { + if (specifier.startsWith(".") || specifier.startsWith("/")) { + return resolveFile(resolve(dirname(fromFile), specifier)); + } + if (specifier.includes(":") || BUILTINS.has(packageName(specifier))) return null; + let aliased = false; + for (const alias of aliases) { + let rest; + if (alias.wildcard) { + if (!specifier.startsWith(alias.prefix) || !specifier.endsWith(alias.suffix)) + continue; + rest = specifier.slice( + alias.prefix.length, + specifier.length - alias.suffix.length, + ); + } else if (specifier !== alias.prefix) continue; + aliased = true; + for (const target of alias.targets) { + const hit = resolveFile( + rest === undefined ? target : target.replace("*", rest), + ); + if (hit) return hit; + } + } + // A tsconfig alias to a stylesheet or JSON file: resolved, just not a script. + if (aliased) return null; + const name = packageName(specifier); + if (isInstalled(name)) return null; + for (const base of baseUrls) { + const hit = resolveFile(join(base, specifier)); + if (hit) return hit; + } + // Only what looks like an alias is worth reporting: a bare name that is + // not installed is as likely a package this checkout has not installed. + const first = specifier.split("/")[0]; + if ( + (/^[@~#$]/.test(specifier) && !/^@[\w-]/.test(specifier)) || + existsSync(join(projectDir, first)) + ) { + const key = `${first}/`; + unresolved.set(key, (unresolved.get(key) ?? 0) + 1); + } + return null; + } + + return { resolveImport, unresolved }; +} diff --git a/packages/cli/cli/upgrade.mjs b/packages/cli/cli/upgrade.mjs index 89fb5e2..89a881e 100644 --- a/packages/cli/cli/upgrade.mjs +++ b/packages/cli/cli/upgrade.mjs @@ -22,6 +22,7 @@ import { registeredVersions, stepsBetween } from "../codemods/registry.mjs"; import { TODO_TAG } from "../codemods/lib/jsx.mjs"; import { diffStat, unifiedDiff } from "./diff.mjs"; import { cliPackageJson, findProjectDir } from "./paths.mjs"; +import { createResolver } from "./resolve.mjs"; import { renderReport, REPORT_HEADING } from "./report.mjs"; import { coerce, compare, parse } from "./semver.mjs"; @@ -207,12 +208,43 @@ export async function runUpgrade(options) { const errors = []; /** @type {string[]} */ const notices = []; + const resolver = createResolver(projectDir); + /** @type {Map} */ + const unscanned = new Map(); const ctx = { from, to, flags: { packages: new Map(), touched: new Set() }, projectDir, note: (/** @type {string} */ message) => packageNotes.push(message), + /** + * A project file as it was before this run: the scanned sources from + * memory, anything else from disk (read once). Null outside the project + * or when unreadable. + * + * @param {string} path absolute + */ + source: (path) => { + const known = state.get(path); + if (known) return known.created ? null : known.original; + if ( + !path.startsWith(projectDir + sep) || + path.split(sep).includes("node_modules") + ) + return null; + if (!unscanned.has(path)) { + let text = null; + try { + text = readFileSync(path, "utf8"); + } catch { + // left null + } + unscanned.set(path, text); + } + return unscanned.get(path) ?? null; + }, + /** The project file an import specifier names (relative or a tsconfig alias), or null. */ + resolveImport: resolver.resolveImport, /** * Edit a project file outside the scanned sources (pnpm-workspace.yaml). * `edit` gets its current text (null: absent) and returns the new text, @@ -348,6 +380,7 @@ export async function runUpgrade(options) { const categories = {}; for (const { step } of steps) { Object.assign(categories, step.categories ?? {}); + if (step.findings) findings.push(...step.findings(ctx, rel)); if (!step.scan) continue; for (const [file, entry] of state) { if (entry.created || entry.project) continue; @@ -404,6 +437,15 @@ export async function runUpgrade(options) { if (pkgChanged && pkgAfter != null) writeFileSync(pkgFile, pkgAfter); } + if (resolver.unresolved.size > 0) { + const list = [...resolver.unresolved.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([prefix, n]) => `\`${prefix}\` (${n})`); + notices.push( + `Imports through ${list.join(", ")} did not resolve: the upgrade reads relative imports and tsconfig \`paths\`, not bundler aliases. Elements of 0.1 components imported through a project barrel that way were not migrated; check those modules by hand.`, + ); + } + const report = renderReport({ from, to, diff --git a/packages/cli/codemods/0.2/components.mjs b/packages/cli/codemods/0.2/components.mjs index 3e68be7..ab6c291 100644 --- a/packages/cli/codemods/0.2/components.mjs +++ b/packages/cli/codemods/0.2/components.mjs @@ -19,6 +19,7 @@ import { } from "../lib/jsx.mjs"; import { addTodo } from "../lib/todo.mjs"; import { ELEMENT_TRANSFORMS } from "./elements.mjs"; +import { importsLocalLegacy, localExports, registerWrapper } from "./local-modules.mjs"; import { MOVED, REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; export const meta = { @@ -167,8 +168,14 @@ function renameReferences(j, root, from, to, programScope) { return; } if (parent?.type === "ExportSpecifier" && parent.local === p.node) { - parent.exported = j.identifier(parent.exported?.name ?? from); - parent.local = j.identifier(to); + // A fresh node: recast prints a parsed specifier from its original + // fields, so editing local/exported in place loses the public name. + const spec = j.exportSpecifier.from({ + local: j.identifier(to), + exported: j.identifier(parent.exported?.name ?? from), + }); + if (parent.exportKind) spec.exportKind = parent.exportKind; + p.parent.replace(spec); return; } if (!isReference(p)) return; @@ -271,6 +278,62 @@ function reuseNode(path, decls, kindKey) { } } +/** @param {any} entry a local-module export */ +const isRemovedType = (entry) => entry?.kind === "type" && entry.removed === true; + +/** + * Remove a top-level statement, handing its comments (TODOs included) to the + * statement after it, or before it when it was the last. + * + * @param {any} j + * @param {any} root + * @param {any} path + */ +function pruneKeepingComments(j, root, path) { + const node = path.node; + const comments = node.comments ?? []; + const body = root.find(j.Program).get().node.body; + const index = body.indexOf(node); + path.prune(); + if (comments.length === 0 || index === -1) return; + const next = body[index]; + if (next) { + next.comments = [...comments, ...(next.comments ?? [])]; + return; + } + const previous = body[index - 1]; + if (previous) { + previous.comments = [ + ...(previous.comments ?? []), + ...comments.map((/** @type {any} */ c) => ({ + ...c, + leading: false, + trailing: true, + })), + ]; + return; + } + keepCommentsInEmptyModule(j, root.find(j.Program).get().node, comments); +} + +/** + * A module the codemod emptied keeps its TODOs on an `export {};`, which also + * keeps it a module. + * + * @param {any} j + * @param {any} program + * @param {any[]} comments + */ +function keepCommentsInEmptyModule(j, program, comments) { + const decl = j.exportNamedDeclaration(null, []); + decl.comments = comments.map((/** @type {any} */ c) => ({ + ...c, + leading: true, + trailing: false, + })); + program.body.push(decl); +} + /** * A removed 0.1 type with no Astryx counterpart loses its import. A local * re-export of it (`import type { X } from …; export type { X };`) goes too, @@ -309,20 +372,11 @@ function dropLocalReexports(j, root, dropped, programScope) { addTodo(j, path, message); return; } - const comment = j.commentLine(` ${TODO_TAG}: ${message}`, true, false); - const comments = [...(node.comments ?? []), comment]; - const body = path.parent.node.body; - const index = body.indexOf(node); - path.prune(); - const next = body[index] ?? null; - if (next) next.comments = [...comments, ...(next.comments ?? [])]; - else if (body[index - 1]) { - const previous = body[index - 1]; - previous.comments = [ - ...(previous.comments ?? []), - ...comments.map((c) => ({ ...c, leading: false, trailing: true })), - ]; - } + node.comments = [ + ...(node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); }); } @@ -332,8 +386,12 @@ function dropLocalReexports(j, root, dropped, programScope) { * @param {{flags: {packages: Map, touched: Set}}} ctx */ export default function transform(file, api, ctx) { - if (!file.source.includes(UIC)) return undefined; + const direct = file.source.includes(UIC); + const viaLocal = importsLocalLegacy(api.jscodeshift, ctx, file.path, file.source); + if (!direct && !viaLocal) return undefined; const j = api.jscodeshift; + // Registers the wrappers this module defines, for the report. + if (direct && ctx.source) localExports(j, ctx, file.path); const root = j(file.source); const isTS = /\.[cm]?tsx?$/.test(file.path); const taken = takenNames(j, root); @@ -391,10 +449,69 @@ export default function transform(file, api, ctx) { return local; }; + /** + * An import from a project module that hands 0.1 components on (a barrel + * the codemod rewrites under the 0.1 names): its components get the same + * element rewrite as a direct import, and the import itself stays. Its + * removed types go, as from ui-common. A wrapper's import is only counted. + * + * @param {any} path + * @param {string} source + */ + const localImport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const declIsType = path.node.importKind === "type"; + const kept = []; + for (const spec of path.node.specifiers ?? []) { + const name = + spec.type === "ImportSpecifier" + ? spec.imported.name + : spec.type === "ImportDefaultSpecifier" + ? "default" + : null; + const entry = name == null ? undefined : entries.get(name); + if (entry?.kind === "type" && entry.removed) { + touched = true; + addTodo( + j, + path, + `type ${entry.type} was removed with ${entry.component} in 0.2 and has no Astryx counterpart; ${source} no longer exports it.`, + ); + droppedTypes.set(spec.local.name, { + imported: entry.type, + component: entry.component, + }); + continue; + } + kept.push(spec); + if (entry?.kind === "wrapper") registerWrapper(ctx, entry, file.path); + if (entry?.kind !== "component") continue; + if (declIsType || spec.importKind === "type") continue; + touched = true; + ctx.flags.touched.add(entry.component); + bindings.set(spec.local.name, { + component: entry.component, + entry: REMOVED.get(entry.component), + local: spec.local.name, + jsxCount: 0, + valueRefs: 0, + add: null, + via: path, + }); + } + if (kept.length > 0) path.node.specifiers = kept; + else pruneKeepingComments(j, root, path); + }; + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localImport(path, source); + return; + } const specifiers = path.node.specifiers ?? []; if (specifiers.length === 0) return; const declIsType = path.node.importKind === "type"; @@ -494,16 +611,54 @@ export default function transform(file, api, ctx) { plans.push({ path, keep, adds }); }); + /** + * A type re-export from a project module (`export type { X }` from `./DataTable`) + * of a type that module no longer exports (its own import of X from ui-common + * was dropped): drop it too. + * + * @param {any} path + * @param {string} source + */ + const localReexport = (path, source) => { + const target = ctx.resolveImport?.(file.path, source); + const entries = target ? localExports(j, ctx, target) : null; + if (!entries || entries.size === 0) return; + const specifiers = path.node.specifiers ?? []; + const gone = specifiers.filter( + (/** @type {any} */ s) => + s.type === "ExportSpecifier" && isRemovedType(entries.get(s.local?.name)), + ); + if (gone.length === 0) return; + touched = true; + const names = gone.map((/** @type {any} */ s) => s.exported.name); + const message = `${names.join(", ")} ${names.length === 1 ? "is" : "are"} no longer re-exported: ${source} does not export ${names.length === 1 ? "it" : "them"} any more (removed in 0.2, no Astryx counterpart).`; + const rest = specifiers.filter((/** @type {any} */ s) => !gone.includes(s)); + if (rest.length > 0) { + path.node.specifiers = rest; + addTodo(j, path, message); + } else { + path.node.comments = [ + ...(path.node.comments ?? []), + j.commentLine(` ${TODO_TAG}: ${message}`, true, false), + ]; + pruneKeepingComments(j, root, path); + } + }; + // Re-exports: `export { Select } from "@lablup/ui-common"` keeps its name. root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { const source = path.node.source?.value; const from = typeof source === "string" ? classify(source) : null; - if (!from) return; + if (!from) { + if (viaLocal && typeof source === "string") localReexport(path, source); + return; + } const declIsType = path.node.exportKind === "type"; const keep = []; /** @type {Map} */ const moved = new Map(); let reexportedComponent = false; + let droppedSpecs = 0; for (const spec of path.node.specifiers ?? []) { const localName = spec.local?.name ?? spec.exported.name; const imported = @@ -531,6 +686,7 @@ export default function transform(file, api, ctx) { `type ${imported} was removed with ${resolved.component} in 0.2 and has no Astryx counterpart.`, ); touched = true; + droppedSpecs++; continue; } target = resolved.to; @@ -554,7 +710,13 @@ export default function transform(file, api, ctx) { ); moved.set(key, list); } - if (moved.size === 0) return; + if (moved.size === 0) { + // Only removed types: they go, and the TODO stays where they stood. + if (droppedSpecs === 0) return; + if (keep.length > 0) path.node.specifiers = keep; + else pruneKeepingComments(j, root, path); + return; + } const decls = [...moved.entries()].map(([key, specs]) => { const [kind, targetSource] = key.split("\u0000"); const decl = j.exportNamedDeclaration(null, specs, j.stringLiteral(targetSource)); @@ -564,7 +726,7 @@ export default function transform(file, api, ctx) { if (reexportedComponent) { decls[0].comments = [ j.commentLine( - " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now; modules importing it from here still pass 0.1 props and need the same migration.", + " TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props.", true, false, ), @@ -587,7 +749,7 @@ export default function transform(file, api, ctx) { addTodo( j, path, - `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names; modules importing them from here still pass 0.1 props.`, + `\`export *\` from ${source} now re-exports Astryx's Badge, Button, Tooltip, … under the 0.1 names. The upgrade migrated the elements of them in the modules it scanned that import them from here; any other importer still passes 0.1 props.`, ); }); @@ -664,6 +826,29 @@ export default function transform(file, api, ctx) { }); } + // A local-barrel import whose every element became another component + // (Button → IconButton) is unused now. + for (const [local, binding] of bindings) { + if (!binding.via || binding.via.pruned) continue; + let used = false; + root.findJSXElements(local).forEach((/** @type {any} */ p) => { + if (isModuleBinding(p, local, programScope)) used = true; + }); + root.find(j.Identifier, { name: local }).forEach((/** @type {any} */ p) => { + if (p.node.type === "JSXIdentifier" || !isReference(p)) return; + if (isModuleBinding(p, local, programScope)) used = true; + }); + if (used) continue; + const node = binding.via.node; + node.specifiers = (node.specifiers ?? []).filter( + (/** @type {any} */ s) => s.local?.name !== local, + ); + if (node.specifiers.length === 0) { + pruneKeepingComments(j, root, binding.via); + binding.via.pruned = true; + } + } + for (const [from, to] of renames) renameReferences(j, root, from, to, programScope); // Imports: the planned moves, minus a Card import every BaseCard outgrew. @@ -671,6 +856,7 @@ export default function transform(file, api, ctx) { [...bindings.values()] .filter( (b) => + b.add != null && b.component === "BaseCard" && b.jsxCount === 0 && b.valueRefs === 0 && @@ -695,6 +881,7 @@ export default function transform(file, api, ctx) { const program = root.find(j.Program).get().node; const first = program.body[0]; if (first) first.comments = [...comments, ...(first.comments ?? [])]; + else keepCommentsInEmptyModule(j, program, comments); } } } diff --git a/packages/cli/codemods/0.2/index.mjs b/packages/cli/codemods/0.2/index.mjs index 51ea089..24c1f49 100644 --- a/packages/cli/codemods/0.2/index.mjs +++ b/packages/cli/codemods/0.2/index.mjs @@ -3,6 +3,7 @@ * `migration/0.1-to-0.2.json` (see ./map.mjs). */ import transformComponents, { meta as componentsMeta } from "./components.mjs"; +import { wrapperFindings } from "./local-modules.mjs"; import { LAB_CSS, LAB_PACKAGE, REMOVED, UIC } from "./map.mjs"; import { transformPackageJson } from "./package-json.mjs"; import { CATEGORIES, scanFile } from "./scan.mjs"; @@ -54,6 +55,7 @@ export default { ], packageJson: transformPackageJson, scan: scanFile, + findings: wrapperFindings, categories: CATEGORIES, notes, }; diff --git a/packages/cli/codemods/0.2/local-modules.mjs b/packages/cli/codemods/0.2/local-modules.mjs new file mode 100644 index 0000000..2dffc07 --- /dev/null +++ b/packages/cli/codemods/0.2/local-modules.mjs @@ -0,0 +1,501 @@ +/** + * The project's own modules that hand ui-common 0.1 components on: barrels + * (`export { Button } from "@lablup/ui-common/components/Button"`, + * `export * from …`, an import then `export { … }`, `export const X = Y`) and + * wrappers (an exported component that renders one). + * + * The components codemod rewrites such a barrel in place and keeps the 0.1 + * public name, so an import of `Select` from the barrel (`@/components/common`) now gets + * Astryx's Selector. Its call sites still pass 0.1 props: this module tells + * the codemod which local imports stand for which 0.1 component, so the same + * element rewrite runs on them. A wrapper is not a re-export: its props are + * its own, so its call sites are left alone and the wrapper is reported. + * + * Every module is read as it was before this run (the barrel itself is being + * rewritten), through the runner's `ctx.source` and `ctx.resolveImport`. + */ +import { REMOVED, REMOVED_TYPES, UIC } from "./map.mjs"; + +/** + * @typedef {{kind: 'component', component: string} + * | {kind: 'type', component: string, type: string, removed: boolean} + * | {kind: 'wrapper', components: string[], file: string, line: number, name: string}} LocalExport + * + * @typedef {object} LocalModuleContext + * @property {(path: string) => string | null} [source] a file's content before this run + * @property {(from: string, specifier: string) => string | null} [resolveImport] + */ + +/** @type {WeakMap>, wrappers: Map}>}>} */ +const caches = new WeakMap(); + +/** @param {object} ctx */ +function cacheFor(ctx) { + let cache = caches.get(ctx); + if (!cache) { + cache = { modules: new Map(), wrappers: new Map() }; + caches.set(ctx, cache); + } + return cache; +} + +/** + * What a ui-common specifier's name stands for in 0.1. + * + * @param {string} source + * @param {string} name imported name (`default` for a default import) + * @returns {LocalExport | null} + */ +function uicName(source, name) { + let imported = name; + if (source !== UIC) { + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (!match) return null; + if (name === "default") imported = match[1]; + } + if (REMOVED.has(imported)) return { kind: "component", component: imported }; + const type = REMOVED_TYPES.get(imported); + if (type) + return { + kind: "type", + component: type.component, + type: imported, + removed: type.to == null, + }; + return null; +} + +/** + * Every removed name a ui-common specifier offers, for `export *`. + * + * @param {string} source + * @returns {Map} + */ +function uicStar(source) { + /** @type {Map} */ + const out = new Map(); + const match = /^@lablup\/ui-common\/components\/([A-Za-z]+)$/.exec(source); + if (source !== UIC && !match) return out; + for (const [name, removed] of REMOVED) { + if (match && name !== match[1]) continue; + out.set(name, { kind: "component", component: name }); + for (const [type, to] of Object.entries(removed.types)) { + out.set(type, { kind: "type", component: name, type, removed: to == null }); + } + } + return out; +} + +/** @param {string} source */ +const isUic = (source) => source === UIC || source.startsWith(`${UIC}/components/`); + +/** + * Import sources a file names, without parsing it. + * + * @param {string} source + */ +function importSources(source) { + const out = new Set(); + for (const m of source.matchAll(/\b(?:from|import)\s*["']([^"']+)["']/g)) + out.add(m[1]); + return out; +} + +/** Strip parentheses and TS casts. @param {any} node */ +function unwrap(node) { + let n = node; + while ( + n && + (n.type === "TSAsExpression" || + n.type === "TSSatisfiesExpression" || + n.type === "TSNonNullExpression" || + n.type === "ParenthesizedExpression") + ) + n = n.expression; + return n; +} + +/** + * The function a component declaration defines: a function declaration, or + * the function a `const` holds, through `forwardRef(…)` / `memo(…)`, or the + * declaration `memo(Name)` names. + * + * @param {any} decl + * @param {Map} [declarations] the module's top-level declarations + * @param {number} [depth] + */ +function componentFunction(decl, declarations = new Map(), depth = 0) { + if (decl.type === "FunctionDeclaration") return decl; + let init = decl.type === "VariableDeclarator" ? unwrap(decl.init) : null; + while (init?.type === "CallExpression") { + init = unwrap( + init.arguments.find( + (/** @type {any} */ a) => + a.type === "ArrowFunctionExpression" || + a.type === "FunctionExpression" || + a.type === "CallExpression" || + a.type === "Identifier", + ), + ); + } + // `memo(EmptyStateComponent)`: the component is declared on its own. + if (init?.type === "Identifier" && depth < 3) { + const target = declarations.get(init.name); + return target ? componentFunction(target, declarations, depth + 1) : null; + } + return init?.type === "ArrowFunctionExpression" || init?.type === "FunctionExpression" + ? init + : null; +} + +/** + * The names a component's props parameter binds: the props object, or each + * destructured prop and the rest. + * + * @param {any} param + * @returns {Set} + */ +function paramNames(param) { + const out = new Set(); + const p = param?.type === "AssignmentPattern" ? param.left : param; + if (p?.type === "Identifier") out.add(p.name); + else if (p?.type === "ObjectPattern") { + for (const prop of p.properties) { + if (prop.type === "RestElement" && prop.argument.type === "Identifier") + out.add(prop.argument.name); + else if (prop.value?.type === "Identifier") out.add(prop.value.name); + else if ( + prop.value?.type === "AssignmentPattern" && + prop.value.left.type === "Identifier" + ) + out.add(prop.value.left.name); + } + } + return out; +} + +/** + * The 0.1 components (and removed types) a project module exports, by + * exported name. Empty for a module that hands on none. + * + * @param {any} j jscodeshift + * @param {LocalModuleContext} ctx + * @param {string} file absolute + * @param {Set} [stack] modules being read, against cycles + * @returns {Map} + */ +export function localExports(j, ctx, file, stack = new Set()) { + const cache = cacheFor(ctx); + const hit = cache.modules.get(file); + if (hit) return hit; + /** @type {Map} */ + const out = new Map(); + const source = ctx.source?.(file); + if (source == null || stack.has(file) || !/\bexport\b/.test(source)) { + if (!stack.has(file)) cache.modules.set(file, out); + return out; + } + // Cheap bail-out: nothing from ui-common, directly or through another module. + const sources = [...importSources(source)]; + if (!sources.some((s) => isUic(s) || !/^[\w@]/.test(s) || s.startsWith("@/"))) { + cache.modules.set(file, out); + return out; + } + stack.add(file); + let root; + try { + // Parse by the module's own extension, not the importer's. + const parser = /\.[cm]?tsx?$/.test(file) ? "tsx" : "babel"; + root = (typeof j.withParser === "function" ? j.withParser(parser) : j)(source); + } catch { + stack.delete(file); + cache.modules.set(file, out); + return out; + } + + /** @param {string} specifier */ + const moduleExports = (specifier) => { + const target = ctx.resolveImport?.(file, specifier); + return target ? localExports(j, ctx, target, stack) : new Map(); + }; + + // Local bindings that stand for a 0.1 component or type. + /** @type {Map} */ + const bindings = new Map(); + root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source.value; + if (typeof from !== "string") return; + for (const spec of path.node.specifiers ?? []) { + if (spec.type === "ImportNamespaceSpecifier") continue; + const name = + spec.type === "ImportDefaultSpecifier" ? "default" : spec.imported.name; + const entry = isUic(from) ? uicName(from, name) : moduleExports(from).get(name); + if (entry) bindings.set(spec.local.name, entry); + } + }); + + // Top-level declarations, for `export { X }` of a local and for wrappers. + /** @type {Map} */ + const declarations = new Map(); + /** @type {Map} */ + const typeDeclarations = new Map(); + for (const statement of root.find(j.Program).get().node.body) { + const node = + statement.type === "ExportNamedDeclaration" || + statement.type === "ExportDefaultDeclaration" + ? statement.declaration + : statement; + if (!node) continue; + if ( + (node.type === "FunctionDeclaration" || node.type === "ClassDeclaration") && + node.id + ) { + declarations.set(node.id.name, node); + } else if (node.type === "VariableDeclaration") { + for (const d of node.declarations) { + if (d.id?.type === "Identifier") declarations.set(d.id.name, d); + } + } else if ( + (node.type === "TSInterfaceDeclaration" || + node.type === "TSTypeAliasDeclaration") && + node.id + ) { + typeDeclarations.set(node.id.name, node); + } + } + + /** + * The 0.1 components whose props type a props parameter's type is built + * on: `SelectProps`, `Omit, …>`, an intersection with + * one, or a local interface or type alias that extends or aliases one. + * + * @param {any} param + * @returns {Set} + */ + const propsTypeComponents = (param) => { + const out = new Set(); + const seen = new Set(); + const WRAPPING = new Set(["Omit", "Partial", "Pick", "Readonly", "Required"]); + /** + * @param {string} name + * @param {any} args type arguments + */ + const reference = (name, args) => { + const bound = bindings.get(name); + if (bound?.kind === "type" && /Props$/.test(bound.type)) out.add(bound.component); + if (WRAPPING.has(name)) visit(args?.params?.[0]); + const local = typeDeclarations.get(name); + if (local && !seen.has(name)) { + seen.add(name); + if (local.type === "TSTypeAliasDeclaration") visit(local.typeAnnotation); + for (const heritage of local.extends ?? []) { + const id = heritage.expression; + if (id?.type === "Identifier") + reference(id.name, heritage.typeParameters ?? heritage.typeArguments); + } + } + }; + /** Only what the props type is made of, not the types of its members. @param {any} t */ + const visit = (t) => { + if (!t) return; + switch (t.type) { + case "TSTypeAnnotation": + case "TSParenthesizedType": + visit(t.typeAnnotation); + break; + case "TSIntersectionType": + case "TSUnionType": + t.types.forEach(visit); + break; + case "TSTypeReference": + if (t.typeName?.type === "Identifier") + reference(t.typeName.name, t.typeParameters ?? t.typeArguments); + break; + default: + } + }; + const p = param?.type === "AssignmentPattern" ? param.left : param; + visit(p?.typeAnnotation); + return out; + }; + + /** + * What exporting local `name` hands on: an aliased 0.1 binding, or a + * wrapper that renders one. + * + * @param {string} name + * @param {string} exported + * @returns {LocalExport | null} + */ + const describeLocal = (name, exported) => { + const bound = bindings.get(name); + if (bound) return bound; + const decl = declarations.get(name); + if (!decl) return null; + if (decl.type === "VariableDeclarator" && decl.init?.type === "Identifier") { + return bindings.get(decl.init.name) ?? describeLocal(decl.init.name, exported); + } + const fn = componentFunction(decl, declarations); + if (!fn) return null; + const params = paramNames(fn.params[0]); + // Its callers pass 0.1 props when its props type is built on a 0.1 one… + const typed = propsTypeComponents(fn.params[0]); + /** @param {any} expression */ + const fromParams = (expression) => { + const e = unwrap(expression); + return e?.type === "Identifier" && params.has(e.name); + }; + const rendered = new Set(); + j(fn) + .find(j.JSXElement) + .forEach((/** @type {any} */ p) => { + const tag = p.node.openingElement.name; + if (tag.type !== "JSXIdentifier") return; + const entry = bindings.get(tag.name); + const components = + entry?.kind === "component" + ? [entry.component] + : entry?.kind === "wrapper" + ? entry.components + : []; + if (components.length === 0) return; + // …or when it spreads its own props (or their rest) into one. + const spreads = (p.node.openingElement.attributes ?? []).some( + (/** @type {any} */ a) => + a.type === "JSXSpreadAttribute" && fromParams(a.argument), + ); + if (spreads || components.some((c) => typed.has(c))) + for (const c of components) rendered.add(c); + }); + if (rendered.size === 0) return null; + return { + kind: "wrapper", + components: [...rendered].sort(), + file, + line: + decl.loc?.start.line ?? + (typeof decl.start === "number" + ? source.slice(0, decl.start).split("\n").length + : 0), + name: exported === "default" ? name : exported, + }; + }; + + root.find(j.ExportNamedDeclaration).forEach((/** @type {any} */ path) => { + const node = path.node; + const from = node.source?.value; + if (typeof from === "string") { + const theirs = isUic(from) ? null : moduleExports(from); + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const name = spec.local?.name ?? spec.exported.name; + const entry = theirs ? theirs.get(name) : uicName(from, name); + if (entry) out.set(spec.exported.name, entry); + } + return; + } + if (node.declaration) { + const decl = node.declaration; + const names = + decl.type === "VariableDeclaration" + ? decl.declarations + .filter((/** @type {any} */ d) => d.id?.type === "Identifier") + .map((/** @type {any} */ d) => d.id.name) + : decl.id + ? [decl.id.name] + : []; + for (const name of names) { + const entry = describeLocal(name, name); + if (entry) out.set(name, entry); + } + return; + } + for (const spec of node.specifiers ?? []) { + if (spec.type !== "ExportSpecifier") continue; + const entry = describeLocal(spec.local.name, spec.exported.name); + if (entry) out.set(spec.exported.name, entry); + } + }); + + root.find(j.ExportDefaultDeclaration).forEach((/** @type {any} */ path) => { + const decl = path.node.declaration; + let entry = null; + if (decl.type === "Identifier") entry = describeLocal(decl.name, "default"); + else if (decl.id?.name) entry = describeLocal(decl.id.name, "default"); + if (entry) out.set("default", entry); + }); + + root.find(j.ExportAllDeclaration).forEach((/** @type {any} */ path) => { + const from = path.node.source?.value; + if (typeof from !== "string" || path.node.exported) return; + const theirs = isUic(from) ? uicStar(from) : moduleExports(from); + for (const [name, entry] of theirs) { + if (name !== "default" && !out.has(name)) out.set(name, entry); + } + }); + + stack.delete(file); + cache.modules.set(file, out); + for (const entry of out.values()) { + if (entry.kind === "wrapper") registerWrapper(ctx, entry); + } + return out; +} + +/** + * @param {object} ctx + * @param {LocalExport & {kind: 'wrapper'}} def + * @param {string} [importer] + */ +export function registerWrapper(ctx, def, importer) { + const { wrappers } = cacheFor(ctx); + const key = `${def.file}\u0000${def.name}`; + const known = wrappers.get(key) ?? { def, importers: new Set() }; + if (importer) known.importers.add(importer); + wrappers.set(key, known); +} + +/** + * Whether a file imports anything from a project module that hands on a 0.1 + * component or type, without parsing the file itself. + * + * @param {any} j + * @param {LocalModuleContext} ctx + * @param {string} file + * @param {string} source + */ +export function importsLocalLegacy(j, ctx, file, source) { + if (!ctx.resolveImport) return false; + for (const specifier of importSources(source)) { + if (specifier.startsWith(UIC)) continue; + const target = ctx.resolveImport(file, specifier); + if (target && localExports(j, ctx, target).size > 0) return true; + } + return false; +} + +/** + * The wrappers the run met, for the report: one finding per wrapper. + * + * @param {object} ctx + * @param {(file: string) => string} rel + */ +export function wrapperFindings(ctx, rel) { + const cache = caches.get(ctx); + if (!cache) return []; + return [...cache.wrappers.values()] + .sort((a, b) => a.def.file.localeCompare(b.def.file) || a.def.line - b.def.line) + .map(({ def, importers }) => { + const targets = def.components.map((c) => { + const to = REMOVED.get(c)?.to; + return to && to !== c ? `${c} (Astryx ${to})` : c; + }); + return { + category: "local-wrapper", + file: rel(def.file), + line: def.line, + text: def.name, + detail: `local wrapper around ${targets.join(", ")}: review its props.${importers.size > 0 ? ` Imported by ${importers.size} scanned module${importers.size === 1 ? "" : "s"}, whose props were not migrated.` : ""}`, + }; + }); +} diff --git a/packages/cli/codemods/0.2/scan.mjs b/packages/cli/codemods/0.2/scan.mjs index 0681740..9100a7a 100644 --- a/packages/cli/codemods/0.2/scan.mjs +++ b/packages/cli/codemods/0.2/scan.mjs @@ -303,6 +303,10 @@ export const CATEGORIES = { title: "Custom properties that collide with Astryx tokens", help: "Astryx declares the same name. Whichever rule wins the cascade now restyles both your CSS and Astryx's components. Rename yours, or set it through a theme (`defineTheme`) on purpose.", }, + "local-wrapper": { + title: "Local wrappers around 0.1 components", + help: "Your own component renders a 0.1 component and hands its props on, so it now renders the Astryx one. Its call sites pass the wrapper's props, which the upgrade does not rewrite: check the wrapper's props type and what it passes on against the Astryx component. A pure re-export (`export { Button } from …`) is not listed: its call sites were migrated.", + }, "stylesheet-path": { title: "0.1 stylesheet paths left in place", help: "Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them.", diff --git a/packages/cli/codemods/registry.mjs b/packages/cli/codemods/registry.mjs index b07f73d..5f3bef8 100644 --- a/packages/cli/codemods/registry.mjs +++ b/packages/cli/codemods/registry.mjs @@ -29,6 +29,7 @@ import { compare, parse } from "../cli/semver.mjs"; * @property {Transform[]} transforms * @property {(text: string, ctx: any) => string | undefined} [packageJson] * @property {(file: string, source: string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [scan] + * @property {(ctx: any, rel: (file: string) => string) => Array<{category: string, file: string, line: number, text: string, detail?: string}>} [findings] report findings the transforms collected, after they ran * @property {Record} [categories] * @property {string[] | ((ctx: any) => string[])} [notes] report notes, or a function of the run */ diff --git a/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md index 1807da4..b49e977 100644 --- a/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/adapter/expected/ui-common-upgrade-report.md @@ -75,6 +75,10 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both |---|---|---| | `src/design-system/common-components.css:6` | `:root { --color-error: #d4380d }` | --color-error | +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md index 787dddf..60740c2 100644 --- a/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/css-entry/expected/ui-common-upgrade-report.md @@ -55,6 +55,10 @@ None. None. +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (1) Scripts, configs or tests that name `@lablup/ui-common/styles/*` directly. base.css and the orange themes are deprecated in 0.2 and removed in 0.3; the Lablup theme replaces them. diff --git a/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md index 8228206..713cf07 100644 --- a/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/library/expected/ui-common-upgrade-report.md @@ -59,6 +59,10 @@ None. None. +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json b/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json new file mode 100644 index 0000000..38441db --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/package.json @@ -0,0 +1,13 @@ +{ + "name": "local-barrel-app", + "private": true, + "type": "module", + "dependencies": { + "@lablup/ui-common": "0.2.0-alpha.0", + "@stylexjs/stylex": "^0.19.0", + "react": "^19.2.0" + }, + "devDependencies": { + "@lablup/ui-common-cli": "0.2.0-alpha.0" + } +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx new file mode 100644 index 0000000..db3aa35 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/Select/index.tsx @@ -0,0 +1,11 @@ +import { Selector as BaseSelect } from "@lablup/ui-common/Selector"; +import type { SelectorProps } from "@lablup/ui-common/Selector"; + +// Supplies the empty-search line the package cannot localise. +export function Select({ noOptionsLabel, ...rest }: SelectorProps) { + return ( + // TODO(ui-common-upgrade): props spread into are not migrated; check them against Astryx Selector's props. + // TODO(ui-common-upgrade): `label` is required and is a string; a node label needs a string for the accessible name. + + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts new file mode 100644 index 0000000..530edca --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/index.ts @@ -0,0 +1,9 @@ +// The app's one import point for shared UI. +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +export { Button } from "@lablup/ui-common/Button"; +// TODO(ui-common-upgrade): re-exported under the 0.1 name, but the component is Astryx's now. The upgrade migrated the elements of it in the modules it scanned that import it from here; any other importer still passes 0.1 props. +export { Card as BaseCard } from "@lablup/ui-common/Card"; +// TODO(ui-common-upgrade): type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart. +export { Select } from "./Select"; +// TODO(ui-common-upgrade): SortDirection is no longer re-exported: ./table does not export it any more (removed in 0.2, no Astryx counterpart). +export * from "./status"; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts new file mode 100644 index 0000000..7b88d66 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/status.ts @@ -0,0 +1,6 @@ +import { StatusDot } from "@lablup/ui-common/StatusDot"; +import { ProgressBar } from "@lablup/ui-common/ProgressBar"; + +export { StatusDot as StatusTag }; +// TODO(ui-common-upgrade): ProgressBar is used as a value here; props passed to it this way are not migrated to Astryx ProgressBar. +export const Meter = ProgressBar; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts new file mode 100644 index 0000000..b3197d3 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/components/common/table.ts @@ -0,0 +1,3 @@ +// TODO(ui-common-upgrade): type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart. +// TODO(ui-common-upgrade): SortDirection (removed with DataTable in 0.2, no Astryx counterpart) is no longer re-exported from here; modules importing it from here need a type of their own. +export {}; diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx new file mode 100644 index 0000000..4e737f8 --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/expected/src/pages/Overview.tsx @@ -0,0 +1,24 @@ +import { ClickableCard } from "@lablup/ui-common/ClickableCard"; +import { useState } from "react"; +// TODO(ui-common-upgrade): type StatusKind was removed with StatusTag in 0.2 and has no Astryx counterpart; @/components/common no longer exports it. +import { Button, Meter, Select, StatusTag } from "@/components/common"; + +// TODO(ui-common-upgrade): type SortDirection was removed with DataTable in 0.2 and has no Astryx counterpart; ../components/common no longer exports it. +export function Overview({ kind, sort }: { kind: StatusKind; sort: SortDirection }) { + const [region, setRegion] = useState("kr"); + return ( + setRegion("kr")} label="Open the overview"> + {/* TODO(ui-common-upgrade): StatusDot renders only the dot; `label` becomes its accessible name. Keep the visible text: {label}. */} + + {/* TODO(ui-common-upgrade): `label` is required and is the accessible name; the old visible `label` text maps to it with isLabelHidden when it was not shown. */} + + + + ); +} diff --git a/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json new file mode 100644 index 0000000..542a54c --- /dev/null +++ b/packages/cli/test/upgrade/fixtures/local-barrel/input/tsconfig.json @@ -0,0 +1,10 @@ +{ + // Feature code imports shared UI as "@/components/common". + "compilerOptions": { + "jsx": "react-jsx", + "paths": { + "@/*": ["./src/*"], + }, + }, + "include": ["src"], +} diff --git a/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md index 7269493..bb0146f 100644 --- a/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md +++ b/packages/cli/test/upgrade/fixtures/root-barrel/expected/ui-common-upgrade-report.md @@ -88,6 +88,10 @@ Astryx declares the same name. Whichever rule wins the cascade now restyles both | `src/themes/violet.css:3` | `[data-theme="violet-light"] { --color-border: #e0dcf5 }` | --color-border | | `src/themes/violet.css:4` | `[data-theme="violet-light"] { --color-text-primary: #1b1535 }` | --color-text-primary | +### Local wrappers around 0.1 components (0) + +None. + ### 0.1 stylesheet paths left in place (0) None. diff --git a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx index 2442573..c6ae01b 100644 --- a/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx +++ b/packages/cli/test/upgrade/fixtures/subpath-barrel/expected/src/components/common/Select.test.tsx @@ -2,6 +2,7 @@ import { render } from "@testing-library/react"; import { Select } from "./index"; it("opens", () => { + // TODO(ui-common-upgrade): `label` is required and is a string; a node label needs a string for the accessible name. const { container } = render(