@lablup/ui-common-cli 0.2.0-alpha.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Rewrite Astryx CLI output into ui-common terms.
3
+ *
4
+ * Every replacement is a plain substring swap between two strings that contain
5
+ * no quote, backslash or control character, so it is safe inside JSON string
6
+ * literals: `--json` output stays valid JSON.
7
+ */
8
+ import { forkedExports, hiddenExports } from "./paths.mjs";
9
+
10
+ /** Astryx CLI subcommands. Used to tell `astryx component` from `astryx-base`. */
11
+ export const ASTRYX_COMMANDS = [
12
+ "init",
13
+ "component",
14
+ "docs",
15
+ "blog",
16
+ "swizzle",
17
+ "gap-report",
18
+ "template",
19
+ "layout",
20
+ "upgrade",
21
+ "theme",
22
+ "integration",
23
+ "hook",
24
+ "discover",
25
+ "search",
26
+ "build",
27
+ "doctor",
28
+ "manifest",
29
+ "help",
30
+ ];
31
+
32
+ const SPECIFIERS = [
33
+ // Order matters: the bare package names last, so they never eat a subpath.
34
+ [/@astryxdesign\/core(?=[/"'`\s),;:]|$)/g, "@lablup/ui-common"],
35
+ [/@astryxdesign\/lab(?=[/"'`\s),;:]|$)/g, "@lablup/ui-common/lab"],
36
+ [
37
+ /@astryxdesign\/theme-neutral(?=[/"'`\s),;:]|$)/g,
38
+ "@lablup/ui-common/theme/neutral",
39
+ ],
40
+ ];
41
+
42
+ /**
43
+ * `@astryxdesign/core/Button` -> `@lablup/ui-common/Button`, and the lab and
44
+ * neutral-theme packages onto their ui-common mirrors.
45
+ *
46
+ * @param {string} text
47
+ */
48
+ export function rewriteSpecifiers(text) {
49
+ let out = text;
50
+ for (const [pattern, replacement] of SPECIFIERS) {
51
+ out = out.replace(
52
+ /** @type {RegExp} */ (pattern),
53
+ /** @type {string} */ (replacement),
54
+ );
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * `pnpm exec astryx component X` -> `<invocation> component X`, and a bare
61
+ * `astryx component` in prose -> `ui-common component`. `astryx-base`,
62
+ * `astryx.css` and `.astryx-badge` are left alone: only the bin followed by a
63
+ * real subcommand is rewritten.
64
+ *
65
+ * @param {string} text
66
+ * @param {string} invocation how the project runs the ui-common bin
67
+ */
68
+ export function rewriteCommands(text, invocation = "ui-common") {
69
+ const commands = ASTRYX_COMMANDS.join("|");
70
+ return (
71
+ text
72
+ // A one-off `npx @astryxdesign/cli` invocation, or an installed-bin
73
+ // invocation through a package manager.
74
+ .replace(
75
+ /\b(?:npx|pnpm dlx|yarn dlx|bunx) @astryxdesign\/cli(?=[\s`'"]|$)/g,
76
+ invocation,
77
+ )
78
+ .replace(
79
+ /\b(?:npx|pnpm exec|pnpm|yarn|bunx|bun x|npm exec)\s+astryx(?=[\s`'"]|$)/g,
80
+ invocation,
81
+ )
82
+ .replace(
83
+ new RegExp(String.raw`(?<![\w/@.\-])astryx(?= (?:${commands})\b)`, "g"),
84
+ "ui-common",
85
+ )
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Lines telling the reader that a name they are looking at is one ui-common
91
+ * hides, and what to use instead; or one ui-common exports from its own copy
92
+ * of the Astryx component, and what that copy changes.
93
+ *
94
+ * @param {string} text rewritten output
95
+ * @param {string[]} args the command line, so `component Dialog` is caught
96
+ * even when the output never spells the name
97
+ */
98
+ export function exclusionNotes(text, args = []) {
99
+ const notes = [];
100
+ const mentions = (name) =>
101
+ new RegExp(String.raw`(?<![\w.-])${name}(?![\w.-])`).test(text) ||
102
+ args.includes(name);
103
+ for (const entry of hiddenExports()) {
104
+ // Asking about the replacement itself needs no pointer to it.
105
+ if (!entry.replacedBy || args.includes(entry.replacedBy)) continue;
106
+ const name = entry.name;
107
+ if (mentions(name)) {
108
+ notes.push(
109
+ `Note: @lablup/ui-common does not export ${name}. Use ${entry.replacedBy} ` +
110
+ `(@lablup/ui-common/${entry.replacedBy}), not ${name}. ${entry.reason}`,
111
+ );
112
+ }
113
+ }
114
+ for (const fork of forkedExports()) {
115
+ if (fork.names.some(mentions)) {
116
+ notes.push(
117
+ `Note: ${fork.name} (${fork.from}) comes from ui-common: its own copy of ` +
118
+ `Astryx's, same API and import path. ${fork.reason}`,
119
+ );
120
+ }
121
+ }
122
+ return notes;
123
+ }
124
+
125
+ /**
126
+ * The full output rewrite: specifiers, then command invocations.
127
+ *
128
+ * @param {string} text
129
+ * @param {string} [invocation]
130
+ */
131
+ export function rewriteOutput(text, invocation) {
132
+ return rewriteCommands(rewriteSpecifiers(text), invocation);
133
+ }
package/cli/semver.mjs ADDED
@@ -0,0 +1,278 @@
1
+ /**
2
+ * The little semver the upgrade registry needs: parse, compare with
3
+ * prerelease precedence, and coerce the loose forms a person types
4
+ * (`0.1`, `v0.1.0-alpha.19`) or a package.json holds (`^0.1.0-alpha.7`).
5
+ */
6
+
7
+ const SEMVER =
8
+ /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
9
+
10
+ /**
11
+ * @param {string} input
12
+ * @returns {{major: number, minor: number, patch: number, pre: string[]} | null}
13
+ */
14
+ export function parse(input) {
15
+ const match = SEMVER.exec(String(input).trim());
16
+ if (!match) return null;
17
+ return {
18
+ major: Number(match[1]),
19
+ minor: Number(match[2] ?? 0),
20
+ patch: Number(match[3] ?? 0),
21
+ pre: match[4] ? match[4].split(".") : [],
22
+ };
23
+ }
24
+
25
+ /** @param {{major: number, minor: number, patch: number, pre: string[]}} v */
26
+ export function format(v) {
27
+ return `${v.major}.${v.minor}.${v.patch}${v.pre.length ? `-${v.pre.join(".")}` : ""}`;
28
+ }
29
+
30
+ /**
31
+ * The first version a dependency spec admits, for `^0.1.0-alpha.7`,
32
+ * `~0.1.0`, `0.1.0-alpha.19`, `>=0.1.0-alpha.0 <0.2.0`. Null for
33
+ * `workspace:*`, tags and URLs.
34
+ *
35
+ * @param {string} spec
36
+ */
37
+ export function coerce(spec) {
38
+ const match = /(?:^|[\s^~>=<v])(\d+(?:\.\d+){0,2}(?:-[0-9A-Za-z.-]+)?)/.exec(
39
+ ` ${String(spec).trim()}`,
40
+ );
41
+ if (!match) return null;
42
+ const parsed = parse(match[1]);
43
+ return parsed ? format(parsed) : null;
44
+ }
45
+
46
+ /** @param {string} a @param {string} b */
47
+ function compareIdentifiers(a, b) {
48
+ const na = /^\d+$/.test(a);
49
+ const nb = /^\d+$/.test(b);
50
+ if (na && nb) return Number(a) - Number(b);
51
+ if (na) return -1;
52
+ if (nb) return 1;
53
+ return a < b ? -1 : a > b ? 1 : 0;
54
+ }
55
+
56
+ /**
57
+ * Semver precedence. A version with a prerelease sorts before the same
58
+ * version without one.
59
+ *
60
+ * @param {string} a
61
+ * @param {string} b
62
+ */
63
+ export function compare(a, b) {
64
+ const va = parse(a);
65
+ const vb = parse(b);
66
+ if (!va || !vb) throw new Error(`Not a version: ${!va ? a : b}`);
67
+ for (const key of /** @type {const} */ (["major", "minor", "patch"])) {
68
+ if (va[key] !== vb[key]) return va[key] - vb[key];
69
+ }
70
+ if (va.pre.length === 0 || vb.pre.length === 0) {
71
+ return vb.pre.length - va.pre.length;
72
+ }
73
+ for (let i = 0; i < Math.max(va.pre.length, vb.pre.length); i++) {
74
+ const x = va.pre[i];
75
+ const y = vb.pre[i];
76
+ if (x === undefined) return -1;
77
+ if (y === undefined) return 1;
78
+ const c = compareIdentifiers(x, y);
79
+ if (c !== 0) return c;
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ /**
85
+ * The version after `version` for a release that carries a breaking change:
86
+ * the next prerelease number while in prerelease, else the next minor (pre-1.0
87
+ * a breaking change bumps the minor).
88
+ *
89
+ * @param {string} version
90
+ */
91
+ export function nextBreaking(version) {
92
+ const v = parse(version);
93
+ if (!v) throw new Error(`Not a version: ${version}`);
94
+ if (v.pre.length > 0) {
95
+ const last = v.pre[v.pre.length - 1];
96
+ const pre = /^\d+$/.test(last)
97
+ ? [...v.pre.slice(0, -1), String(Number(last) + 1)]
98
+ : [...v.pre, "1"];
99
+ return format({ ...v, pre });
100
+ }
101
+ if (v.major === 0) return format({ major: 0, minor: v.minor + 1, patch: 0, pre: [] });
102
+ return format({ major: v.major + 1, minor: 0, patch: 0, pre: [] });
103
+ }
104
+
105
+ /**
106
+ * @typedef {{version: string, inclusive: boolean, text?: string}} Bound
107
+ * `text` is the comparator as written, kept when the bound survives.
108
+ */
109
+
110
+ /**
111
+ * A partial version (`19`, `19.x`, `19.2.*`) as its first admitted version
112
+ * and the version its x-range stops before. Null for anything else.
113
+ *
114
+ * @param {string} input
115
+ */
116
+ function partial(input) {
117
+ const m =
118
+ /^v?(\d+|[xX*])(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
119
+ input,
120
+ );
121
+ if (!m) return null;
122
+ const wild = (/** @type {string | undefined} */ s) => s == null || /^[xX*]$/.test(s);
123
+ if (wild(m[1])) return { min: null, end: null, full: false, parts: 0 };
124
+ const major = Number(m[1]);
125
+ if (wild(m[2]))
126
+ return { min: `${major}.0.0`, end: `${major + 1}.0.0`, full: false, parts: 1 };
127
+ const minor = Number(m[2]);
128
+ if (wild(m[3]))
129
+ return {
130
+ min: `${major}.${minor}.0`,
131
+ end: `${major}.${minor + 1}.0`,
132
+ full: false,
133
+ parts: 2,
134
+ };
135
+ const version = `${major}.${minor}.${Number(m[3])}${m[4] ? `-${m[4]}` : ""}`;
136
+ return { min: version, end: null, full: true, parts: 3 };
137
+ }
138
+
139
+ /**
140
+ * One `||` alternative as a lower and an upper bound; null when it is not a
141
+ * range this reads (a tag, a URL).
142
+ *
143
+ * @param {string} alternative
144
+ * @returns {{lower: Bound | null, upper: Bound | null, hyphen?: string} | null}
145
+ */
146
+ function bounds(alternative) {
147
+ const text = alternative.trim();
148
+ if (text === "") return { lower: null, upper: null };
149
+ const hyphen = /^(\S+)\s+-\s+(\S+)$/.exec(text);
150
+ if (hyphen) {
151
+ const from = partial(hyphen[1]);
152
+ const to = partial(hyphen[2]);
153
+ if (!from || !to) return null;
154
+ return {
155
+ lower: from.min ? { version: from.min, inclusive: true } : null,
156
+ upper: to.full
157
+ ? { version: /** @type {string} */ (to.min), inclusive: true }
158
+ : to.end
159
+ ? { version: to.end, inclusive: false }
160
+ : null,
161
+ hyphen: hyphen[2],
162
+ };
163
+ }
164
+ /** @type {Bound | null} */
165
+ let lower = null;
166
+ /** @type {Bound | null} */
167
+ let upper = null;
168
+ /** @param {Bound} b */
169
+ const raise = (b) => {
170
+ if (!lower || compare(b.version, lower.version) > 0) lower = b;
171
+ };
172
+ /** @param {Bound} b */
173
+ const cap = (b) => {
174
+ const c = upper ? compare(b.version, upper.version) : -1;
175
+ if (c < 0 || (c === 0 && !b.inclusive)) upper = b;
176
+ };
177
+ for (const token of text.replace(/(<=|>=|[<>=^~])\s+/g, "$1").split(/\s+/)) {
178
+ const m = /^(<=|>=|<|>|=|\^|~)?(.*)$/.exec(token);
179
+ const op = m?.[1] ?? "";
180
+ const v = partial(m?.[2] ?? "");
181
+ if (!v) return null;
182
+ if (!v.min) {
183
+ // `*`, `x`, `>=*`: no bound; `<*` admits nothing.
184
+ if (op === "<" || op === ">")
185
+ return { lower: null, upper: { version: "0.0.0", inclusive: false } };
186
+ continue;
187
+ }
188
+ const at = /** @type {string} */ (v.min);
189
+ if (op === "^" || op === "~") {
190
+ const p = /** @type {{major: number, minor: number, patch: number}} */ (
191
+ parse(at)
192
+ );
193
+ raise({ version: at, inclusive: true });
194
+ const end =
195
+ op === "~"
196
+ ? v.parts >= 2
197
+ ? `${p.major}.${p.minor + 1}.0`
198
+ : `${p.major + 1}.0.0`
199
+ : p.major > 0 || v.parts === 1
200
+ ? `${p.major + 1}.0.0`
201
+ : p.minor > 0 || v.parts === 2
202
+ ? `0.${p.minor + 1}.0`
203
+ : `0.0.${p.patch + 1}`;
204
+ cap({ version: end, inclusive: false });
205
+ } else if (op === ">=") raise({ version: at, inclusive: true, text: token });
206
+ else if (op === ">")
207
+ raise(
208
+ v.full
209
+ ? { version: at, inclusive: false, text: token }
210
+ : { version: /** @type {string} */ (v.end), inclusive: true, text: token },
211
+ );
212
+ else if (op === "<") cap({ version: at, inclusive: false, text: token });
213
+ else if (op === "<=")
214
+ cap(
215
+ v.full
216
+ ? { version: at, inclusive: true, text: token }
217
+ : { version: /** @type {string} */ (v.end), inclusive: false, text: token },
218
+ );
219
+ else {
220
+ raise({ version: at, inclusive: true });
221
+ cap(
222
+ v.full
223
+ ? { version: at, inclusive: true }
224
+ : { version: /** @type {string} */ (v.end), inclusive: false },
225
+ );
226
+ }
227
+ }
228
+ return { lower, upper };
229
+ }
230
+
231
+ /**
232
+ * `range` narrowed to the versions at or above `floor`, alternative by
233
+ * alternative: `>=18 <21 || ^22` at 19.2.0 is `>=19.2.0 <21 || ^22`. An
234
+ * alternative already above the floor keeps its text; one wholly below it is
235
+ * dropped. Null when nothing is left; `range` itself when it is not a range
236
+ * this reads.
237
+ *
238
+ * @param {string} range
239
+ * @param {string} floor a full version
240
+ * @returns {string | null}
241
+ */
242
+ export function narrowToFloor(range, floor) {
243
+ const f = parse(floor);
244
+ if (!f) throw new Error(`Not a version: ${floor}`);
245
+ const alternatives = range.split("||").map((a) => a.trim());
246
+ const parsed = alternatives.map(bounds);
247
+ if (parsed.some((b) => b == null)) return range;
248
+ /** @type {string[]} */
249
+ const kept = [];
250
+ for (const [i, b] of /** @type {NonNullable<ReturnType<typeof bounds>>[]} */ (
251
+ parsed
252
+ ).entries()) {
253
+ const { lower, upper } = b;
254
+ if (lower && compare(lower.version, floor) >= 0) {
255
+ kept.push(alternatives[i]);
256
+ continue;
257
+ }
258
+ if (upper) {
259
+ const c = compare(upper.version, floor);
260
+ if (c < 0 || (c === 0 && !upper.inclusive)) continue;
261
+ }
262
+ let next;
263
+ if (!upper) next = `>=${floor}`;
264
+ else if (b.hyphen != null) next = `${floor} - ${b.hyphen}`;
265
+ else if (!upper.inclusive && upper.version === `${f.major + 1}.0.0` && f.major > 0)
266
+ next = `^${floor}`;
267
+ else if (upper.inclusive && upper.version === floor) next = floor;
268
+ else
269
+ next = `>=${floor} ${upper.text ?? `${upper.inclusive ? "<=" : "<"}${upper.version}`}`;
270
+ kept.push(next);
271
+ }
272
+ const unique = [...new Set(kept)];
273
+ if (unique.length === 0) return null;
274
+ return unique.length === alternatives.length &&
275
+ unique.every((a, i) => a === alternatives[i])
276
+ ? range
277
+ : unique.join(" || ");
278
+ }
package/cli/shadow.mjs ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Let Astryx's lookup commands find @astryxdesign/core in a project that only
3
+ * depends on @lablup/ui-common.
4
+ *
5
+ * `astryx component`, `search`, `docs`, `build` and friends locate core by
6
+ * walking up from the working directory for `node_modules/@astryxdesign/core`.
7
+ * Under pnpm's isolated layout a ui-common consumer has no such directory:
8
+ * core is ui-common's dependency, not the project's. So, for read-only
9
+ * commands only, the CLI runs in a throwaway "shadow" of the project: its
10
+ * package.json, config and lockfile, and a node_modules made of symlinks to
11
+ * the project's own packages plus the Astryx packages ui-common pins. The
12
+ * project's integrations (ui-common's included) load exactly as they would in
13
+ * place. Paths in the output are mapped back to the project.
14
+ *
15
+ * Commands that write files (`init`, `swizzle`, `template <name> <path>`,
16
+ * `theme add`, …) never run in a shadow.
17
+ */
18
+ import {
19
+ existsSync,
20
+ mkdirSync,
21
+ mkdtempSync,
22
+ readdirSync,
23
+ rmSync,
24
+ symlinkSync,
25
+ } from "node:fs";
26
+ import { tmpdir } from "node:os";
27
+ import { dirname, join } from "node:path";
28
+
29
+ import { dependencyDir, findProjectDir } from "./paths.mjs";
30
+
31
+ /** Astryx commands that only read. */
32
+ const READ_ONLY = new Set([
33
+ "component",
34
+ "search",
35
+ "docs",
36
+ "build",
37
+ "hook",
38
+ "discover",
39
+ "blog",
40
+ "layout",
41
+ "template",
42
+ ]);
43
+
44
+ const ASTRYX_PACKAGES = [
45
+ "@astryxdesign/core",
46
+ "@astryxdesign/theme-neutral",
47
+ "@astryxdesign/lab",
48
+ ];
49
+
50
+ const PROJECT_FILES = [
51
+ "package.json",
52
+ "astryx.config.ts",
53
+ "astryx.config.mts",
54
+ "astryx.config.js",
55
+ "astryx.config.mjs",
56
+ "astryx.config.cjs",
57
+ "pnpm-lock.yaml",
58
+ "yarn.lock",
59
+ "package-lock.json",
60
+ "bun.lock",
61
+ "AGENTS.md",
62
+ "CLAUDE.md",
63
+ ".claude",
64
+ ".cursorrules",
65
+ ];
66
+
67
+ /**
68
+ * Whether the Astryx CLI finds core from `cwd` (its own findCoreDir walk:
69
+ * five levels of `packages/core` or `node_modules/@astryxdesign/core`).
70
+ *
71
+ * @param {string} cwd
72
+ */
73
+ export function coreReachableFrom(cwd) {
74
+ let dir = cwd;
75
+ for (let i = 0; i < 5; i++) {
76
+ if (existsSync(join(dir, "packages", "core"))) return true;
77
+ if (existsSync(join(dir, "node_modules", "@astryxdesign", "core"))) return true;
78
+ const parent = dirname(dir);
79
+ if (parent === dir) break;
80
+ dir = parent;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Whether this command should run in a shadow of the project.
87
+ *
88
+ * @param {string[]} args
89
+ * @param {string} cwd
90
+ */
91
+ export function needsShadow(args, cwd) {
92
+ const positionals = args.filter((a) => !a.startsWith("-"));
93
+ const command = positionals[0];
94
+ if (!command || !READ_ONLY.has(command)) return false;
95
+ // `template <name> <path>` and `--cdn` write files.
96
+ if (
97
+ command === "template" &&
98
+ (positionals.length > 2 || args.some((a) => a.startsWith("--cdn")))
99
+ ) {
100
+ return false;
101
+ }
102
+ if (!dependencyDir("@astryxdesign/core")) return false;
103
+ return !coreReachableFrom(cwd);
104
+ }
105
+
106
+ /** @param {string} target @param {string} link */
107
+ function link(target, link) {
108
+ symlinkSync(target, link, process.platform === "win32" ? "junction" : undefined);
109
+ }
110
+
111
+ /**
112
+ * @param {string} cwd
113
+ * @returns {{dir: string, projectDir: string, cleanup: () => void}}
114
+ */
115
+ export function createShadow(cwd) {
116
+ const projectDir = findProjectDir(cwd) ?? cwd;
117
+ const dir = mkdtempSync(join(tmpdir(), "ui-common-astryx-"));
118
+ for (const file of PROJECT_FILES) {
119
+ const source = join(projectDir, file);
120
+ if (existsSync(source)) link(source, join(dir, file));
121
+ }
122
+
123
+ const modules = join(dir, "node_modules");
124
+ mkdirSync(modules);
125
+ // The project's installed packages, from the nearest node_modules.
126
+ let search = projectDir;
127
+ for (;;) {
128
+ const nm = join(search, "node_modules");
129
+ if (existsSync(nm)) {
130
+ for (const entry of readdirSync(nm)) {
131
+ if (entry.startsWith(".")) continue;
132
+ if (entry.startsWith("@")) {
133
+ mkdirSync(join(modules, entry), { recursive: true });
134
+ for (const scoped of readdirSync(join(nm, entry))) {
135
+ link(join(nm, entry, scoped), join(modules, entry, scoped));
136
+ }
137
+ } else {
138
+ link(join(nm, entry), join(modules, entry));
139
+ }
140
+ }
141
+ break;
142
+ }
143
+ const parent = dirname(search);
144
+ if (parent === search) break;
145
+ search = parent;
146
+ }
147
+ // The Astryx packages ui-common pins, where the project has none.
148
+ for (const name of ASTRYX_PACKAGES) {
149
+ const target = join(modules, ...name.split("/"));
150
+ const source = dependencyDir(name);
151
+ if (!source || existsSync(target)) continue;
152
+ mkdirSync(dirname(target), { recursive: true });
153
+ link(source, target);
154
+ }
155
+
156
+ return {
157
+ dir,
158
+ projectDir,
159
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
160
+ };
161
+ }