@yopem-ui/cli 0.0.2 → 0.1.1

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/src/init.ts CHANGED
@@ -1,16 +1,27 @@
1
- import { readFile, realpath, writeFile, mkdir, unlink } from "node:fs/promises"
2
- import { dirname, join } from "node:path"
3
- import ts from "typescript-api"
4
-
5
- import type { InstallOptions, JsonObject, JsonValue } from "./install"
1
+ import type {
2
+ FileChange,
3
+ InstallOptions,
4
+ JsonObject,
5
+ JsonValue,
6
+ } from "@yopem-ui/cli/install"
6
7
 
7
8
  import {
9
+ applyInstall,
8
10
  existingFile,
9
- installItem,
10
11
  isRecord,
11
12
  isString,
12
- runBun,
13
- } from "./install"
13
+ planInstall,
14
+ writeFiles,
15
+ } from "@yopem-ui/cli/install"
16
+ import {
17
+ isPackageName,
18
+ packageRunner,
19
+ pendingDependencies,
20
+ workspaceRoot,
21
+ } from "@yopem-ui/cli/project"
22
+ import { readFile, realpath } from "node:fs/promises"
23
+ import { join, relative, resolve } from "node:path"
24
+ import ts from "typescript-api"
14
25
 
15
26
  type Framework =
16
27
  | "vite"
@@ -20,7 +31,10 @@ type Framework =
20
31
  | "next"
21
32
  | "astro"
22
33
 
23
- type PackageManager = "bun" | "npm" | "pnpm" | "yarn"
34
+ interface SharedUI {
35
+ name: string
36
+ source: string
37
+ }
24
38
 
25
39
  interface Edit {
26
40
  start: number
@@ -28,8 +42,28 @@ interface Edit {
28
42
  text: string
29
43
  }
30
44
 
45
+ export interface InitResult {
46
+ framework: Framework
47
+ configured: number
48
+ warnings?: string[]
49
+ preview?: {
50
+ files: {
51
+ path: string
52
+ action: "write" | "skip" | "delete" | "conflict"
53
+ reason?: string
54
+ }[]
55
+ dependencies: {
56
+ cwd: string
57
+ dependencies: string[]
58
+ devDependencies: string[]
59
+ }[]
60
+ prerequisites: string[]
61
+ }
62
+ }
63
+
31
64
  export interface InitOptions extends InstallOptions {
32
65
  framework?: Framework
66
+ ui?: string
33
67
  }
34
68
 
35
69
  function object(value: JsonValue | undefined): value is JsonObject {
@@ -266,6 +300,19 @@ function addAlias(
266
300
  ) {
267
301
  throw new Error(`Incompatible @ alias in ${path}`)
268
302
  }
303
+
304
+ const aliasValue = declaration?.initializer ?? current.initializer
305
+
306
+ if (
307
+ ts.isStringLiteral(aliasValue) &&
308
+ /^(?:\.\/)?src\/?$/.test(aliasValue.text)
309
+ ) {
310
+ edits.push({
311
+ start: current.initializer.getStart(source),
312
+ end: current.initializer.end,
313
+ text: "yopemSource",
314
+ })
315
+ }
269
316
  }
270
317
 
271
318
  function moduleConfig(source: ts.SourceFile, path: string) {
@@ -549,14 +596,18 @@ function babelPlugin(presets = false) {
549
596
  return `babel({ plugins: yopemBabelPlugins${presets ? ", presets: yopemBabelPresets" : ""} })`
550
597
  }
551
598
 
552
- function stylexSetup(babelPlugins: string[] = [], babelPresets: string[] = []) {
599
+ function stylexSetup(
600
+ babelPlugins: string[] = [],
601
+ babelPresets: string[] = [],
602
+ shared?: SharedUI,
603
+ ) {
553
604
  return `import babel from "@rolldown/plugin-babel"
554
605
  import { createRequire as yopemCreateRequire } from "node:module"
555
606
  import { fileURLToPath as yopemFileURLToPath } from "node:url"
556
607
 
557
- const yopemSource = yopemFileURLToPath(new URL("./src", import.meta.url))
608
+ const yopemSource = yopemFileURLToPath(new URL("./src", import.meta.url))${shared ? `\nconst yopemUISource = yopemFileURLToPath(new URL(${JSON.stringify(shared.source)}, import.meta.url))` : ""}
558
609
  const yopemBabelPlugins = [${babelPlugins.join(", ")}${babelPlugins.length ? ", " : ""}["@stylexjs/babel-plugin", {
559
- aliases: { "@/*": [yopemSource + "/*"] },
610
+ aliases: { "@/*": [yopemSource + "/*"]${shared ? `, ${JSON.stringify(`${shared.name}/*`)}: [yopemUISource + "/*"]` : ""} },
560
611
  dev: process.env.NODE_ENV !== "production",
561
612
  runtimeInjection: false,
562
613
  treeshakeCompensation: true,
@@ -564,7 +615,7 @@ const yopemBabelPlugins = [${babelPlugins.join(", ")}${babelPlugins.length ? ",
564
615
  }]]${babelPresets.length ? `\nconst yopemBabelPresets = [${babelPresets.join(", ")}]` : ""}
565
616
  const yopemPostcssPlugin = yopemCreateRequire(import.meta.url)("@stylexjs/postcss-plugin")({
566
617
  cwd: yopemFileURLToPath(new URL(".", import.meta.url)),
567
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"],
618
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}"${shared ? ', yopemUISource + "/**/*.{js,jsx,ts,tsx}"' : ""}],
568
619
  babelConfig: {
569
620
  babelrc: false,
570
621
  parserOpts: { plugins: ["typescript", "jsx"] },
@@ -697,10 +748,20 @@ function viteConfig(
697
748
  path: string,
698
749
  babelConfig: ReturnType<typeof migrateBabel>,
699
750
  postcssPlugins: string[],
751
+ shared?: SharedUI,
700
752
  ) {
701
753
  content = stripLegacyConfig(content, path)
702
754
 
703
755
  if (content.includes("stylex.vite(") || hasYopemStylexConfig(content)) {
756
+ if (
757
+ shared &&
758
+ (!content.includes(JSON.stringify(`${shared.name}/*`)) ||
759
+ !content.includes(JSON.stringify(shared.source)))
760
+ )
761
+ throw new Error(
762
+ `Existing StyleX configuration targets a different UI package in ${path}`,
763
+ )
764
+
704
765
  if (!content.includes('"@":') && !content.includes("'@':")) {
705
766
  throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
706
767
  }
@@ -736,7 +797,7 @@ function viteConfig(
736
797
  edits.push({
737
798
  start: 0,
738
799
  end: 0,
739
- text: stylexSetup(babelConfig.plugins, babelConfig.presets),
800
+ text: stylexSetup(babelConfig.plugins, babelConfig.presets, shared),
740
801
  })
741
802
 
742
803
  return applyEdits(content, edits)
@@ -747,6 +808,7 @@ function astroConfig(
747
808
  path: string,
748
809
  babelConfig: ReturnType<typeof migrateBabel>,
749
810
  postcssPlugins: string[],
811
+ shared?: SharedUI,
750
812
  ) {
751
813
  content = stripLegacyConfig(content, path)
752
814
  const source = parsed(path, content)
@@ -772,6 +834,15 @@ function astroConfig(
772
834
  hasYopemStylexConfig(content) && content.includes(`${reactName}()`)
773
835
 
774
836
  if (alreadyConfigured) {
837
+ if (
838
+ shared &&
839
+ (!content.includes(JSON.stringify(`${shared.name}/*`)) ||
840
+ !content.includes(JSON.stringify(shared.source)))
841
+ )
842
+ throw new Error(
843
+ `Existing StyleX configuration targets a different UI package in ${path}`,
844
+ )
845
+
775
846
  if (!content.includes('"@":') && !content.includes("'@':")) {
776
847
  throw new Error(`Existing StyleX configuration lacks @ alias in ${path}`)
777
848
  }
@@ -847,7 +918,7 @@ function astroConfig(
847
918
  edits.push({
848
919
  start: 0,
849
920
  end: 0,
850
- text: `${reactImport ? "" : 'import react from "@astrojs/react"\n'}${stylexSetup(babelConfig.plugins, babelConfig.presets)}`,
921
+ text: `${reactImport ? "" : 'import react from "@astrojs/react"\n'}${stylexSetup(babelConfig.plugins, babelConfig.presets, shared)}`,
851
922
  })
852
923
 
853
924
  return applyEdits(content, edits)
@@ -924,10 +995,10 @@ function tsconfig(content: string, path: string) {
924
995
  return applyEdits(content, edits)
925
996
  }
926
997
 
927
- function reactEntry(content: string, path: string) {
998
+ function reactEntry(content: string, path: string, prefix = "@") {
928
999
  if (content.includes("themeMarker") || content.includes("rootStyles.html")) {
929
1000
  if (
930
- !content.includes('"@/styles/styles.css"') ||
1001
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
931
1002
  !content.includes("rootStyles.html") ||
932
1003
  !content.includes('dataset.theme = "light"')
933
1004
  ) {
@@ -947,7 +1018,7 @@ function reactEntry(content: string, path: string) {
947
1018
  {
948
1019
  start: 0,
949
1020
  end: 0,
950
- text: 'import "@/styles/styles.css"\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n',
1021
+ text: `import "${prefix}/styles/styles.css"\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n`,
951
1022
  },
952
1023
  {
953
1024
  start: last.end,
@@ -961,10 +1032,11 @@ function jsxLayout(
961
1032
  content: string,
962
1033
  path: string,
963
1034
  framework: "next" | "tanstack-start" | "react-router",
1035
+ prefix = "@",
964
1036
  ) {
965
1037
  if (content.includes("stylexProps(") || content.includes("rootStyles.html")) {
966
1038
  if (
967
- !content.includes('"@/styles/styles.css"') ||
1039
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
968
1040
  !content.includes("rootStyles.html") ||
969
1041
  !content.includes("rootStyles.body")
970
1042
  ) {
@@ -1064,20 +1136,20 @@ function jsxLayout(
1064
1136
  })
1065
1137
  }
1066
1138
 
1067
- const css = 'import "@/styles/styles.css"\n'
1139
+ const css = `import "${prefix}/styles/styles.css"\n`
1068
1140
  edits.push({
1069
1141
  start: 0,
1070
1142
  end: 0,
1071
- text: `${css}import { stylexProps } from "@/lib/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n`,
1143
+ text: `${css}import { stylexProps } from "${prefix}/lib/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n`,
1072
1144
  })
1073
1145
 
1074
1146
  return applyEdits(content, edits)
1075
1147
  }
1076
1148
 
1077
- function astroLayout(content: string, path: string) {
1149
+ function astroLayout(content: string, path: string, prefix = "@") {
1078
1150
  if (content.includes("yopemHtml.className")) {
1079
1151
  if (
1080
- !content.includes('"@/styles/styles.css"') ||
1152
+ !content.includes(`"${prefix}/styles/styles.css"`) ||
1081
1153
  !content.includes("yopemBody.className")
1082
1154
  ) {
1083
1155
  throw new Error(`Incomplete Yopem layout in ${path}`)
@@ -1117,7 +1189,7 @@ function astroLayout(content: string, path: string) {
1117
1189
  {
1118
1190
  start: end,
1119
1191
  end,
1120
- text: `${content.includes('"@/styles/styles.css"') ? "" : '\nimport "@/styles/styles.css"'}\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "@/styles/tokens.stylex"\n\nconst yopemHtml = stylex.props(themeMarker, lightTheme, rootStyles.html)\nconst yopemBody = stylex.props(rootStyles.body)`,
1192
+ text: `${content.includes(`"${prefix}/styles/styles.css"`) ? "" : `\nimport "${prefix}/styles/styles.css"`}\nimport * as stylex from "@stylexjs/stylex"\nimport { lightTheme, rootStyles, themeMarker } from "${prefix}/styles/tokens.stylex"\n\nconst yopemHtml = stylex.props(themeMarker, lightTheme, rootStyles.html)\nconst yopemBody = stylex.props(rootStyles.body)`,
1121
1193
  },
1122
1194
  ]
1123
1195
 
@@ -1170,7 +1242,7 @@ function astroLayout(content: string, path: string) {
1170
1242
  return applyEdits(content, edits)
1171
1243
  }
1172
1244
 
1173
- function nextBabel(esm: boolean) {
1245
+ function nextBabel(esm: boolean, shared?: SharedUI) {
1174
1246
  const header = esm
1175
1247
  ? `import { createRequire } from "node:module"
1176
1248
  import { dirname } from "node:path"
@@ -1208,7 +1280,7 @@ function expandLocalSpreads({ types: t }) {
1208
1280
  }
1209
1281
 
1210
1282
  ${esm ? 'export const presets = ["next/babel"]\nexport const plugins = [' : 'module.exports = {\n presets: ["next/babel"],\n plugins: ['}expandLocalSpreads, ["@stylexjs/babel-plugin", {
1211
- aliases: { "@/*": [path.join(__dirname, "src/*")] },
1283
+ aliases: { "@/*": [path.join(__dirname, "src/*")]${shared ? `, ${JSON.stringify(`${shared.name}/*`)}: [path.join(__dirname, ${JSON.stringify(`${shared.source}*`)})]` : ""} },
1212
1284
  dev: process.env.NODE_ENV !== "production",
1213
1285
  runtimeInjection: false,
1214
1286
  treeshakeCompensation: true,
@@ -1217,7 +1289,40 @@ ${esm ? 'export const presets = ["next/babel"]\nexport const plugins = [' : 'mod
1217
1289
  `
1218
1290
  }
1219
1291
 
1220
- function nextPostcss() {
1292
+ function nextConfig(content: string, path: string, name: string) {
1293
+ const source = parsed(path, content)
1294
+ const config = moduleConfig(source, path)
1295
+ const packages = property(config, "transpilePackages")
1296
+ const edits: Edit[] = []
1297
+
1298
+ if (!packages) {
1299
+ addProperty(
1300
+ source,
1301
+ config,
1302
+ `transpilePackages: [${JSON.stringify(name)}]`,
1303
+ edits,
1304
+ )
1305
+ } else {
1306
+ if (!ts.isArrayLiteralExpression(packages.initializer))
1307
+ throw new Error(`Unsupported transpilePackages in ${path}`)
1308
+
1309
+ if (
1310
+ !packages.initializer.elements.some(
1311
+ (entry) => ts.isStringLiteral(entry) && entry.text === name,
1312
+ )
1313
+ )
1314
+ addArrayEntries(
1315
+ source,
1316
+ packages.initializer,
1317
+ [JSON.stringify(name)],
1318
+ edits,
1319
+ )
1320
+ }
1321
+
1322
+ return applyEdits(content, edits)
1323
+ }
1324
+
1325
+ function nextPostcss(shared?: SharedUI) {
1221
1326
  return `// Next loads this PostCSS config synchronously through CommonJS.
1222
1327
  // eslint-disable-next-line @typescript-eslint/no-require-imports
1223
1328
  const babelConfig = require("./babel.config.js")
@@ -1225,7 +1330,7 @@ const babelConfig = require("./babel.config.js")
1225
1330
  module.exports = {
1226
1331
  plugins: {
1227
1332
  "@stylexjs/postcss-plugin": {
1228
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
1333
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"${shared ? `, require("node:path").join(__dirname, ${JSON.stringify(`${shared.source}**/*.{js,jsx,ts,tsx}`)})` : ""}],
1229
1334
  babelConfig: {
1230
1335
  babelrc: false,
1231
1336
  parserOpts: { plugins: ["typescript", "jsx"] },
@@ -1239,7 +1344,12 @@ module.exports = {
1239
1344
  `
1240
1345
  }
1241
1346
 
1242
- function postcss(content: string, path: string, esm: boolean) {
1347
+ function postcss(
1348
+ content: string,
1349
+ path: string,
1350
+ esm: boolean,
1351
+ shared?: SharedUI,
1352
+ ) {
1243
1353
  if (content.includes('"@stylexjs/postcss-plugin"')) return content
1244
1354
  const source = parsed(path, content)
1245
1355
  let config: ts.ObjectLiteralExpression | undefined
@@ -1269,7 +1379,7 @@ function postcss(content: string, path: string, esm: boolean) {
1269
1379
  source,
1270
1380
  plugins,
1271
1381
  `"@stylexjs/postcss-plugin": {
1272
- include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"],
1382
+ include: ["src/**/*.{js,jsx,ts,tsx}", "app/**/*.{js,jsx,ts,tsx}", "pages/**/*.{js,jsx,ts,tsx}"${shared ? `, ${esm ? "yopemRequire" : "require"}("node:path").resolve(${esm ? 'yopemRequire("node:url").fileURLToPath(new URL(".", import.meta.url))' : "__dirname"}, ${JSON.stringify(`${shared.source}**/*.{js,jsx,ts,tsx}`)})` : ""}],
1273
1383
  babelConfig: { babelrc: false, parserOpts: { plugins: ["typescript", "jsx"] }, plugins: yopemBabelConfig.plugins },
1274
1384
  useCSSLayers: true,
1275
1385
  }`,
@@ -1279,7 +1389,7 @@ function postcss(content: string, path: string, esm: boolean) {
1279
1389
  start: 0,
1280
1390
  end: 0,
1281
1391
  text: esm
1282
- ? 'import { createRequire } from "node:module"\nconst yopemBabelConfig = createRequire(import.meta.url)("./babel.config.js")\n'
1392
+ ? 'import { createRequire } from "node:module"\nconst yopemRequire = createRequire(import.meta.url)\nconst yopemBabelConfig = yopemRequire("./babel.config.js")\n'
1283
1393
  : 'const yopemBabelConfig = require("./babel.config.js")\n',
1284
1394
  })
1285
1395
 
@@ -1295,7 +1405,7 @@ const lintRules = {
1295
1405
  "yopem-ui/valid-polymorphic-as": "error",
1296
1406
  }
1297
1407
 
1298
- function lintConfig(content: string) {
1408
+ function lintConfig(content: string, shared?: SharedUI) {
1299
1409
  const config: JsonValue = JSON.parse(content)
1300
1410
 
1301
1411
  if (!object(config)) throw new Error("Invalid .oxlintrc.json")
@@ -1325,6 +1435,45 @@ function lintConfig(content: string) {
1325
1435
  throw new Error("Conflicting yopem-ui plugin in .oxlintrc.json")
1326
1436
  }
1327
1437
 
1438
+ const configuredRules: JsonObject = { ...lintRules, ...rules }
1439
+
1440
+ if (shared) {
1441
+ for (const name of [
1442
+ "yopem-ui/enforce-styling-methods",
1443
+ "yopem-ui/no-restyle",
1444
+ "yopem-ui/valid-polymorphic-as",
1445
+ ]) {
1446
+ const setting = configuredRules[name] ?? "error"
1447
+ const level = Array.isArray(setting) ? (setting[0] ?? "error") : setting
1448
+
1449
+ if (level === "off" || level === 0) continue
1450
+
1451
+ const options = Array.isArray(setting) ? (setting[1] ?? {}) : {}
1452
+
1453
+ if (!object(options)) throw new Error(`Invalid options for ${name}`)
1454
+
1455
+ const sources = options.componentSources ?? [
1456
+ "@/components/ui/",
1457
+ "@registry/components/ui/",
1458
+ "@yopem-ui/ui",
1459
+ ]
1460
+
1461
+ if (!Array.isArray(sources) || !sources.every(isString)) {
1462
+ throw new Error(`Invalid componentSources for ${name}`)
1463
+ }
1464
+
1465
+ configuredRules[name] = [
1466
+ level,
1467
+ {
1468
+ ...options,
1469
+ componentSources: [
1470
+ ...new Set([...sources, `${shared.name}/components/ui/`]),
1471
+ ],
1472
+ },
1473
+ ]
1474
+ }
1475
+ }
1476
+
1328
1477
  const next = {
1329
1478
  ...config,
1330
1479
  jsPlugins: existing
@@ -1333,7 +1482,7 @@ function lintConfig(content: string) {
1333
1482
  ...plugins,
1334
1483
  { name: "yopem-ui", specifier: "@yopem-ui/oxlint-plugin" },
1335
1484
  ],
1336
- rules: { ...lintRules, ...rules },
1485
+ rules: configuredRules,
1337
1486
  overrides: overrides.some(
1338
1487
  (entry) => JSON.stringify(entry) === JSON.stringify(componentOverride),
1339
1488
  )
@@ -1419,48 +1568,9 @@ function detectFramework(
1419
1568
  return detected[0]!
1420
1569
  }
1421
1570
 
1422
- async function packageManager(
1423
- root: string,
1424
- declared: JsonValue | undefined,
1425
- ): Promise<PackageManager> {
1426
- const markers: [PackageManager, string][] = [
1427
- ["bun", "bun.lock"],
1428
- ["bun", "bun.lockb"],
1429
- ["npm", "package-lock.json"],
1430
- ["pnpm", "pnpm-lock.yaml"],
1431
- ["yarn", "yarn.lock"],
1432
- ]
1433
-
1434
- const found = new Set<PackageManager>()
1435
-
1436
- for (const [manager, path] of markers) {
1437
- if (await existingFile(root, path)) found.add(manager)
1438
- }
1439
-
1440
- if (found.size > 1) throw new Error("Conflicting package manager lockfiles")
1441
-
1442
- const named = isString(declared) ? declared.split("@")[0] : undefined
1443
-
1444
- if (
1445
- named &&
1446
- named !== "bun" &&
1447
- named !== "npm" &&
1448
- named !== "pnpm" &&
1449
- named !== "yarn"
1450
- ) {
1451
- throw new Error(`Unsupported package manager: ${named}`)
1452
- }
1453
-
1454
- const locked = [...found][0]
1455
-
1456
- if (locked && named && locked !== named) {
1457
- throw new Error("Package manager and lockfile disagree")
1458
- }
1459
-
1460
- return locked ?? named ?? "bun"
1461
- }
1462
-
1463
- export async function initProject(options: InitOptions = {}) {
1571
+ export async function initProject(
1572
+ options: InitOptions = {},
1573
+ ): Promise<InitResult> {
1464
1574
  const root = await realpath(options.cwd ?? process.cwd())
1465
1575
 
1466
1576
  if (!(await existingFile(root, "package.json"))) {
@@ -1484,13 +1594,46 @@ export async function initProject(options: InitOptions = {}) {
1484
1594
  )
1485
1595
 
1486
1596
  const framework = detectFramework(dependencies, options.framework)
1487
- const manager = await packageManager(root, manifest.packageManager)
1597
+ const uiRoot = options.ui ? await realpath(resolve(root, options.ui)) : root
1598
+ let shared: SharedUI | undefined
1599
+
1600
+ if (options.ui) {
1601
+ if (uiRoot === root)
1602
+ throw new Error("Shared UI package must differ from app")
1603
+
1604
+ const uiManifest: JsonValue = JSON.parse(
1605
+ await readFile(join(uiRoot, "package.json"), "utf8"),
1606
+ )
1607
+
1608
+ if (!object(uiManifest) || !isPackageName(uiManifest.name))
1609
+ throw new Error("Shared UI package requires a valid package name")
1610
+
1611
+ const owner = await workspaceRoot(root)
1612
+
1613
+ if ((await workspaceRoot(uiRoot)) !== owner)
1614
+ throw new Error("App and UI package must belong to the same workspace")
1615
+
1616
+ shared = {
1617
+ name: uiManifest.name,
1618
+ source: `${relative(root, join(uiRoot, "src")).replaceAll("\\", "/")}/`,
1619
+ }
1620
+ }
1621
+
1622
+ const packageRun = await packageRunner(root, options.run)
1623
+ const uiRun = await packageRunner(uiRoot, options.run)
1488
1624
  const edits = new Map<string, { before: string | null; after: string }>()
1489
1625
 
1626
+ const plannedFiles: {
1627
+ path: string
1628
+ action: "write" | "skip" | "delete"
1629
+ reason?: string
1630
+ }[] = []
1631
+
1490
1632
  async function plan(
1491
1633
  path: string,
1492
1634
  transform: (content: string) => string,
1493
1635
  fallback?: string,
1636
+ reason?: string,
1494
1637
  ) {
1495
1638
  const exists = await existingFile(root, path)
1496
1639
 
@@ -1498,6 +1641,12 @@ export async function initProject(options: InitOptions = {}) {
1498
1641
  const before = exists ? await readFile(join(root, path), "utf8") : null
1499
1642
  const after = transform(before ?? fallback!)
1500
1643
 
1644
+ plannedFiles.push({
1645
+ path: resolve(root, path),
1646
+ action: before === after ? "skip" : "write",
1647
+ ...(reason && { reason }),
1648
+ })
1649
+
1501
1650
  if (before !== after) edits.set(path, { before, after })
1502
1651
  }
1503
1652
 
@@ -1550,7 +1699,54 @@ export async function initProject(options: InitOptions = {}) {
1550
1699
  : "tsconfig.json"
1551
1700
 
1552
1701
  await plan(tsPath, (content) => tsconfig(content, tsPath))
1553
- await plan(".oxlintrc.json", lintConfig, "{}")
1702
+ await plan(".oxlintrc.json", (content) => lintConfig(content, shared), "{}")
1703
+
1704
+ if (shared) {
1705
+ const prefix = relative(root, uiRoot).replaceAll("\\", "/")
1706
+
1707
+ await plan(
1708
+ `${prefix}/package.json`,
1709
+ (content) => {
1710
+ const value: JsonValue = JSON.parse(content)
1711
+
1712
+ if (
1713
+ !object(value) ||
1714
+ (value.exports !== undefined && !object(value.exports))
1715
+ )
1716
+ throw new Error("Shared UI package requires object exports")
1717
+ const exports = object(value.exports) ? { ...value.exports } : {}
1718
+
1719
+ const paths = {
1720
+ "./components/ui/*": "./src/components/ui/*.tsx",
1721
+ "./lib/*": "./src/lib/*.ts",
1722
+ "./styles/tokens.stylex": "./src/styles/tokens.stylex.ts",
1723
+ "./styles/styles.css": "./src/styles/styles.css",
1724
+ "./theme/*": "./src/theme/*.tsx",
1725
+ }
1726
+
1727
+ for (const [path, target] of Object.entries(paths)) {
1728
+ if (exports[path] !== undefined && exports[path] !== target)
1729
+ throw new Error(`Conflicting UI package export: ${path}`)
1730
+ exports[path] = target
1731
+ }
1732
+
1733
+ const sideEffects =
1734
+ value.sideEffects === false
1735
+ ? ["**/*.css"]
1736
+ : Array.isArray(value.sideEffects) &&
1737
+ !value.sideEffects.includes("**/*.css")
1738
+ ? [...value.sideEffects, "**/*.css"]
1739
+ : value.sideEffects
1740
+
1741
+ return JSON.stringify(value.exports) === JSON.stringify(exports) &&
1742
+ JSON.stringify(value.sideEffects) === JSON.stringify(sideEffects)
1743
+ ? content
1744
+ : `${JSON.stringify({ ...value, exports, sideEffects }, null, 2)}\n`
1745
+ },
1746
+ undefined,
1747
+ "Shared UI exports: ./components/ui/*, ./lib/*, ./styles/tokens.stylex, ./styles/styles.css, ./theme/*; CSS side effects",
1748
+ )
1749
+ }
1554
1750
 
1555
1751
  const devDependencies = [
1556
1752
  "oxlint@^1.79.0",
@@ -1561,7 +1757,9 @@ export async function initProject(options: InitOptions = {}) {
1561
1757
  "@stylexjs/postcss-plugin@^0.19.0",
1562
1758
  ]
1563
1759
 
1564
- const runtimeDependencies: string[] = []
1760
+ const runtimeDependencies: string[] = shared
1761
+ ? [`${shared.name}@workspace:*`, "@stylexjs/stylex@^0.19.0"]
1762
+ : []
1565
1763
 
1566
1764
  if (framework === "astro") {
1567
1765
  const config = await chooseFile(root, [
@@ -1571,7 +1769,7 @@ export async function initProject(options: InitOptions = {}) {
1571
1769
  ])
1572
1770
 
1573
1771
  await plan(config, (content) =>
1574
- astroConfig(content, config, babelConfig, postcssPlugins),
1772
+ astroConfig(content, config, babelConfig, postcssPlugins, shared),
1575
1773
  )
1576
1774
 
1577
1775
  const layout = await chooseFile(root, [
@@ -1579,12 +1777,27 @@ export async function initProject(options: InitOptions = {}) {
1579
1777
  "src/layouts/layout.astro",
1580
1778
  ])
1581
1779
 
1582
- await plan(layout, (content) => astroLayout(content, layout))
1780
+ await plan(layout, (content) => astroLayout(content, layout, shared?.name))
1583
1781
 
1584
1782
  for (const name of ["@astrojs/react", "react", "react-dom"]) {
1585
1783
  if (!(name in dependencies)) runtimeDependencies.push(name)
1586
1784
  }
1587
1785
  } else if (framework === "next") {
1786
+ if (shared) {
1787
+ const config = await chooseFile(
1788
+ root,
1789
+ ["next.config.ts", "next.config.mjs", "next.config.js"],
1790
+ "next.config.mjs",
1791
+ )
1792
+
1793
+ const name = shared.name
1794
+ await plan(
1795
+ config,
1796
+ (content) => nextConfig(content, config, name),
1797
+ "export default {}\n",
1798
+ )
1799
+ }
1800
+
1588
1801
  if (!object(manifest.scripts))
1589
1802
  throw new Error("Next.js scripts are missing")
1590
1803
  nextScripts(manifest.scripts)
@@ -1594,7 +1807,9 @@ export async function initProject(options: InitOptions = {}) {
1594
1807
  "app/layout.tsx",
1595
1808
  ])
1596
1809
 
1597
- await plan(layout, (content) => jsxLayout(content, layout, "next"))
1810
+ await plan(layout, (content) =>
1811
+ jsxLayout(content, layout, "next", shared?.name),
1812
+ )
1598
1813
 
1599
1814
  const babel = await chooseFile(
1600
1815
  root,
@@ -1612,7 +1827,7 @@ export async function initProject(options: InitOptions = {}) {
1612
1827
  throw new Error(`Unsupported Babel configuration: ${babel}`)
1613
1828
  }
1614
1829
 
1615
- const babelContent = nextBabel(manifest.type === "module")
1830
+ const babelContent = nextBabel(manifest.type === "module", shared)
1616
1831
  await plan(
1617
1832
  babel,
1618
1833
  (content) => {
@@ -1634,15 +1849,16 @@ export async function initProject(options: InitOptions = {}) {
1634
1849
  await plan(
1635
1850
  postcssPath,
1636
1851
  (content) =>
1637
- postcssPath === "postcss.config.cjs" && content === nextPostcss()
1852
+ postcssPath === "postcss.config.cjs" && content === nextPostcss(shared)
1638
1853
  ? content
1639
1854
  : postcss(
1640
1855
  content,
1641
1856
  postcssPath,
1642
1857
  postcssPath.endsWith(".mjs") ||
1643
1858
  (postcssPath.endsWith(".js") && manifest.type === "module"),
1859
+ shared,
1644
1860
  ),
1645
- nextPostcss(),
1861
+ nextPostcss(shared),
1646
1862
  )
1647
1863
  devDependencies.splice(
1648
1864
  0,
@@ -1663,7 +1879,7 @@ export async function initProject(options: InitOptions = {}) {
1663
1879
  ])
1664
1880
 
1665
1881
  await plan(config, (content) =>
1666
- viteConfig(content, config, babelConfig, postcssPlugins),
1882
+ viteConfig(content, config, babelConfig, postcssPlugins, shared),
1667
1883
  )
1668
1884
 
1669
1885
  if (
@@ -1677,7 +1893,9 @@ export async function initProject(options: InitOptions = {}) {
1677
1893
  : ["app/root.tsx", "src/root.tsx"],
1678
1894
  )
1679
1895
 
1680
- await plan(layout, (content) => jsxLayout(content, layout, framework))
1896
+ await plan(layout, (content) =>
1897
+ jsxLayout(content, layout, framework, shared?.name),
1898
+ )
1681
1899
  } else {
1682
1900
  const entry = await chooseFile(root, [
1683
1901
  "src/main.tsx",
@@ -1686,110 +1904,282 @@ export async function initProject(options: InitOptions = {}) {
1686
1904
  "src/index.jsx",
1687
1905
  ])
1688
1906
 
1689
- await plan(entry, (content) => reactEntry(content, entry))
1907
+ await plan(entry, (content) => reactEntry(content, entry, shared?.name))
1690
1908
  }
1691
1909
  }
1692
1910
 
1693
- const packageRun =
1694
- options.run ??
1695
- (manager === "bun"
1696
- ? runBun
1697
- : async function runPackage(args: string[], cwd: string) {
1698
- const command =
1699
- manager === "npm" && args[0] === "add" ? "install" : args[0]!
1700
-
1701
- const flags = args
1702
- .slice(1)
1703
- .map((argument) => (argument === "-d" ? "-D" : argument))
1704
-
1705
- const child = Bun.spawn([manager, command, ...flags], {
1706
- cwd,
1707
- stdout: "inherit",
1708
- stderr: "inherit",
1709
- })
1911
+ const basePlan = await planInstall("base", {
1912
+ ...options,
1913
+ cwd: uiRoot,
1914
+ importPrefix: shared?.name,
1915
+ dryRun: true,
1916
+ run: uiRun,
1917
+ })
1710
1918
 
1711
- if ((await child.exited) !== 0)
1712
- throw new Error(`${manager} ${command} failed`)
1713
- })
1919
+ const conflict = basePlan.preview.files.find(
1920
+ (file) => file.action === "conflict",
1921
+ )
1714
1922
 
1715
- await installItem("base", { ...options, cwd: root, run: packageRun })
1923
+ if (conflict) throw new Error(conflict.reason)
1716
1924
 
1717
1925
  if (
1718
- !(await readFile(join(root, "src/styles/styles.css"), "utf8")).includes(
1719
- "@stylex;",
1720
- )
1926
+ !basePlan.changes.get("src/styles/styles.css")?.after?.includes("@stylex;")
1721
1927
  ) {
1722
1928
  throw new Error(
1723
1929
  "Add @stylex; to src/styles/styles.css before configuring StyleX",
1724
1930
  )
1725
1931
  }
1726
1932
 
1727
- const packages = await readFile(join(root, "package.json"), "utf8")
1728
- const installed: JsonValue = JSON.parse(packages)
1933
+ const runtimeRequirements =
1934
+ uiRoot === root
1935
+ ? [...basePlan.requirements.dependencies, ...runtimeDependencies]
1936
+ : runtimeDependencies
1729
1937
 
1730
- if (!object(installed))
1731
- throw new Error("Invalid package.json after installation")
1938
+ const devRequirements =
1939
+ uiRoot === root
1940
+ ? [...basePlan.requirements.devDependencies, ...devDependencies]
1941
+ : devDependencies
1732
1942
 
1733
- const available = Object.fromEntries(
1734
- [installed.dependencies, installed.devDependencies].flatMap((entry) =>
1735
- object(entry) ? Object.entries(entry) : [],
1736
- ),
1943
+ const appPending = await pendingDependencies(
1944
+ root,
1945
+ runtimeRequirements,
1946
+ devRequirements,
1737
1947
  )
1738
1948
 
1739
- const neededRuntime = runtimeDependencies.filter(
1740
- (name) => !(name in available),
1949
+ const baseDependencyNames = new Set(
1950
+ [
1951
+ ...basePlan.requirements.dependencies,
1952
+ ...basePlan.requirements.devDependencies,
1953
+ ].map((argument) => {
1954
+ const separator = argument.indexOf("@", 1)
1955
+
1956
+ return separator < 0 ? argument : argument.slice(0, separator)
1957
+ }),
1741
1958
  )
1742
1959
 
1743
- if (neededRuntime.length) await packageRun(["add", ...neededRuntime], root)
1960
+ function isBaseDependency(argument: string) {
1961
+ const separator = argument.indexOf("@", 1)
1744
1962
 
1745
- const neededDev = devDependencies.filter(
1746
- (name) => !(name.split("@").slice(0, -1).join("@") in available),
1747
- )
1963
+ return baseDependencyNames.has(
1964
+ separator < 0 ? argument : argument.slice(0, separator),
1965
+ )
1966
+ }
1748
1967
 
1749
- if (neededDev.length) await packageRun(["add", "-d", ...neededDev], root)
1968
+ const baseApplicationPlan =
1969
+ uiRoot === root
1970
+ ? {
1971
+ ...basePlan,
1972
+ preview: {
1973
+ ...basePlan.preview,
1974
+ dependencies: appPending.dependencies.filter(isBaseDependency),
1975
+ devDependencies:
1976
+ appPending.devDependencies.filter(isBaseDependency),
1977
+ },
1978
+ }
1979
+ : basePlan
1750
1980
 
1751
- for (const [path, { before }] of edits) {
1752
- const exists = await existingFile(root, path)
1753
- const current = exists ? await readFile(join(root, path), "utf8") : null
1981
+ if (options.dryRun) {
1982
+ const baseDependencies = {
1983
+ dependencies: basePlan.preview.dependencies,
1984
+ devDependencies: basePlan.preview.devDependencies,
1985
+ }
1754
1986
 
1755
- if (current !== before) throw new Error(`File changed during init: ${path}`)
1756
- }
1987
+ for (const [path] of retired)
1988
+ plannedFiles.push({ path: resolve(root, path), action: "delete" })
1989
+
1990
+ if (framework === "next") {
1991
+ if (!object(manifest.scripts))
1992
+ throw new Error("Next.js scripts are missing")
1993
+ const originalScripts = manifest.scripts
1994
+ const scripts = nextScripts(originalScripts)
1995
+ plannedFiles.push({
1996
+ path: join(root, "package.json"),
1997
+ action:
1998
+ JSON.stringify(scripts) === JSON.stringify(manifest.scripts)
1999
+ ? "skip"
2000
+ : "write",
2001
+ reason: `Next.js scripts: ${
2002
+ Object.entries(scripts)
2003
+ .filter(([name, value]) => value !== originalScripts[name])
2004
+ .map(
2005
+ ([name, value]) =>
2006
+ `${name}: ${JSON.stringify(originalScripts[name])} -> ${JSON.stringify(value)}`,
2007
+ )
2008
+ .join("; ") || "unchanged"
2009
+ }`,
2010
+ })
2011
+ }
1757
2012
 
1758
- for (const [path, before] of retired) {
1759
- if ((await readFile(join(root, path), "utf8")) !== before)
1760
- throw new Error(`File changed during init: ${path}`)
1761
- }
2013
+ const dependencyTargets =
2014
+ uiRoot === root
2015
+ ? [{ cwd: root, ...appPending }]
2016
+ : [
2017
+ { cwd: uiRoot, ...baseDependencies },
2018
+ { cwd: root, ...appPending },
2019
+ ]
2020
+
2021
+ for (const group of dependencyTargets) {
2022
+ if (!group.dependencies.length && !group.devDependencies.length) continue
2023
+ const path = join(group.cwd, "package.json")
2024
+ const planned = plannedFiles.find((file) => file.path === path)
2025
+
2026
+ if (planned) {
2027
+ planned.action = "write"
2028
+ planned.reason = [
2029
+ planned.reason,
2030
+ "Pending package-manager dependency changes",
2031
+ ]
2032
+ .filter(Boolean)
2033
+ .join("; ")
2034
+ } else {
2035
+ plannedFiles.push({
2036
+ path,
2037
+ action: "write",
2038
+ reason: "Pending package-manager dependency changes",
2039
+ })
2040
+ }
2041
+ }
1762
2042
 
1763
- for (const [path, { before, after }] of edits) {
1764
- await mkdir(dirname(join(root, path)), { recursive: true })
1765
- await writeFile(join(root, path), after, {
1766
- flag: before === null ? "wx" : "w",
1767
- })
2043
+ for (const warning of basePlan.warnings) options.onWarning?.(warning)
2044
+
2045
+ return {
2046
+ framework,
2047
+ configured: 0,
2048
+ ...(basePlan.warnings.length && { warnings: basePlan.warnings }),
2049
+ preview: {
2050
+ files: [
2051
+ ...basePlan.preview.files.map((file) => ({
2052
+ ...file,
2053
+ path: join(uiRoot, file.path),
2054
+ })),
2055
+ ...plannedFiles,
2056
+ ],
2057
+ dependencies: dependencyTargets,
2058
+ prerequisites: [
2059
+ "Supported framework, safe project paths and configuration",
2060
+ "Validated registry base with @stylex; stylesheet directive",
2061
+ ],
2062
+ },
2063
+ }
1768
2064
  }
1769
2065
 
1770
- for (const path of retired.keys()) await unlink(join(root, path))
2066
+ let baseInstalled = false
1771
2067
 
1772
- if (framework === "next") {
1773
- const latest: JsonValue = JSON.parse(
1774
- await readFile(join(root, "package.json"), "utf8"),
1775
- )
2068
+ try {
2069
+ const baseResult = await applyInstall(baseApplicationPlan, options)
2070
+ baseInstalled = true
2071
+
2072
+ if (
2073
+ !(await readFile(join(uiRoot, "src/styles/styles.css"), "utf8")).includes(
2074
+ "@stylex;",
2075
+ )
2076
+ ) {
2077
+ throw new Error(
2078
+ "Add @stylex; to src/styles/styles.css before configuring StyleX",
2079
+ )
2080
+ }
2081
+
2082
+ if (!(await existingFile(root, "package.json")))
2083
+ throw new Error("Missing package.json after installation")
2084
+ const packages = await readFile(join(root, "package.json"), "utf8")
2085
+ const installed: JsonValue = JSON.parse(packages)
1776
2086
 
1777
- if (!object(latest))
2087
+ if (!object(installed))
1778
2088
  throw new Error("Invalid package.json after installation")
1779
2089
 
1780
- if (JSON.stringify(latest.scripts) !== JSON.stringify(manifest.scripts)) {
1781
- throw new Error("Next.js scripts changed during init")
2090
+ const pending = await pendingDependencies(
2091
+ root,
2092
+ runtimeRequirements,
2093
+ devRequirements,
2094
+ )
2095
+
2096
+ if (uiRoot === root) {
2097
+ pending.dependencies = pending.dependencies.filter(
2098
+ (argument) => !isBaseDependency(argument),
2099
+ )
2100
+ pending.devDependencies = pending.devDependencies.filter(
2101
+ (argument) => !isBaseDependency(argument),
2102
+ )
1782
2103
  }
1783
2104
 
1784
- const scripts = nextScripts(latest.scripts)
2105
+ if (pending.dependencies.length)
2106
+ await packageRun(["add", ...pending.dependencies], root)
2107
+
2108
+ if (pending.devDependencies.length)
2109
+ await packageRun(["add", "-d", ...pending.devDependencies], root)
2110
+
2111
+ if (shared) {
2112
+ const path = `${relative(root, uiRoot).replaceAll("\\", "/")}/package.json`
2113
+ const planned = edits.get(path)
2114
+
2115
+ if (planned) {
2116
+ if (!(await existingFile(root, path)))
2117
+ throw new Error("Missing UI package.json after installation")
2118
+ const current = await readFile(join(uiRoot, "package.json"), "utf8")
2119
+ const before: JsonValue = JSON.parse(planned.before!)
2120
+ const after: JsonValue = JSON.parse(planned.after)
2121
+ const value: JsonValue = JSON.parse(current)
2122
+
2123
+ if (
2124
+ !object(before) ||
2125
+ !object(after) ||
2126
+ !object(value) ||
2127
+ value.name !== before.name ||
2128
+ JSON.stringify(value.exports) !== JSON.stringify(before.exports) ||
2129
+ JSON.stringify(value.sideEffects) !==
2130
+ JSON.stringify(before.sideEffects)
2131
+ )
2132
+ throw new Error("UI package exports changed during installation")
1785
2133
 
1786
- if (JSON.stringify(latest.scripts) !== JSON.stringify(scripts)) {
1787
- await writeFile(
1788
- join(root, "package.json"),
1789
- `${JSON.stringify({ ...latest, scripts }, null, 2)}\n`,
1790
- )
2134
+ edits.set(path, {
2135
+ before: current,
2136
+ after: `${JSON.stringify({ ...value, exports: after.exports, sideEffects: after.sideEffects }, null, 2)}\n`,
2137
+ })
2138
+ }
1791
2139
  }
1792
- }
1793
2140
 
1794
- return { framework, configured: edits.size }
2141
+ const changes = new Map<string, FileChange>(edits)
2142
+
2143
+ for (const [path, before] of retired) {
2144
+ changes.set(path, { before, after: null })
2145
+ }
2146
+
2147
+ if (framework === "next") {
2148
+ if (!(await existingFile(root, "package.json")))
2149
+ throw new Error("Missing package.json after installation")
2150
+ const before = await readFile(join(root, "package.json"), "utf8")
2151
+ const latest: JsonValue = JSON.parse(before)
2152
+
2153
+ if (!object(latest))
2154
+ throw new Error("Invalid package.json after installation")
2155
+
2156
+ if (JSON.stringify(latest.scripts) !== JSON.stringify(manifest.scripts)) {
2157
+ throw new Error("Next.js scripts changed during init")
2158
+ }
2159
+
2160
+ const scripts = nextScripts(latest.scripts)
2161
+
2162
+ changes.set("package.json", {
2163
+ before,
2164
+ after:
2165
+ JSON.stringify(latest.scripts) === JSON.stringify(scripts)
2166
+ ? before
2167
+ : `${JSON.stringify({ ...latest, scripts }, null, 2)}\n`,
2168
+ })
2169
+ }
2170
+
2171
+ await writeFiles(root, changes)
2172
+
2173
+ return {
2174
+ framework,
2175
+ configured: edits.size,
2176
+ ...(baseResult.warnings && { warnings: baseResult.warnings }),
2177
+ }
2178
+ } catch (cause) {
2179
+ if (!baseInstalled) throw cause
2180
+ throw new Error(
2181
+ `${cause instanceof Error ? cause.message : String(cause)}\nDependencies may have changed (package.json, lockfiles, node_modules); package-manager changes were not rolled back.\nPrior init base install may have changed source files and ui.json; that install was not rolled back.`,
2182
+ { cause },
2183
+ )
2184
+ }
1795
2185
  }