@lablup/ui-common-cli 0.2.0-alpha.19.pr-73-20261022061203-8f47fc7d0 → 0.2.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  The `ui-common` command line for [`@lablup/ui-common`](https://www.npmjs.com/package/@lablup/ui-common):
4
4
  the Astryx CLI that ui-common pins, with its output in `@lablup/ui-common`
5
5
  terms, the agent block, the upgrade codemods, `adopt --from astryx` for an
6
- app that uses Astryx directly, `doctor`, and the `ui-common-adopt` agent
7
- skill.
6
+ app that uses Astryx directly, `doctor`, the `ui-common-adopt` agent skill,
7
+ and the computer-level `ui-common` skill (`ui-common init --user`).
8
8
 
9
9
  It is a separate package so the CLI's toolchain (the Astryx CLI, jscodeshift,
10
10
  postcss) stays out of a consumer's production install. It is released at the
package/cli/adopt.mjs CHANGED
@@ -27,6 +27,10 @@ import {
27
27
  addLabOverride,
28
28
  } from "../codemods/0.2/package-json.mjs";
29
29
  import { DIALOG_NAMES, transformDialogs } from "../codemods/adopt/dialog.mjs";
30
+ import {
31
+ LAB as LAB_MIRROR,
32
+ moveDrawerImports,
33
+ } from "../codemods/0.2.0-alpha.20/drawer.mjs";
30
34
  import {
31
35
  addUiCommonLayer,
32
36
  ensureHtmlLayerStatement,
@@ -286,6 +290,24 @@ export async function runAdopt(options) {
286
290
  },
287
291
  );
288
292
  touch(file, rewritten.text, "specifiers");
293
+ // lab's Drawer is core's since Astryx 0.7.2, so ui-common's lives at
294
+ // /Drawer, not in the lab mirror the specifier rewrite points at.
295
+ if (script && entry.current.includes(LAB_MIRROR)) {
296
+ const j = jscodeshift.withParser(/\.[cm]?tsx?$/.test(ext) ? "tsx" : "babel");
297
+ try {
298
+ const out = moveDrawerImports(
299
+ { source: entry.current, path: file },
300
+ { jscodeshift: j },
301
+ );
302
+ if (out != null) touch(file, out, "drawer-from-lab");
303
+ } catch (err) {
304
+ errors.push({
305
+ file: rel(file),
306
+ transform: "drawer-from-lab",
307
+ error: `could not be parsed, so Drawer imports were left in ${LAB_MIRROR}: ${/** @type {Error} */ (err).message}`,
308
+ });
309
+ }
310
+ }
289
311
  for (const [row, n] of Object.entries(rewritten.rows))
290
312
  rows[row] = (rows[row] ?? 0) + n;
291
313
  for (const l of rewritten.left) {
package/cli/main.mjs CHANGED
@@ -16,6 +16,7 @@ import { TEMPLATE_HELP, templateCommand } from "./templates.mjs";
16
16
  import { UPGRADE_HELP, upgradeCommand } from "./upgrade.mjs";
17
17
  import { ADOPT_HELP, adoptCommand } from "./adopt.mjs";
18
18
  import { DOCTOR_HELP, doctorCommand } from "./doctor.mjs";
19
+ import { INIT_USER_HELP, initUserCommand } from "./user-skill.mjs";
19
20
 
20
21
  export const HELP = `Usage: ui-common <command> [options]
21
22
 
@@ -39,6 +40,10 @@ ui-common commands:
39
40
  agents [--write <file>] [--check] [--skill [--dir <dir>]]
40
41
  Print, write or check the ui-common agent block (UI-COMMON markers);
41
42
  install the ui-common-adopt agent skill.
43
+ init --user [--remove-agents]
44
+ Install the ui-common agent skill for every agent on this computer
45
+ (~/.agents/skills/ui-common, linked into ~/.claude/skills and the
46
+ like), in place of Astryx's. Any other \`init\` is Astryx's.
42
47
  template [<name> [<path>]] [--list] [--type page|block] [--overwrite]
43
48
  List, print or scaffold one of ui-common's page and block templates.
44
49
  Astryx's own templates are not offered; \`search\` and \`build\` leave
@@ -98,6 +103,10 @@ export async function main(argv) {
98
103
  return 0;
99
104
  case "agents":
100
105
  return agentsCommand(rest);
106
+ case "init":
107
+ // The computer-level skill is ui-common's own; a project init is Astryx's.
108
+ if (rest.includes("--user")) return initUserCommand(rest);
109
+ return passthrough(argv);
101
110
  case "template":
102
111
  return templateCommand(rest);
103
112
  case "upgrade":
@@ -117,4 +126,12 @@ export async function main(argv) {
117
126
  }
118
127
  }
119
128
 
120
- export { ADOPT_HELP, AGENTS_HELP, DOCTOR_HELP, SYNC_HELP, TEMPLATE_HELP, UPGRADE_HELP };
129
+ export {
130
+ ADOPT_HELP,
131
+ AGENTS_HELP,
132
+ DOCTOR_HELP,
133
+ INIT_USER_HELP,
134
+ SYNC_HELP,
135
+ TEMPLATE_HELP,
136
+ UPGRADE_HELP,
137
+ };
package/cli/rewrite.mjs CHANGED
@@ -69,10 +69,11 @@ export function rewriteCommands(text, invocation = "ui-common") {
69
69
  const commands = ASTRYX_COMMANDS.join("|");
70
70
  return (
71
71
  text
72
- // A one-off `npx @astryxdesign/cli` invocation, or an installed-bin
73
- // invocation through a package manager.
72
+ // A one-off `npx @astryxdesign/cli` invocation (since Astryx 0.7.2
73
+ // pinned to a release, `npx @astryxdesign/cli@0.7.3`, when no project
74
+ // CLI answers), or an installed-bin invocation through a package manager.
74
75
  .replace(
75
- /\b(?:npx|pnpm dlx|yarn dlx|bunx) @astryxdesign\/cli(?=[\s`'"]|$)/g,
76
+ /\b(?:npx(?: -y)?|pnpm dlx|yarn dlx|bunx) @astryxdesign\/cli(?:@[\w.+-]+)?(?=[\s`'"]|$)/g,
76
77
  invocation,
77
78
  )
78
79
  .replace(
@@ -0,0 +1,340 @@
1
+ /**
2
+ * `ui-common init --user`: the computer-level agent skill, ui-common's
3
+ * counterpart of the one `astryx init --user` writes (Astryx 0.7.2).
4
+ *
5
+ * Astryx's skill tells every agent on the computer to run `astryx` and add
6
+ * `@astryxdesign/core`; in a project that depends on @lablup/ui-common that
7
+ * is the wrong advice. ui-common writes its own instead, from
8
+ * `skill/ui-common/SKILL.md`, at `~/.agents/skills/ui-common/SKILL.md`, and
9
+ * links its folder into the skill folders Astryx links its own into (the
10
+ * table is Astryx's, `SKILL_LINKS`, so the two reach the same agents):
11
+ * `~/.claude/skills` always, the others when their agent is installed, and a
12
+ * managed copy where the agent skips links.
13
+ *
14
+ * The written file carries a hash line, so a file a person edited, a file
15
+ * ui-common did not write, a symlinked file, and a link target holding
16
+ * anything else are never changed or removed. The text is version-free, so
17
+ * nothing refreshes it in the background; running the command again after
18
+ * an upgrade does.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+ import {
22
+ existsSync,
23
+ lstatSync,
24
+ mkdirSync,
25
+ readdirSync,
26
+ readFileSync,
27
+ readlinkSync,
28
+ realpathSync,
29
+ rmdirSync,
30
+ symlinkSync,
31
+ unlinkSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import { homedir } from "node:os";
35
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
36
+
37
+ import { CLI_ROOT, importAstryxInternal } from "./paths.mjs";
38
+
39
+ export const USER_SKILL_NAME = "ui-common";
40
+ export const USER_SKILL_SOURCE = join(CLI_ROOT, "skill", USER_SKILL_NAME, "SKILL.md");
41
+
42
+ /** The skill folder, relative to the home folder: where Astryx keeps its own. */
43
+ const SKILL_FOLDER = join(".agents", "skills", USER_SKILL_NAME);
44
+ /** Astryx's skill file, which the receipt mentions when it is there too. */
45
+ const ASTRYX_SKILL = join(".agents", "skills", "astryx", "SKILL.md");
46
+
47
+ const HASH_LINE = /^<!-- ui-common-managed sha256:([0-9a-f]{64}) -->\n/m;
48
+
49
+ /** @param {string} text */
50
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
51
+
52
+ /**
53
+ * The file `init --user` writes: the shipped skill with a hash line after its
54
+ * frontmatter, so a later run tells an unedited file from an edited one.
55
+ *
56
+ * @param {string} [source] the skill text
57
+ */
58
+ export function renderUserSkill(source = readFileSync(USER_SKILL_SOURCE, "utf8")) {
59
+ const end = source.indexOf("\n---\n", 4);
60
+ if (!source.startsWith("---\n") || end === -1) {
61
+ throw new Error(`${USER_SKILL_SOURCE} has no frontmatter`);
62
+ }
63
+ const cut = end + "\n---\n".length;
64
+ return `${source.slice(0, cut)}<!-- ui-common-managed sha256:${sha256(source)} -->\n${source.slice(cut)}`;
65
+ }
66
+
67
+ /**
68
+ * - `absent`: nothing there.
69
+ * - `current`: exactly this release's rendering.
70
+ * - `differs`: an unedited file ui-common wrote, from another release.
71
+ * - `edited`: a file ui-common wrote that a person changed. Never touched.
72
+ * - `foreign`: no hash line, a symlink, or not a regular file. Never touched.
73
+ *
74
+ * @typedef {'absent' | 'current' | 'differs' | 'edited' | 'foreign'} FileState
75
+ */
76
+
77
+ /**
78
+ * @param {string} file
79
+ * @param {string} expected
80
+ * @returns {FileState}
81
+ */
82
+ function fileState(file, expected) {
83
+ let stat;
84
+ try {
85
+ stat = lstatSync(file);
86
+ } catch {
87
+ return "absent";
88
+ }
89
+ if (!stat.isFile()) return "foreign";
90
+ const content = readFileSync(file, "utf8");
91
+ const match = HASH_LINE.exec(content);
92
+ if (!match) return "foreign";
93
+ if (content === expected) return "current";
94
+ return sha256(content.replace(HASH_LINE, "")) === match[1] ? "differs" : "edited";
95
+ }
96
+
97
+ /**
98
+ * Where a link sits: `linked` (a link to the skill folder), `absent`, a
99
+ * managed copy (`copy`, unedited) or `foreign` (anything else; never touched).
100
+ *
101
+ * @param {string} at
102
+ * @param {string} folder
103
+ */
104
+ function linkState(at, folder) {
105
+ let stat;
106
+ try {
107
+ stat = lstatSync(at);
108
+ } catch {
109
+ return "absent";
110
+ }
111
+ if (stat.isSymbolicLink()) {
112
+ const target = resolve(dirname(at), readlinkSync(at));
113
+ let real = target;
114
+ try {
115
+ real = realpathSync(target);
116
+ } catch {
117
+ // dangling: compare the target as written
118
+ }
119
+ let ours = folder;
120
+ try {
121
+ ours = realpathSync(folder);
122
+ } catch {
123
+ // the skill folder is gone
124
+ }
125
+ return real === ours || target === folder ? "linked" : "foreign";
126
+ }
127
+ if (!stat.isDirectory()) return "foreign";
128
+ const entries = readdirSync(at);
129
+ if (entries.length !== 1 || entries[0] !== "SKILL.md") return "foreign";
130
+ const state = fileState(join(at, "SKILL.md"), "");
131
+ return state === "differs" ? "copy" : "foreign";
132
+ }
133
+
134
+ /** @param {string} at @param {string} folder */
135
+ function makeLink(at, folder) {
136
+ mkdirSync(dirname(at), { recursive: true });
137
+ if (process.platform === "win32") {
138
+ symlinkSync(folder, at, "junction");
139
+ return;
140
+ }
141
+ const real = realpathSync(folder);
142
+ const rel = relative(realpathSync(dirname(at)), real);
143
+ symlinkSync(isAbsolute(rel) ? real : rel, at, "dir");
144
+ }
145
+
146
+ /** @param {string} file */
147
+ function removeFile(file) {
148
+ unlinkSync(file);
149
+ try {
150
+ rmdirSync(dirname(file));
151
+ } catch {
152
+ // the folder holds something else
153
+ }
154
+ }
155
+
156
+ /** `~/.claude/skills/ui-common` for a path in `home`. @param {string} file @param {string} home */
157
+ export function displayPath(file, home) {
158
+ const rel = relative(home, file);
159
+ return rel && !rel.startsWith("..") && !isAbsolute(rel) ? `~${sep}${rel}` : file;
160
+ }
161
+
162
+ /**
163
+ * The skill folders to link, from Astryx's table, for the ui-common skill.
164
+ *
165
+ * @param {string} home
166
+ * @returns {Promise<Array<{path: string, wanted: boolean, mode: 'link' | 'copy'}>>}
167
+ */
168
+ export async function userSkillLinks(home) {
169
+ const { SKILL_LINKS } = await importAstryxInternal(
170
+ "foundation/agent-docs/computer-agent-text.mjs",
171
+ );
172
+ return /** @type {Array<{skills: string, when: string | null, mode: 'link' | 'copy'}>} */ (
173
+ SKILL_LINKS
174
+ ).map(({ skills, when, mode }) => ({
175
+ path: join(home, skills, USER_SKILL_NAME),
176
+ wanted: when == null || existsSync(join(home, when)),
177
+ mode,
178
+ }));
179
+ }
180
+
181
+ /**
182
+ * @typedef {{path: string, action: 'written' | 'unchanged' | 'linked' | 'removed' | 'absent' | 'kept', reason?: string}} Row
183
+ */
184
+
185
+ /**
186
+ * Write or refresh the skill and link it where agents read it.
187
+ *
188
+ * @param {{home?: string, source?: string}} [options]
189
+ * @returns {Promise<Row[]>}
190
+ */
191
+ export async function writeUserSkill({ home = homedir(), source } = {}) {
192
+ const expected = renderUserSkill(source);
193
+ const folder = join(home, SKILL_FOLDER);
194
+ const file = join(folder, "SKILL.md");
195
+ /** @type {Row[]} */
196
+ const rows = [];
197
+ const state = fileState(file, expected);
198
+ if (state === "current") rows.push({ path: file, action: "unchanged" });
199
+ else if (state === "absent" || state === "differs") {
200
+ mkdirSync(folder, { recursive: true });
201
+ writeFileSync(file, expected);
202
+ rows.push({ path: file, action: "written" });
203
+ } else {
204
+ rows.push({ path: file, action: "kept", reason: state });
205
+ return rows;
206
+ }
207
+ for (const place of await userSkillLinks(home)) {
208
+ if (!place.wanted) continue;
209
+ const at = place.path;
210
+ const now = linkState(at, folder);
211
+ if (now === "linked") {
212
+ rows.push({ path: at, action: "unchanged" });
213
+ continue;
214
+ }
215
+ if (now === "foreign") {
216
+ rows.push({ path: at, action: "kept", reason: "foreign" });
217
+ continue;
218
+ }
219
+ if (place.mode === "link") {
220
+ if (now === "copy") removeFile(join(at, "SKILL.md"));
221
+ try {
222
+ makeLink(at, folder);
223
+ rows.push({ path: at, action: "linked" });
224
+ continue;
225
+ } catch {
226
+ // no links on this file system: a managed copy instead
227
+ }
228
+ }
229
+ const copy = join(at, "SKILL.md");
230
+ if (fileState(copy, expected) === "current") {
231
+ rows.push({ path: at, action: "unchanged" });
232
+ continue;
233
+ }
234
+ mkdirSync(at, { recursive: true });
235
+ writeFileSync(copy, expected);
236
+ rows.push({ path: at, action: "written" });
237
+ }
238
+ return rows;
239
+ }
240
+
241
+ /**
242
+ * Remove the skill, its links and its managed copies; anything ui-common did
243
+ * not write, or a person edited, stays.
244
+ *
245
+ * @param {{home?: string}} [options]
246
+ * @returns {Promise<Row[]>}
247
+ */
248
+ export async function removeUserSkill({ home = homedir() } = {}) {
249
+ const folder = join(home, SKILL_FOLDER);
250
+ const file = join(folder, "SKILL.md");
251
+ const state = fileState(file, "");
252
+ if (state === "absent") return [{ path: file, action: "absent" }];
253
+ // An edited or foreign file stays, and so do the links that reach it.
254
+ if (state !== "differs") return [{ path: file, action: "kept", reason: state }];
255
+ /** @type {Row[]} */
256
+ const rows = [];
257
+ for (const place of await userSkillLinks(home)) {
258
+ const now = linkState(place.path, folder);
259
+ if (now === "linked") unlinkSync(place.path);
260
+ else if (now === "copy") removeFile(join(place.path, "SKILL.md"));
261
+ else continue;
262
+ rows.push({ path: place.path, action: "removed" });
263
+ }
264
+ removeFile(file);
265
+ rows.unshift({ path: file, action: "removed" });
266
+ return rows;
267
+ }
268
+
269
+ const VERBS = { written: "Wrote", linked: "Linked", removed: "Removed" };
270
+
271
+ const KEPT = {
272
+ edited: "edited by hand, left as it is",
273
+ foreign: "not written by ui-common, left as it is",
274
+ };
275
+
276
+ /**
277
+ * @param {string[]} argv arguments after `init`, `--user` included
278
+ * @param {{home?: string, out?: (s: string) => void, err?: (s: string) => void}} [io]
279
+ * @returns {Promise<number>}
280
+ */
281
+ export async function initUserCommand(argv, io = {}) {
282
+ const home = io.home ?? homedir();
283
+ const out = io.out ?? ((s) => process.stdout.write(s));
284
+ const err = io.err ?? ((s) => process.stderr.write(s));
285
+ let remove = false;
286
+ for (const arg of argv) {
287
+ if (arg === "--user") continue;
288
+ if (arg === "--remove-agents") remove = true;
289
+ else if (arg === "-h" || arg === "--help") {
290
+ out(INIT_USER_HELP);
291
+ return 0;
292
+ } else {
293
+ err(`ui-common init --user: unknown argument "${arg}"\n${INIT_USER_HELP}`);
294
+ return 2;
295
+ }
296
+ }
297
+ const rows = remove
298
+ ? await removeUserSkill({ home })
299
+ : await writeUserSkill({ home });
300
+ for (const row of rows) {
301
+ const where = displayPath(row.path, home);
302
+ const line =
303
+ row.action === "kept"
304
+ ? `[keep] ${where}: ${KEPT[/** @type {'edited' | 'foreign'} */ (row.reason)] ?? row.reason}`
305
+ : row.action === "absent"
306
+ ? `[ok] ${where} was not there`
307
+ : row.action === "unchanged"
308
+ ? `[ok] ${where} is up to date`
309
+ : `[ok] ${VERBS[row.action]} ${where}`;
310
+ out(`${line}\n`);
311
+ }
312
+ if (!remove && existsSync(join(home, ASTRYX_SKILL))) {
313
+ out(
314
+ `Note: ${displayPath(join(home, ASTRYX_SKILL), home)} is Astryx's own skill, which steers agents to \`astryx\` and @astryxdesign/*. The ui-common skill tells them to use ui-common instead in a project that depends on @lablup/ui-common. If you do not use Astryx directly, remove Astryx's with \`ui-common astryx init --user --remove-agents\`.\n`,
315
+ );
316
+ }
317
+ return rows.some((r) => r.action === "kept" && !remove && r.path.endsWith("SKILL.md"))
318
+ ? 1
319
+ : 0;
320
+ }
321
+
322
+ export const INIT_USER_HELP = `Usage: ui-common init --user [--remove-agents]
323
+
324
+ Install the ui-common agent skill for every agent on this computer:
325
+ ~/.agents/skills/${USER_SKILL_NAME}/SKILL.md, linked into ~/.claude/skills and the
326
+ skill folders of the other agents Astryx reaches (Kiro, Windsurf, Letta,
327
+ Xcode, ~/.config/agents) when they are installed. It tells agents to use the
328
+ ui-common CLI (search, build, template, component, docs) and @lablup/ui-common
329
+ imports, not \`astryx\` and @astryxdesign/*, in a project that depends on
330
+ @lablup/ui-common. Run it again after upgrading @lablup/ui-common-cli.
331
+
332
+ This replaces what \`astryx init --user\` writes; \`ui-common astryx init --user\`
333
+ still runs Astryx's own.
334
+
335
+ --remove-agents Remove the skill, its links and its copies. A file you
336
+ edited, or one ui-common did not write, is kept.
337
+
338
+ Exit codes: 0 done, 1 the skill file was kept because it is not ui-common's
339
+ or was edited, 2 bad arguments.
340
+ `;
@@ -3,7 +3,7 @@
3
3
  * Astryx counterparts, and their props are reshaped.
4
4
  *
5
5
  * - Imports from the root barrel and from `@lablup/ui-common/components/<Name>`
6
- * move to `@lablup/ui-common/<Astryx subpath>` (Drawer to `/lab`). Kept
6
+ * move to `@lablup/ui-common/<Astryx subpath>`. Kept
7
7
  * components (PageHeader, StatCard, the Skeleton composites, …) keep the
8
8
  * import they had. `usePrefersReducedMotion` moves from `/hooks` to the root.
9
9
  * - Re-exports (`export { Select } from …`) keep their public name.
@@ -689,18 +689,18 @@ export function Drawer(h) {
689
689
  const value = attrValue(title);
690
690
  replaceAttr(el, title, makeAttr(j, "label", attrExpression(j, title)));
691
691
  if (value.kind === "expression" && !isStringish(value.expression)) {
692
- h.todo("lab Drawer `label` must be a string; it was the title.");
692
+ h.todo("Drawer `label` must be a string; it was the title.");
693
693
  }
694
694
  }
695
695
  if (title || getAttr(el, "subtitle") || getAttr(el, "footer")) {
696
696
  todoAbout(
697
697
  h,
698
698
  "header",
699
- "lab Drawer renders no header: render the title, subtitle and footer inside children.",
699
+ "Drawer renders no header: compose DrawerHeader (title and close button) and the footer inside children.",
700
700
  );
701
701
  }
702
702
 
703
- // 0.1's default width was "medium"; the lab Drawer's default is narrower.
703
+ // 0.1's default width was "medium"; Astryx's Drawer default is narrower.
704
704
  const widths = /** @type {Record<string, number>} */ (
705
705
  h.removed.spec.valueMaps.width ?? {}
706
706
  );
@@ -38,7 +38,7 @@ function notes(ctx) {
38
38
  }
39
39
  if (ctx.flags.packages.has(LAB_PACKAGE)) {
40
40
  out.push(
41
- `A Drawer moved to ${UIC}/lab: ${LAB_PACKAGE} is an optional peer of ui-common, pinned to the canary it is built against. The codemod added it to package.json and ${LAB_CSS} to the stylesheet entry it rewrote; import lab.css yourself if your entry is elsewhere.`,
41
+ `A component moved to ${UIC}/lab: ${LAB_PACKAGE} is an optional peer of ui-common, pinned to the canary it is built against. The codemod added it to package.json and ${LAB_CSS} to the stylesheet entry it rewrote; import lab.css yourself if your entry is elsewhere.`,
42
42
  );
43
43
  }
44
44
  out.push(
@@ -7,9 +7,10 @@
7
7
  * - in a library, drop React 18 from the react / react-dom peer ranges;
8
8
  * - in a pnpm project, decline the Astryx postinstalls in `allowBuilds`;
9
9
  * - add @astryxdesign/lab, pinned to the canary ui-common is built against,
10
- * when a Drawer import was moved to `@lablup/ui-common/lab`, and point its
11
- * core peer at ui-common's core with the project's package manager's
12
- * override (../../cli/lab-peer.mjs).
10
+ * when a moved component needs it (`requiresPackages` in the migration map;
11
+ * none since the Drawer moved to core in Astryx 0.7.2), and point its core
12
+ * peer at ui-common's core with the project's package manager's override
13
+ * (../../cli/lab-peer.mjs).
13
14
  */
14
15
  import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs";
15
16
  import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs";
@@ -316,7 +317,8 @@ export function transformPackageJson(text, ctx) {
316
317
  }
317
318
  }
318
319
 
319
- // Packages the map says a moved component needs (the lab Drawer).
320
+ // Packages the map says a moved component needs (none since the Drawer
321
+ // moved to core in Astryx 0.7.2).
320
322
  for (const [lab, range] of ctx.flags.packages) {
321
323
  const added = ensure(lab, range);
322
324
  if (added.length === 0) continue;
@@ -26,8 +26,8 @@ const { base, layerOrder, imports: replacement, entryFile } = STYLESHEETS;
26
26
  const DROPPED = STYLESHEETS.dropped;
27
27
 
28
28
  /**
29
- * The stylesheets that replace base.css. lab.css joins them when a Drawer was
30
- * moved to the lab package.
29
+ * The stylesheets that replace base.css. lab.css joins them when a moved
30
+ * component needs the lab package.
31
31
  *
32
32
  * @param {{flags?: {packages: Map<string, string>}}} ctx
33
33
  * @returns {string[]}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * 0.2.0-alpha.20: Astryx 0.7.2 moved `Drawer` and `DrawerHeader` from lab to
3
+ * core, so ui-common's `Drawer` fork moved from `@lablup/ui-common/lab` to
4
+ * `@lablup/ui-common/Drawer`, and the lab mirror no longer has either name.
5
+ *
6
+ * Imports and re-exports of those names from `@lablup/ui-common/lab` move to
7
+ * `@lablup/ui-common/Drawer` (joining an import from there when the file has
8
+ * one); the rest of a lab import stays. A namespace import of the lab mirror,
9
+ * or a module mock of it, is left as written and reported.
10
+ *
11
+ * Core's Drawer also took `isFullWidthOnMobile` into `experimental`, so the
12
+ * prop on a Drawer element becomes `experimental={{ isFullWidthOnMobile }}`;
13
+ * an element that already has `experimental` is left as written and reported.
14
+ */
15
+
16
+ export const LAB = "@lablup/ui-common/lab";
17
+ export const DRAWER = "@lablup/ui-common/Drawer";
18
+ export const MOVED = new Set([
19
+ "Drawer",
20
+ "DrawerProps",
21
+ "DrawerHeader",
22
+ "DrawerHeaderProps",
23
+ ]);
24
+
25
+ /** @param {any} specifier */
26
+ const importedName = (specifier) =>
27
+ specifier.imported?.name ?? specifier.imported?.value ?? specifier.local?.name;
28
+
29
+ /** @param {any} specifier */
30
+ const exportedFrom = (specifier) => specifier.local?.name ?? specifier.local?.value;
31
+
32
+ /**
33
+ * @param {{path: string, source: string}} file
34
+ * @param {{jscodeshift: any}} api
35
+ */
36
+ export function moveDrawerImports(file, api) {
37
+ if (!file.source.includes(LAB) && !file.source.includes(FULL_WIDTH)) return undefined;
38
+ const j = api.jscodeshift;
39
+ const root = j(file.source);
40
+ let changed = false;
41
+
42
+ root
43
+ .find(j.ImportDeclaration, { source: { value: LAB } })
44
+ .forEach((/** @type {any} */ path) => {
45
+ const node = path.node;
46
+ const specifiers = node.specifiers ?? [];
47
+ const moved = specifiers.filter(
48
+ (/** @type {any} */ s) =>
49
+ s.type === "ImportSpecifier" && MOVED.has(importedName(s)),
50
+ );
51
+ if (moved.length === 0) return;
52
+ const kept = specifiers.filter((/** @type {any} */ s) => !moved.includes(s));
53
+ const target = root
54
+ .find(j.ImportDeclaration, {
55
+ source: { value: DRAWER },
56
+ importKind: node.importKind ?? "value",
57
+ })
58
+ .filter(
59
+ (/** @type {any} */ p) =>
60
+ !(p.node.specifiers ?? []).some(
61
+ (/** @type {any} */ s) => s.type !== "ImportSpecifier",
62
+ ),
63
+ );
64
+ if (target.size() > 0) {
65
+ target.get().node.specifiers.push(...moved);
66
+ } else {
67
+ const declaration = j.importDeclaration(moved, j.stringLiteral(DRAWER));
68
+ if (node.importKind === "type") declaration.importKind = "type";
69
+ j(path).insertAfter(declaration);
70
+ }
71
+ if (kept.length > 0) node.specifiers = kept;
72
+ else j(path).remove();
73
+ changed = true;
74
+ });
75
+
76
+ root
77
+ .find(j.ExportNamedDeclaration, { source: { value: LAB } })
78
+ .forEach((/** @type {any} */ path) => {
79
+ const node = path.node;
80
+ const moved = node.specifiers.filter((/** @type {any} */ s) =>
81
+ MOVED.has(exportedFrom(s)),
82
+ );
83
+ if (moved.length === 0) return;
84
+ const kept = node.specifiers.filter((/** @type {any} */ s) => !moved.includes(s));
85
+ const declaration = j.exportNamedDeclaration(
86
+ null,
87
+ moved,
88
+ j.stringLiteral(DRAWER),
89
+ );
90
+ if (node.exportKind === "type") declaration.exportKind = "type";
91
+ j(path).insertAfter(declaration);
92
+ if (kept.length > 0) node.specifiers = kept;
93
+ else j(path).remove();
94
+ changed = true;
95
+ });
96
+
97
+ if (moveFullWidthOnMobile(j, root)) changed = true;
98
+
99
+ if (!changed) return undefined;
100
+ return root.toSource({ quote: /from\s+'/.test(file.source) ? "single" : "double" });
101
+ }
102
+
103
+ const DRAWER_SOURCES = new Set([LAB, DRAWER, "@lablup/ui-common"]);
104
+ const FULL_WIDTH = "isFullWidthOnMobile";
105
+
106
+ /**
107
+ * Rewrite `isFullWidthOnMobile` on elements of a Drawer imported from
108
+ * ui-common into core's `experimental={{ isFullWidthOnMobile }}`.
109
+ *
110
+ * @param {any} j
111
+ * @param {any} root
112
+ */
113
+ function moveFullWidthOnMobile(j, root) {
114
+ /** @type {Set<string>} */
115
+ const locals = new Set();
116
+ root.find(j.ImportDeclaration).forEach((/** @type {any} */ path) => {
117
+ if (!DRAWER_SOURCES.has(path.node.source.value)) return;
118
+ for (const s of path.node.specifiers ?? []) {
119
+ if (s.type === "ImportSpecifier" && importedName(s) === "Drawer") {
120
+ locals.add(s.local.name);
121
+ }
122
+ }
123
+ });
124
+ if (locals.size === 0) return false;
125
+ let changed = false;
126
+ root.find(j.JSXOpeningElement).forEach((/** @type {any} */ path) => {
127
+ const name = path.node.name;
128
+ if (name.type !== "JSXIdentifier" || !locals.has(name.name)) return;
129
+ const attrs = path.node.attributes ?? [];
130
+ const index = attrs.findIndex(
131
+ (/** @type {any} */ a) => a.type === "JSXAttribute" && a.name.name === FULL_WIDTH,
132
+ );
133
+ if (index < 0) return;
134
+ const hasExperimental = attrs.some(
135
+ (/** @type {any} */ a) =>
136
+ a.type === "JSXAttribute" && a.name.name === "experimental",
137
+ );
138
+ if (hasExperimental) return;
139
+ const value = attrs[index].value;
140
+ let expression;
141
+ if (value == null) expression = j.booleanLiteral(true);
142
+ else if (value.type === "JSXExpressionContainer") expression = value.expression;
143
+ else return;
144
+ const property = j.objectProperty(j.identifier(FULL_WIDTH), expression);
145
+ if (expression.type === "Identifier" && expression.name === FULL_WIDTH) {
146
+ property.shorthand = true;
147
+ }
148
+ attrs[index] = j.jsxAttribute(
149
+ j.jsxIdentifier("experimental"),
150
+ j.jsxExpressionContainer(j.objectExpression([property])),
151
+ );
152
+ changed = true;
153
+ });
154
+ return changed;
155
+ }
156
+
157
+ export const CATEGORIES = {
158
+ "drawer-lab": {
159
+ title: "Drawer moved from @lablup/ui-common/lab to @lablup/ui-common/Drawer",
160
+ help: "Astryx 0.7.2 moved Drawer and DrawerHeader into core, so @lablup/ui-common/lab no longer exports them. The upgrade moved every named import; these uses go through the whole lab module (a namespace import or a module mock), so move their Drawer and DrawerHeader to @lablup/ui-common/Drawer by hand.",
161
+ },
162
+ "drawer-full-width": {
163
+ title: "Drawer's isFullWidthOnMobile is now experimental.isFullWidthOnMobile",
164
+ help: "Core's Drawer reads the option only from `experimental={{ isFullWidthOnMobile }}`; a top-level isFullWidthOnMobile is ignored and the drawer keeps the 56px page reveal on mobile. The upgrade rewrote the prop on Drawer elements it could see; move these by hand (a props object or spread, or an element that already has experimental).",
165
+ },
166
+ };
167
+
168
+ const LAB_RE = /@lablup\/ui-common\/lab["']/;
169
+
170
+ /**
171
+ * Namespace imports and module mocks of the lab mirror, which the transform
172
+ * cannot prove free of Drawer, and any `isFullWidthOnMobile` it left outside
173
+ * `experimental`.
174
+ *
175
+ * @param {string} file
176
+ * @param {string} source
177
+ */
178
+ export function scanDrawer(file, source) {
179
+ /** @type {Array<{category: string, file: string, line: number, text: string}>} */
180
+ const out = [];
181
+ if (/@lablup\/ui-common/.test(source)) {
182
+ source.split("\n").forEach((line, i) => {
183
+ if (/\bisFullWidthOnMobile\b/.test(line) && !/\bexperimental\b/.test(line)) {
184
+ out.push({
185
+ category: "drawer-full-width",
186
+ file,
187
+ line: i + 1,
188
+ text: line.trim(),
189
+ });
190
+ }
191
+ });
192
+ }
193
+ if (!LAB_RE.test(source)) return out;
194
+ source.split("\n").forEach((line, i) => {
195
+ if (!LAB_RE.test(line)) return;
196
+ const namespace = /import\s+(?:type\s+)?\*\s+as\s+\w+\s+from/.test(line);
197
+ const mock = /\b(?:vi|jest)\.(?:mock|doMock)\(/.test(line);
198
+ if (namespace || mock) {
199
+ out.push({ category: "drawer-lab", file, line: i + 1, text: line.trim() });
200
+ }
201
+ });
202
+ return out;
203
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The 0.2.0-alpha.20 step, ui-common's own part (Astryx's codemods for the
3
+ * same release are in ./upstream.json): the Drawer leaves the lab mirror.
4
+ */
5
+ import { CATEGORIES, moveDrawerImports, scanDrawer } from "./drawer.mjs";
6
+
7
+ /** @type {import('../registry.mjs').Step} */
8
+ export default {
9
+ title: "0.2.0-alpha.20: Drawer moves from /lab to /Drawer",
10
+ transforms: [
11
+ {
12
+ id: "drawer-from-lab",
13
+ title: "Import Drawer and DrawerHeader from @lablup/ui-common/Drawer",
14
+ extensions: [".tsx", ".ts", ".jsx", ".js", ".mjs", ".cjs"],
15
+ parse: true,
16
+ run: moveDrawerImports,
17
+ },
18
+ ],
19
+ scan: scanDrawer,
20
+ categories: CATEGORIES,
21
+ notes: [
22
+ "Drawer and DrawerHeader are core components since Astryx 0.7.2: they come from @lablup/ui-common/Drawer (or the root) and no longer need @astryxdesign/lab or lab.css. A project that used lab only for the Drawer can drop both.",
23
+ ],
24
+ };
@@ -0,0 +1,19 @@
1
+ {
2
+ "$comment": "Written by `ui-common sync-astryx`. Astryx codemods consumers need when they upgrade across this ui-common version; `ui-common upgrade` runs them with @lablup/ui-common specifiers swapped for Astryx's, and moves the consumer's lab pin and core override.",
3
+ "astryx": {
4
+ "from": "0.7.0",
5
+ "to": "0.7.3"
6
+ },
7
+ "lab": {
8
+ "from": "0.7.0-canary.97a0d31",
9
+ "to": "0.7.3-canary.cac3d8d"
10
+ },
11
+ "codemods": [
12
+ {
13
+ "id": "migrate-list-edge-compensation-to-edge",
14
+ "version": "0.7.2",
15
+ "title": "Migrate List edgeCompensation to edge"
16
+ }
17
+ ],
18
+ "optional": []
19
+ }
@@ -40,7 +40,7 @@ export const CATEGORIES = {
40
40
  },
41
41
  escape: {
42
42
  title: "Escape handlers of your own",
43
- help: "`Modal`, the lab `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too.",
43
+ help: "`Modal`, the `Drawer` and popovers close through one layer stack: one Escape closes the top layer only. A handler of yours that consumes Escape (inline edit, a search box) must call `event.preventDefault()`, or the stack closes the surrounding layer too.",
44
44
  },
45
45
  "agent-blocks": {
46
46
  title: "Astryx agent blocks, and tools anchored on them",
@@ -5,7 +5,8 @@
5
5
  * prerelease keys work: 0.1.0-alpha.19 < 0.2.0-alpha.0 < 0.2.0).
6
6
  *
7
7
  * Two kinds of step:
8
- * - hand-written ones, listed in STATIC_STEPS (0.1 -> 0.2);
8
+ * - hand-written ones, listed in STATIC_STEPS (0.1 -> 0.2; the Drawer's move
9
+ * off the lab mirror in 0.2.0-alpha.20);
9
10
  * - `codemods/<version>/upstream.json`, written by `ui-common sync-astryx`
10
11
  * when ui-common moves to a new Astryx: the Astryx codemods a consumer needs
11
12
  * for that bump, run through ./upstream.mjs.
@@ -44,6 +45,10 @@ const STATIC_STEPS = [
44
45
  version: "0.2.0-alpha.0",
45
46
  load: async () => (await import("./0.2/index.mjs")).default,
46
47
  },
48
+ {
49
+ version: "0.2.0-alpha.20",
50
+ load: async () => (await import("./0.2.0-alpha.20/index.mjs")).default,
51
+ },
47
52
  ];
48
53
 
49
54
  /** Upstream steps recorded by `sync-astryx`: `codemods/<version>/upstream.json`. */
@@ -208,11 +208,10 @@
208
208
  }
209
209
  ],
210
210
  "replacement": {
211
- "specifier": "@lablup/ui-common/lab",
211
+ "specifier": "@lablup/ui-common/Drawer",
212
212
  "names": { "Drawer": "Drawer", "DrawerProps": "DrawerProps" }
213
213
  },
214
214
  "alternatives": [],
215
- "requiresPackages": { "@astryxdesign/lab": "0.7.0-canary.97a0d31" },
216
215
  "propRenames": [
217
216
  {
218
217
  "from": "onClose",
@@ -224,7 +223,7 @@
224
223
  ],
225
224
  "valueMaps": { "width": { "narrow": 400, "medium": 520, "wide": 900 } },
226
225
  "manual": [
227
- "lab Drawer renders no header: render the title, subtitle and footer inside children.",
226
+ "Drawer renders no header: compose DrawerHeader (title and close button) and the footer inside children.",
228
227
  "closeLabel, ariaLabelledBy and ariaDescribedBy have no counterpart.",
229
228
  "preventDismiss and onDismissAttempt: decline the close in onOpenChange.",
230
229
  "The .drawer classes are gone."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lablup/ui-common-cli",
3
- "version": "0.2.0-alpha.19.pr-73-20261022061203-8f47fc7d0",
3
+ "version": "0.2.0-alpha.20",
4
4
  "description": "The ui-common command line: the pinned Astryx CLI in @lablup/ui-common terms, the agent block and skill, the upgrade codemods, adopt (from Astryx) and doctor",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Lablup Inc.",
@@ -37,15 +37,15 @@
37
37
  "./package.json": "./package.json"
38
38
  },
39
39
  "dependencies": {
40
- "@astryxdesign/cli": "0.7.0",
40
+ "@astryxdesign/cli": "0.7.3",
41
41
  "jscodeshift": "^17.4.0",
42
42
  "postcss": "^8.5.25",
43
43
  "postcss-selector-parser": "^7.1.6"
44
44
  },
45
45
  "peerDependencies": {
46
- "@lablup/ui-common": "0.2.0-alpha.19.pr-73-20261022061203-8f47fc7d0"
46
+ "@lablup/ui-common": "0.2.0-alpha.20"
47
47
  },
48
48
  "devDependencies": {
49
- "@lablup/ui-common": "0.2.0-alpha.19.pr-73-20261022061203-8f47fc7d0"
49
+ "@lablup/ui-common": "0.2.0-alpha.20"
50
50
  }
51
51
  }
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: ui-common
3
+ description: "@lablup/ui-common, Lablup's UI layer on the Astryx design system, is reachable on this computer through the ui-common command; use it when building or changing UI, choosing components or templates, layout, styling or theming in any project that depends on @lablup/ui-common, and use it there instead of the astryx skill."
4
+ ---
5
+
6
+ @lablup/ui-common is Lablup's UI layer on Astryx: Astryx's components under `@lablup/ui-common` import paths, the Lablup theme, and components of its own.
7
+ Use it when you build or change UI in a project whose package.json has `@lablup/ui-common`. There it replaces the `astryx` skill: run `ui-common`, not `astryx`, and never import `@astryxdesign/*`.
8
+ Run it through the project's package manager (`pnpm exec ui-common <cmd>`, `npx ui-common <cmd>`). Start with `ui-common search '<what you need>'`.
9
+ Then read `ui-common component <Name>` or `ui-common docs <topic>`, not the package's files. Import exactly what they print: the `@lablup/ui-common` root or one of its subpaths.
10
+
11
+ WORKFLOW — start every page from a template, never from scratch:
12
+
13
+ 1. `ui-common build "<idea>"` — names the template to scaffold from and the parts it lacks.
14
+ 2. `ui-common template <name> <path>` — scaffold it; keep its frame, gap and padding; replace the content.
15
+ 3. `ui-common template <Block>` for parts it lacks; `ui-common component <Name>` to read props before using one.
16
+
17
+ Follow the project's own agent docs (the UI-COMMON block); `ui-common agents --write AGENTS.md` writes one where there is none.
18
+ Before you deliver, re-read each file; replace any raw <div>/<span>, imported .css/@apply, or hardcoded #hex/px with the component or a token. Then build the project and deliver that build, not a description of it.
19
+ If the project has no `ui-common` command, add `@lablup/ui-common-cli` as a devDependency at the version of its `@lablup/ui-common`.