@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,333 @@
1
+ /**
2
+ * 0.1 -> 0.2 package.json edits:
3
+ * - bump @lablup/ui-common to the target version (keeping `^`/`~`);
4
+ * - add @lablup/ui-common-cli, the `ui-common` bin, to devDependencies at
5
+ * exactly the target version (0.1 shipped the bin inside ui-common);
6
+ * - add the @stylexjs/stylex peer ui-common 0.2 needs, when missing;
7
+ * - in a library, drop React 18 from the react / react-dom peer ranges;
8
+ * - in a pnpm project, decline the Astryx postinstalls in `allowBuilds`;
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).
13
+ */
14
+ import { applyLabOverride, CORE, detectPackageManager } from "../../cli/lab-peer.mjs";
15
+ import { targetUiCommonRoot, uiCommonPackageJson } from "../../cli/paths.mjs";
16
+ import { coerce, narrowToFloor } from "../../cli/semver.mjs";
17
+ import { LAB_PACKAGE, stylexPeer, UIC } from "./map.mjs";
18
+
19
+ /** The CLI package, released in lockstep with ui-common. */
20
+ export const CLI_PACKAGE = "@lablup/ui-common-cli";
21
+
22
+ const FIELDS = /** @type {const} */ ([
23
+ "dependencies",
24
+ "devDependencies",
25
+ "peerDependencies",
26
+ ]);
27
+
28
+ /** @param {Record<string, string>} object */
29
+ function isSorted(object) {
30
+ const keys = Object.keys(object);
31
+ return keys.every((k, i) => i === 0 || keys[i - 1].localeCompare(k) <= 0);
32
+ }
33
+
34
+ /**
35
+ * @param {any} pkg
36
+ * @param {string} field
37
+ * @param {string} name
38
+ * @param {string} range
39
+ */
40
+ function addDependency(pkg, field, name, range) {
41
+ const current = pkg[field] ?? {};
42
+ const next = { ...current, [name]: range };
43
+ pkg[field] = isSorted(current)
44
+ ? Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b)))
45
+ : next;
46
+ }
47
+
48
+ /**
49
+ * @param {string} spec
50
+ * @param {string} to
51
+ * @returns {{value: string, note?: string} | null}
52
+ */
53
+ function bumpSpec(spec, to) {
54
+ if (/^(workspace:|link:|file:|npm:|git|https?:)/.test(spec)) return null;
55
+ const simple = /^([\^~]?)v?\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.exec(spec.trim());
56
+ if (simple) return { value: `${simple[1]}${to}` };
57
+ return {
58
+ value: `^${to}`,
59
+ note: `the range "${spec}" was replaced with "^${to}"; widen it again if this package must still accept 0.1.`,
60
+ };
61
+ }
62
+
63
+ /** Packages whose postinstall pnpm 11 must be told to run or skip. */
64
+ export const DECLINED_BUILDS = ["@astryxdesign/core", "@astryxdesign/cli"];
65
+
66
+ /**
67
+ * `pnpm-workspace.yaml` with `allowBuilds` declining each of `names` it does
68
+ * not decide yet. An entry set to true or false is left as it is; one with
69
+ * any other value (pnpm writes "set this to true or false" when it stops an
70
+ * install) counts as undecided and is set to false. Comments and every other
71
+ * line are kept.
72
+ *
73
+ * @param {string | null} yaml current text, null when there is no file
74
+ * @param {string[]} names
75
+ * @returns {{yaml?: string, notes: string[]}}
76
+ */
77
+ export function applyAllowBuilds(yaml, names) {
78
+ const line = (/** @type {string} */ name, indent = " ") =>
79
+ `${indent}"${name}": false`;
80
+ const why =
81
+ "their postinstall only prints an `astryx init` nudge, and pnpm 11 stops the install (ERR_PNPM_IGNORED_BUILDS) until each is allowed or declined";
82
+ if (yaml == null) {
83
+ return {
84
+ yaml: `allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`,
85
+ notes: [
86
+ `wrote pnpm-workspace.yaml with allowBuilds declining ${names.join(", ")}: ${why}.`,
87
+ ],
88
+ };
89
+ }
90
+ const block = /^allowBuilds:[ \t]*(#.*)?(\r?\n)/m.exec(yaml);
91
+ if (!block) {
92
+ if (/^allowBuilds\s*:/m.test(yaml)) {
93
+ return {
94
+ notes: [
95
+ `pnpm-workspace.yaml has an allowBuilds entry this cannot edit; decline ${names.join(", ")} in it: ${why}.`,
96
+ ],
97
+ };
98
+ }
99
+ const base = yaml.replace(/\s*$/, "");
100
+ return {
101
+ yaml: `${base}${base ? "\n\n" : ""}allowBuilds:\n${names.map((n) => line(n)).join("\n")}\n`,
102
+ notes: [
103
+ `added allowBuilds declining ${names.join(", ")} to pnpm-workspace.yaml: ${why}.`,
104
+ ],
105
+ };
106
+ }
107
+ const at = block.index + block[0].length;
108
+ const after = yaml.slice(at);
109
+ const end = /^\S/m.exec(after)?.index ?? after.length;
110
+ let body = after.slice(0, end);
111
+ const indent = /^([ \t]+)\S/m.exec(body)?.[1] ?? " ";
112
+ const notes = [];
113
+ const added = [];
114
+ for (const name of names) {
115
+ const esc = name.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
116
+ const entry = new RegExp(
117
+ `^([ \\t]+["']?${esc}["']?[ \\t]*:[ \\t]*)([^\\r\\n#]*?)([ \\t]*(#.*)?)$`,
118
+ "m",
119
+ );
120
+ const match = entry.exec(body);
121
+ if (!match) {
122
+ added.push(name);
123
+ continue;
124
+ }
125
+ const value = match[2].trim().replace(/^["']|["']$/g, "");
126
+ if (value === "true" || value === "false") continue;
127
+ body = body.replace(entry, (_m, head, _v, tail) => `${head}false${tail}`);
128
+ notes.push(
129
+ `pnpm-workspace.yaml allowBuilds["${name}"] was "${match[2].trim()}", which pnpm does not accept; set it to false.`,
130
+ );
131
+ }
132
+ if (added.length > 0) {
133
+ const trimmed = body.replace(/\s*$/, "");
134
+ const rest = body.slice(trimmed.length);
135
+ body = `${trimmed}${trimmed ? "\n" : ""}${added.map((n) => line(n, indent)).join("\n")}${rest.includes("\n") ? rest : "\n"}`;
136
+ notes.push(
137
+ `added ${added.join(", ")} to allowBuilds in pnpm-workspace.yaml, declined: ${why}.`,
138
+ );
139
+ }
140
+ const next = `${yaml.slice(0, at)}${body}${after.slice(end)}`;
141
+ return next === yaml ? { notes } : { yaml: next, notes };
142
+ }
143
+
144
+ /** React's floor when the target ui-common's peer cannot be read. */
145
+ const REACT_FLOOR = "19.2.0";
146
+
147
+ /**
148
+ * A React range narrowed to `floor` and up (`>=18 <21 || ^22` →
149
+ * `>=19.2.0 <21 || ^22`); null when it admits no version that high, the
150
+ * range itself when it already starts there or is not a version range.
151
+ *
152
+ * @param {string} range
153
+ * @param {string} floor
154
+ * @returns {string | null}
155
+ */
156
+ export function narrowReactRange(range, floor) {
157
+ if (/^(workspace:|link:|file:|npm:|catalog:)/.test(range.trim())) return range;
158
+ return narrowToFloor(range, floor);
159
+ }
160
+
161
+ /**
162
+ * Point the lab canary's core peer at ui-common's core: `overrides` in
163
+ * package.json for npm (the project's own, when it is the install root),
164
+ * `overrides` in pnpm-workspace.yaml for pnpm, a note otherwise.
165
+ *
166
+ * @param {any} pkg parsed package.json, edited in place
167
+ * @param {{projectDir?: string, note: (message: string) => void, editFile?: (path: string, edit: (current: string | null) => string | undefined) => void}} ctx
168
+ */
169
+ function addLabOverride(pkg, ctx) {
170
+ const pin = uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).dependencies?.[
171
+ CORE
172
+ ];
173
+ if (!pin || !ctx.projectDir || !ctx.editFile) return;
174
+ const { manager, root, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg);
175
+ if (manager === "npm" && root !== ctx.projectDir) {
176
+ // npm reads overrides from the install root's package.json only.
177
+ const { note } = applyLabOverride({ manager: null, pin });
178
+ ctx.note(`npm installs from ${root}, not this package: ${note}`);
179
+ return;
180
+ }
181
+ if (manager === "pnpm" && workspaceYaml) {
182
+ const yamlFile = workspaceYaml;
183
+ ctx.editFile(yamlFile, (current) => {
184
+ const edit = applyLabOverride({ manager, pin, workspaceYaml: current });
185
+ if (edit.note) ctx.note(edit.note);
186
+ return edit.workspaceYaml;
187
+ });
188
+ return;
189
+ }
190
+ const { note } = applyLabOverride({ manager, pin, pkg });
191
+ ctx.note(note);
192
+ }
193
+
194
+ /**
195
+ * @param {string} text package.json source
196
+ * @param {{to: string, flags: {packages: Map<string, string>}, note: (message: string) => void}} ctx
197
+ */
198
+ export function transformPackageJson(text, ctx) {
199
+ const pkg = JSON.parse(text);
200
+ const indent = /^([ \t]+)"/m.exec(text)?.[1] ?? " ";
201
+ const fields = FIELDS.filter((f) => pkg[f]?.[UIC] != null);
202
+ if (fields.length === 0) return undefined;
203
+
204
+ for (const field of fields) {
205
+ const spec = pkg[field][UIC];
206
+ const bumped = bumpSpec(spec, ctx.to);
207
+ if (!bumped) {
208
+ ctx.note(`${field}["${UIC}"] is "${spec}"; not a version, so it was left alone.`);
209
+ continue;
210
+ }
211
+ if (bumped.value !== spec) {
212
+ pkg[field][UIC] = bumped.value;
213
+ ctx.note(`${field}["${UIC}"]: "${spec}" → "${bumped.value}".`);
214
+ }
215
+ if (bumped.note) ctx.note(`${field}["${UIC}"]: ${bumped.note}`);
216
+ }
217
+
218
+ const has = (/** @type {string} */ name) =>
219
+ FIELDS.some((f) => pkg[f]?.[name] != null);
220
+
221
+ // The bin moved out of ui-common into its own package. It is a dev-time
222
+ // tool, pinned exactly: it upgrades to and reads the ui-common of its own
223
+ // version.
224
+ const cliField = FIELDS.find((f) => pkg[f]?.[CLI_PACKAGE] != null);
225
+ if (!cliField) {
226
+ addDependency(pkg, "devDependencies", CLI_PACKAGE, ctx.to);
227
+ ctx.note(
228
+ `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}.`,
229
+ );
230
+ } else {
231
+ const spec = pkg[cliField][CLI_PACKAGE];
232
+ const bumped = bumpSpec(spec, ctx.to);
233
+ if (!bumped) {
234
+ ctx.note(
235
+ `${cliField}["${CLI_PACKAGE}"] is "${spec}"; not a version, so it was left alone.`,
236
+ );
237
+ } else if (spec !== ctx.to) {
238
+ pkg[cliField][CLI_PACKAGE] = ctx.to;
239
+ ctx.note(`${cliField}["${CLI_PACKAGE}"]: "${spec}" → "${ctx.to}".`);
240
+ }
241
+ }
242
+ // A library that takes ui-common as a peer takes StyleX as a peer too;
243
+ // an application depends on it.
244
+ const library = fields.includes("peerDependencies");
245
+
246
+ /**
247
+ * Add `name` where this project needs it, and return the fields it went
248
+ * into. A library needs it as a peer and, when it develops against
249
+ * ui-common, as a devDependency: each is checked on its own, since one does
250
+ * not stand in for the other. An application needs it anywhere.
251
+ *
252
+ * @param {string} name
253
+ * @param {string} range
254
+ */
255
+ const ensure = (name, range) => {
256
+ /** @type {string[]} */
257
+ const wanted = library
258
+ ? [
259
+ ...(pkg.peerDependencies?.[name] == null && pkg.dependencies?.[name] == null
260
+ ? ["peerDependencies"]
261
+ : []),
262
+ ...(fields.includes("devDependencies") && pkg.devDependencies?.[name] == null
263
+ ? ["devDependencies"]
264
+ : []),
265
+ ]
266
+ : has(name)
267
+ ? []
268
+ : [fields.includes("dependencies") ? "dependencies" : fields[0]];
269
+ for (const field of wanted) addDependency(pkg, field, name, range);
270
+ return wanted;
271
+ };
272
+
273
+ const stylex = stylexPeer();
274
+ const stylexAdded = ensure(stylex.name, stylex.range);
275
+ if (stylexAdded.length > 0)
276
+ ctx.note(`added ${stylex.name} ${stylex.range} to ${stylexAdded.join(" and ")}.`);
277
+
278
+ // 0.2 needs React 19.2: a library that still accepts older React in its
279
+ // peers would install beside such an app and break there.
280
+ const reactPeers =
281
+ uiCommonPackageJson(targetUiCommonRoot(ctx.projectDir)).peerDependencies ?? {};
282
+ for (const name of ["react", "react-dom"]) {
283
+ const floor = coerce(reactPeers[name] ?? "") ?? REACT_FLOOR;
284
+ const spec = pkg.peerDependencies?.[name];
285
+ if (library && typeof spec === "string") {
286
+ const next = narrowReactRange(spec, floor);
287
+ if (next == null) {
288
+ ctx.note(
289
+ `peerDependencies["${name}"] is "${spec}", which admits no React ${floor} or later; ${UIC} 0.2 needs it, so move the range up by hand.`,
290
+ );
291
+ } else if (next !== spec) {
292
+ pkg.peerDependencies[name] = next;
293
+ ctx.note(
294
+ `peerDependencies["${name}"]: "${spec}" → "${next}": ${UIC} 0.2 needs React ${floor} or later.`,
295
+ );
296
+ }
297
+ }
298
+ const own = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
299
+ if (typeof own === "string" && narrowReactRange(own, floor) !== own) {
300
+ ctx.note(
301
+ `${pkg.dependencies?.[name] ? "dependencies" : "devDependencies"}["${name}"] is "${own}": ${UIC} 0.2 needs React ${floor} or later; upgrade React too.`,
302
+ );
303
+ }
304
+ }
305
+
306
+ // pnpm 11 refuses to install until the Astryx postinstalls are decided.
307
+ if (ctx.projectDir && ctx.editFile) {
308
+ const { manager, workspaceYaml } = detectPackageManager(ctx.projectDir, pkg);
309
+ if (manager === "pnpm" && workspaceYaml) {
310
+ ctx.editFile(workspaceYaml, (current) => {
311
+ const edit = applyAllowBuilds(current, DECLINED_BUILDS);
312
+ for (const note of edit.notes) ctx.note(note);
313
+ return edit.yaml;
314
+ });
315
+ }
316
+ }
317
+
318
+ // Packages the map says a moved component needs (the lab Drawer).
319
+ for (const [lab, range] of ctx.flags.packages) {
320
+ const added = ensure(lab, range);
321
+ if (added.length === 0) continue;
322
+ const field = added.join(" and ");
323
+ ctx.note(
324
+ lab === LAB_PACKAGE
325
+ ? `added ${lab} ${range} to ${field}: a Drawer moved to ${UIC}/lab, and ui-common pins the lab canary exactly.`
326
+ : `added ${lab} ${range} to ${field}.`,
327
+ );
328
+ if (lab === LAB_PACKAGE) addLabOverride(pkg, ctx);
329
+ }
330
+
331
+ const out = `${JSON.stringify(pkg, null, indent)}${text.endsWith("\n") ? "\n" : ""}`;
332
+ return out === text ? undefined : out;
333
+ }