@multiplatform.one/cli 7.10.0 → 7.15.0

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.
Files changed (163) hide show
  1. package/README.md +121 -111
  2. package/bin/createMultiplatformApp.mjs +39 -37
  3. package/lib/bin/multiplatformOne.mjs +65 -75
  4. package/lib/commands/addApp.mjs +78 -0
  5. package/lib/commands/adoptApp.mjs +11 -7
  6. package/lib/commands/doctypeTypes.mjs +148 -0
  7. package/lib/commands/e2e.mjs +2 -2
  8. package/lib/commands/init.mjs +142 -459
  9. package/lib/commands/merge.mjs +359 -0
  10. package/lib/commands/pieces.mjs +236 -0
  11. package/lib/commands/provenance.mjs +108 -0
  12. package/lib/commands/scaffold.mjs +105 -0
  13. package/lib/commands/updateApp.mjs +211 -528
  14. package/lib/source.json +5 -0
  15. package/lib/source.mjs +63 -0
  16. package/package.json +7 -7
  17. package/scripts/frappe-bootstrap.sh +29 -3
  18. package/scripts/frappe-dev.sh +1 -2
  19. package/scripts/frappe-helpers.sh +50 -2
  20. package/scripts/frappe.sh +5 -0
  21. package/scripts/scaffold.sh +937 -0
  22. package/scripts/stamp-source.mjs +51 -0
  23. package/scripts/stamp-source.spec.ts +86 -0
  24. package/src/bin/multiplatformOne.ts +116 -226
  25. package/src/commands/addApp.ts +102 -0
  26. package/src/commands/adoptApp.spec.ts +5 -1
  27. package/src/commands/adoptApp.ts +15 -10
  28. package/src/commands/doctypeTypes.spec.ts +155 -0
  29. package/src/commands/doctypeTypes.ts +214 -0
  30. package/src/commands/init.spec.ts +66 -0
  31. package/src/commands/init.ts +201 -643
  32. package/src/commands/merge.spec.ts +81 -0
  33. package/src/commands/merge.ts +411 -0
  34. package/src/commands/pieces.spec.ts +62 -0
  35. package/src/commands/pieces.ts +340 -0
  36. package/src/commands/provenance.spec.ts +92 -0
  37. package/src/commands/provenance.ts +169 -0
  38. package/src/commands/scaffold.ts +131 -0
  39. package/src/commands/updateApp.spec.ts +540 -331
  40. package/src/commands/updateApp.ts +255 -668
  41. package/src/source.spec.ts +45 -0
  42. package/src/source.ts +88 -0
  43. package/templates/AGENTS.md +161 -0
  44. package/templates/README.md +62 -0
  45. package/templates/pieces/frappe/universal/README.md.partial +7 -32
  46. package/templates/pieces/frappe/universal/env.example.partial +3 -3
  47. package/templates/pieces/frappe-bench/universal/README.md.partial +28 -0
  48. package/templates/pieces/{frappe → frappe-bench}/universal/docker/compose.frappe.yaml +2 -2
  49. package/templates/pieces/tauri/universal/README.md.partial +7 -5
  50. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/dev.rs +463 -0
  51. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/src/lib.rs +6 -0
  52. package/templates/pieces/tauri/universal/apps/__NAME__/src-tauri/tauri.conf.json +2 -1
  53. package/templates/pieces/webext/universal/apps/__NAME__/package.json.partial +1 -0
  54. package/templates/pieces/webext/universal/apps/__NAME__/vite.config.webext.ts +6 -2
  55. package/types/bin/multiplatformOne.d.ts.map +1 -1
  56. package/types/commands/addApp.d.ts +20 -0
  57. package/types/commands/addApp.d.ts.map +1 -0
  58. package/types/commands/adoptApp.d.ts +4 -3
  59. package/types/commands/adoptApp.d.ts.map +1 -1
  60. package/types/commands/doctypeTypes.d.ts +40 -0
  61. package/types/commands/doctypeTypes.d.ts.map +1 -0
  62. package/types/commands/init.d.ts +30 -13
  63. package/types/commands/init.d.ts.map +1 -1
  64. package/types/commands/merge.d.ts +54 -0
  65. package/types/commands/merge.d.ts.map +1 -0
  66. package/types/commands/pieces.d.ts +54 -0
  67. package/types/commands/pieces.d.ts.map +1 -0
  68. package/types/commands/provenance.d.ts +52 -0
  69. package/types/commands/provenance.d.ts.map +1 -0
  70. package/types/commands/scaffold.d.ts +43 -0
  71. package/types/commands/scaffold.d.ts.map +1 -0
  72. package/types/commands/updateApp.d.ts +19 -27
  73. package/types/commands/updateApp.d.ts.map +1 -1
  74. package/types/source.d.ts +35 -0
  75. package/types/source.d.ts.map +1 -0
  76. package/lib/commands/initApp.mjs +0 -431
  77. package/lib/generateVscode.mjs +0 -184
  78. package/scripts/clone.sh +0 -17
  79. package/scripts/update.sh +0 -46
  80. package/src/commands/initApp.spec.ts +0 -720
  81. package/src/commands/initApp.ts +0 -706
  82. package/src/generateVscode.ts +0 -238
  83. package/templates/app/AGENTS.md +0 -142
  84. package/templates/app/README.md +0 -62
  85. package/templates/app/apps/__NAME__/index.html +0 -36
  86. package/templates/app/apps/__NAME__/package.json +0 -31
  87. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  88. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  89. package/templates/app/apps/__NAME__/src/App.tsx +0 -9
  90. package/templates/app/apps/__NAME__/src/main.tsx +0 -14
  91. package/templates/app/apps/__NAME__/tsconfig.json +0 -8
  92. package/templates/app/apps/__NAME__/vite.config.ts +0 -17
  93. package/templates/app/features/__NAME__/package.json +0 -34
  94. package/templates/app/features/__NAME__/src/HomeScreen.tsx +0 -70
  95. package/templates/app/features/__NAME__/src/index.ts +0 -1
  96. package/templates/app/features/__NAME__/tsconfig.json +0 -5
  97. package/templates/app/gitignore +0 -9
  98. package/templates/app/npmrc +0 -2
  99. package/templates/app/oxlintrc.json +0 -39
  100. package/templates/app/package.json +0 -38
  101. package/templates/app/packages/config/config.json +0 -4
  102. package/templates/app/packages/config/index.ts +0 -5
  103. package/templates/app/packages/config/package.json +0 -17
  104. package/templates/app/packages/config/tsconfig.json +0 -5
  105. package/templates/app/pnpm-workspace.yaml +0 -4
  106. package/templates/app/tsconfig.base.json +0 -16
  107. package/templates/universal/AGENTS.md +0 -142
  108. package/templates/universal/README.md +0 -97
  109. package/templates/universal/apps/__NAME__/app.config.js +0 -31
  110. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +0 -4
  111. package/templates/universal/apps/__NAME__/package.json +0 -62
  112. package/templates/universal/apps/__NAME__/polyfills.native.ts +0 -23
  113. package/templates/universal/apps/__NAME__/polyfills.ts +0 -3
  114. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  115. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  116. package/templates/universal/apps/__NAME__/routes/_layout.tsx +0 -22
  117. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +0 -21
  118. package/templates/universal/apps/__NAME__/routes/about.tsx +0 -3
  119. package/templates/universal/apps/__NAME__/routes/index.tsx +0 -3
  120. package/templates/universal/apps/__NAME__/src/app.ts +0 -10
  121. package/templates/universal/apps/__NAME__/tamagui.css +0 -3
  122. package/templates/universal/apps/__NAME__/tsconfig.json +0 -8
  123. package/templates/universal/apps/__NAME__/types.d.ts +0 -9
  124. package/templates/universal/apps/__NAME__/vite.config.ts +0 -20
  125. package/templates/universal/features/__NAME__/package.json +0 -35
  126. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +0 -27
  127. package/templates/universal/features/__NAME__/src/AppLayout.tsx +0 -50
  128. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +0 -22
  129. package/templates/universal/features/__NAME__/src/ContactForm.tsx +0 -55
  130. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +0 -40
  131. package/templates/universal/features/__NAME__/src/index.ts +0 -4
  132. package/templates/universal/features/__NAME__/src/screens.spec.ts +0 -12
  133. package/templates/universal/features/__NAME__/tsconfig.json +0 -5
  134. package/templates/universal/features/__NAME__/vitest.config.mjs +0 -7
  135. package/templates/universal/gitignore +0 -14
  136. package/templates/universal/npmrc +0 -3
  137. package/templates/universal/oxlintrc.json +0 -39
  138. package/templates/universal/package.json +0 -51
  139. package/templates/universal/packages/config/config.json +0 -4
  140. package/templates/universal/packages/config/index.ts +0 -5
  141. package/templates/universal/packages/config/package.json +0 -17
  142. package/templates/universal/packages/config/tsconfig.json +0 -5
  143. package/templates/universal/packages/i18n/en/common.json +0 -30
  144. package/templates/universal/packages/i18n/es/common.json +0 -30
  145. package/templates/universal/packages/i18n/i18n.d.ts +0 -16
  146. package/templates/universal/packages/i18n/index.ts +0 -26
  147. package/templates/universal/packages/i18n/package.json +0 -20
  148. package/templates/universal/packages/i18n/resources.ts +0 -13
  149. package/templates/universal/packages/i18n/tsconfig.json +0 -5
  150. package/templates/universal/packages/themes/accent.ts +0 -32
  151. package/templates/universal/packages/themes/base.ts +0 -50
  152. package/templates/universal/packages/themes/fonts.ts +0 -8
  153. package/templates/universal/packages/themes/index.ts +0 -33
  154. package/templates/universal/packages/themes/package.json +0 -23
  155. package/templates/universal/packages/themes/tamagui.config.ts +0 -14
  156. package/templates/universal/packages/themes/tsconfig.json +0 -5
  157. package/templates/universal/pnpm-workspace.yaml +0 -4
  158. package/templates/universal/tsconfig.base.json +0 -21
  159. package/templates/universal/types/env.d.ts +0 -9
  160. package/types/commands/initApp.d.ts +0 -114
  161. package/types/commands/initApp.d.ts.map +0 -1
  162. package/types/generateVscode.d.ts +0 -7
  163. package/types/generateVscode.d.ts.map +0 -1
@@ -1,671 +1,229 @@
1
- import { execSync } from "node:child_process";
2
- import { type Dirent, existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
3
- import { join, resolve } from "node:path";
4
- import inquirer from "inquirer";
5
-
6
- const repoUrl = "https://gitlab.com/bitspur/frappe/multiplatform.one.git";
7
-
8
- const services = ["frappe", "solana", "ethereum", "sui"] as const;
9
-
10
- /** Resolve a service name to its relative directory from the project root. */
11
- function serviceDir(name: string): string {
12
- return `apps/${name}`;
13
- }
14
- // vscode + webext are no longer standalone apps: they are delivery targets
15
- // of the One platform app (apps/one/vscode, apps/one/webext) and ride with
16
- // the "one" selection.
17
- const apps = ["one", "keycloak", "storybook", "storybook-expo", "vocs"] as const;
18
-
19
- type Service = (typeof services)[number];
20
- type App = (typeof apps)[number];
21
-
22
- /** env variable prefixes to remove per service */
23
- const serviceEnvPrefixes: Record<Service, string[]> = {
24
- frappe: ["FRAPPE_", "MARIADB_"],
25
- solana: [],
26
- ethereum: [],
27
- sui: [],
28
- };
29
-
30
- /** env variable prefixes to remove per app */
31
- const appEnvPrefixes: Record<App, string[]> = {
32
- one: ["ONE_", "VSCODE_", "WEBEXT_"],
33
- keycloak: ["KEYCLOAK_"],
34
- storybook: ["STORYBOOK_", "VR_DIFFING_ENGINE="],
35
- "storybook-expo": [],
36
- vocs: ["VOCS_"],
37
- };
38
-
39
- /** env section headers (comments) to remove per app/service */
40
- const serviceEnvSections: Record<Service, string[]> = {
41
- frappe: ["# frappe", "# mariadb"],
42
- solana: [],
43
- ethereum: [],
44
- sui: [],
45
- };
46
-
47
- const appEnvSections: Record<App, string[]> = {
48
- one: ["# one", "# vscode", "# webext"],
49
- keycloak: ["# keycloak"],
50
- storybook: ["# storybook"],
51
- "storybook-expo": [],
52
- vocs: ["# vocs"],
53
- };
54
-
55
- /** launch.json configuration names per service/app */
56
- const serviceLaunchNames: Record<Service, string[]> = {
57
- frappe: ["frappe dev"],
58
- solana: ["solana dev", "solana localnet"],
59
- ethereum: ["ethereum dev"],
60
- sui: ["sui dev"],
61
- };
1
+ /**
2
+ * `mpo init`: a consumer project is the multiplatform.one monorepo at one
3
+ * commit, pruned. scripts/scaffold.sh does the work (clone or archive at
4
+ * the ref, drop public/ and the singleton apps not asked for, rename
5
+ * apps/one to each product app, rewrite workspace: ranges to semver); this
6
+ * command picks the commit, asks what to keep, and finishes the repo.
7
+ */
62
8
 
63
- const appLaunchNames: Record<App, string[]> = {
64
- one: ["one dev", "vscode dev", "webext dev"],
65
- keycloak: ["keycloak dev", "keycloak storybook"],
66
- storybook: ["storybook dev"],
67
- "storybook-expo": ["storybook-expo dev"],
68
- vocs: ["vocs dev"],
9
+ import { existsSync, readdirSync } from "node:fs";
10
+ import { basename, resolve } from "node:path";
11
+ import inquirer from "inquirer";
12
+ import spawn from "nano-spawn";
13
+ import { resolveSource, type SourceSpec } from "../source";
14
+ import { mpoRange, validateName } from "./provenance";
15
+ import { runScaffold, ScaffoldError } from "./scaffold";
16
+
17
+ /** The apps that exist once per project; everything else under apps/ is a product app. */
18
+ export const SINGLETON_APPS = [
19
+ "ethereum",
20
+ "frappe",
21
+ "keycloak",
22
+ "solana",
23
+ "storybook",
24
+ "storybook-expo",
25
+ "sui",
26
+ "uxpin",
27
+ "vocs",
28
+ ] as const;
29
+ export type SingletonApp = (typeof SINGLETON_APPS)[number];
30
+
31
+ const SINGLETON_DESCRIPTIONS: Record<SingletonApp, string> = {
32
+ ethereum: "Hardhat chain + contracts",
33
+ frappe: "Frappe bench (backend, docker compose)",
34
+ keycloak: "Keycloak theme + realm (auth, docker compose)",
35
+ solana: "Anchor programs + localnet",
36
+ storybook: "Storybook (web)",
37
+ "storybook-expo": "Storybook (native)",
38
+ sui: "Move packages + localnet",
39
+ uxpin: "UXPin Merge library",
40
+ vocs: "Docs site",
69
41
  };
70
42
 
71
- interface InitOptions {
72
- name?: string;
73
- services?: string;
74
- apps?: string;
43
+ export interface InitOptions {
44
+ /** Product apps besides the project name (each is a renamed apps/one). */
45
+ apps?: string[];
46
+ /** Singleton apps to keep. */
47
+ services?: string[];
48
+ /** Path or url to scaffold from instead of the CLI's recorded source. */
49
+ source?: string;
50
+ /** Commit, tag or branch at that source. */
75
51
  checkout?: string;
76
- /** Path to clone.sh script (from CLI); if set, clone via script instead of execSync */
77
- cloneScript?: string;
52
+ /** Range for @multiplatform.one/* (default: ^<cli version at the source>). */
53
+ mpoVersion?: string;
54
+ skipInstall?: boolean;
55
+ skipGit?: boolean;
56
+ /** Non-interactive: no prompts, no services unless passed. */
57
+ yes?: boolean;
58
+ /** Scaffold into an existing (non-empty) directory. */
59
+ into?: boolean;
60
+ /** Show the scaffold's per-path progress. */
61
+ verbose?: boolean;
78
62
  }
79
63
 
80
- /**
81
- * Shared modify step: remove unselected apps/services and rewrite configs.
82
- * Used by both init (with prompted selections) and update (with getPresentApps/getPresentServices).
83
- */
84
- export function runModifyStep(
85
- dir: string,
86
- selectedServices: string[],
87
- selectedApps: string[],
88
- ): void {
89
- const services = selectedServices as Service[];
90
- const apps = selectedApps as App[];
64
+ function unique(values: string[]): string[] {
65
+ return [...new Set(values)];
66
+ }
91
67
 
68
+ export function validateServices(services: string[]): SingletonApp[] {
69
+ const known = new Set<string>(SINGLETON_APPS);
92
70
  for (const service of services) {
93
- if (!services.includes(service)) {
94
- const servicePath = join(dir, serviceDir(service));
95
- if (existsSync(servicePath)) {
96
- rmSync(servicePath, { recursive: true });
97
- console.log(`Removed service: ${service}`);
98
- }
99
- }
100
- }
101
-
102
- for (const app of apps) {
103
- if (!apps.includes(app)) {
104
- const appPath = join(dir, "apps", app);
105
- if (existsSync(appPath)) {
106
- rmSync(appPath, { recursive: true });
107
- console.log(`Removed app: ${app}`);
108
- }
109
- }
110
- }
111
-
112
- const packagesDir = join(dir, "packages");
113
- if (existsSync(packagesDir)) {
114
- const versionMap = new Map<string, string>();
115
- for (const pkg of readdirSync(packagesDir)) {
116
- const pkgJsonPath = join(packagesDir, pkg, "package.json");
117
- if (existsSync(pkgJsonPath)) {
118
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
119
- if (pkgJson.name && pkgJson.version) {
120
- versionMap.set(pkgJson.name, pkgJson.version);
121
- }
122
- }
123
- }
124
- for (const pkg of readdirSync(packagesDir)) {
125
- const pkgJsonPath = join(packagesDir, pkg, "package.json");
126
- if (existsSync(pkgJsonPath)) {
127
- const pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
128
- if (!pkgJson.private) {
129
- rmSync(join(packagesDir, pkg), { recursive: true });
130
- console.log(`Removed public package: ${pkgJson.name}`);
131
- }
71
+ if (!known.has(service)) {
72
+ throw new Error(
73
+ `Unknown service "${service}". Services are the singleton apps: ${SINGLETON_APPS.join(", ")}`,
74
+ );
75
+ }
76
+ }
77
+ return unique(services).sort() as SingletonApp[];
78
+ }
79
+
80
+ async function promptServices(): Promise<SingletonApp[]> {
81
+ const result = await inquirer.prompt([
82
+ {
83
+ message: "Which singleton apps should the project keep? (space to toggle, enter to confirm)",
84
+ name: "services",
85
+ type: "checkbox",
86
+ choices: SINGLETON_APPS.map((service) => ({
87
+ name: `${service} — ${SINGLETON_DESCRIPTIONS[service]}`,
88
+ value: service,
89
+ checked: false,
90
+ })),
91
+ },
92
+ ]);
93
+ return validateServices(result.services as string[]);
94
+ }
95
+
96
+ async function insideGitRepo(dir: string): Promise<boolean> {
97
+ return spawn("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir }).then(
98
+ () => true,
99
+ () => false,
100
+ );
101
+ }
102
+
103
+ async function scaffoldWithFallback(
104
+ spec: SourceSpec,
105
+ request: Parameters<typeof runScaffold>[0],
106
+ ): Promise<Awaited<ReturnType<typeof runScaffold>>> {
107
+ try {
108
+ return await runScaffold({ ...request, source: spec.source, ref: spec.ref });
109
+ } catch (err) {
110
+ if (spec.origin !== "tag" || !(err instanceof ScaffoldError)) throw err;
111
+ console.warn(`⚠️ ${err.message}; retrying from main`);
112
+ return runScaffold({ ...request, source: spec.source, ref: "main" });
113
+ }
114
+ }
115
+
116
+ export async function init(nameArg: string | undefined, options: InitOptions = {}): Promise<void> {
117
+ const interactive = !options.yes && process.stdout.isTTY;
118
+
119
+ // --into: the argument is the directory; the product app is named after it.
120
+ let targetDir: string;
121
+ let name: string;
122
+ if (options.into) {
123
+ targetDir = resolve(nameArg ?? ".");
124
+ name = validateName(options.apps?.[0] ?? basename(targetDir));
125
+ } else {
126
+ let projectName = nameArg;
127
+ if (!projectName) {
128
+ if (!interactive) {
129
+ projectName = "my-app";
130
+ } else {
131
+ const result = await inquirer.prompt([
132
+ { message: "What is the app name?", name: "name", type: "input", default: "my-app" },
133
+ ]);
134
+ projectName = result.name as string;
132
135
  }
133
136
  }
134
- convertWorkspaceVersions(dir, versionMap);
135
- }
136
-
137
- cleanupPnpmWorkspace(dir, services, apps);
138
- cleanupLaunchJson(dir, services, apps);
139
- cleanupEnvFiles(dir, services, apps);
140
- cleanupTsconfigs(dir);
141
- cleanupMisc(dir);
142
- removeLicenseHeaders(dir);
143
- }
144
-
145
- export async function init(nameArg: string | undefined, options: InitOptions) {
146
- let projectName = nameArg;
147
- if (!projectName) {
148
- const result = await inquirer.prompt([
149
- {
150
- message: "What is the project name?",
151
- name: "name",
152
- type: "input",
153
- default: "my-app",
154
- },
155
- ]);
156
- projectName = result.name as string;
137
+ name = validateName(projectName);
138
+ targetDir = resolve(name);
157
139
  }
140
+ const apps = unique([name, ...(options.apps ?? []).map(validateName)]);
158
141
 
159
- let selectedServices: Service[];
142
+ let services: SingletonApp[];
160
143
  if (options.services) {
161
- selectedServices = options.services.split(",").map((s) => s.trim()) as Service[];
144
+ services = validateServices(options.services);
145
+ } else if (interactive) {
146
+ services = await promptServices();
162
147
  } else {
163
- const result = await inquirer.prompt([
164
- {
165
- message: "Select services:",
166
- name: "services",
167
- type: "checkbox",
168
- choices: services.map((s) => ({ name: s, value: s })),
169
- },
170
- ]);
171
- selectedServices = result.services as Service[];
172
- }
173
-
174
- let selectedApps: App[];
175
- if (options.apps) {
176
- selectedApps = options.apps.split(",").map((s) => s.trim()) as App[];
177
- } else {
178
- const result = await inquirer.prompt([
179
- {
180
- message: "Select apps:",
181
- name: "apps",
182
- type: "checkbox",
183
- choices: apps.map((a) => ({
184
- name: a,
185
- value: a,
186
- checked: a === "one",
187
- })),
188
- },
189
- ]);
190
- selectedApps = result.apps as App[];
148
+ services = [];
191
149
  }
192
150
 
193
- // Target dir: when project name is provided, use ./<name>; when not provided, we prompted above (default "my-app"). Init cannot run inside an existing git repo (enforced by CLI).
194
- const targetDir = resolve(projectName);
195
-
196
- if (existsSync(targetDir)) {
197
- const entries = readdirSync(targetDir);
198
- if (entries.length > 0) {
199
- throw new Error(`Directory "${targetDir}" already exists and is not empty`);
200
- }
201
- }
202
-
203
- // Clone repository (via script when provided by CLI, else inline)
204
- const branch = options.checkout || "main";
205
- console.log("\nCloning repository...");
206
- if (options.cloneScript) {
207
- execSync(`sh "${options.cloneScript}" "${repoUrl}" "${branch}" "${targetDir}"`, {
208
- stdio: "inherit",
209
- });
210
- } else {
211
- execSync(`git clone --depth 1 --branch "${branch}" ${repoUrl} "${targetDir}"`, {
212
- stdio: "inherit",
213
- });
214
- }
215
-
216
- // Remove .git to start fresh
217
- rmSync(join(targetDir, ".git"), { recursive: true, force: true });
218
-
219
- runModifyStep(targetDir, selectedServices, selectedApps);
220
-
221
- // Init-only: update root package.json name
222
- const rootPkgPath = join(targetDir, "package.json");
223
- if (existsSync(rootPkgPath)) {
224
- const rootPkg = JSON.parse(readFileSync(rootPkgPath, "utf-8"));
225
- rootPkg.name = projectName;
226
- rootPkg.packageManager = undefined;
227
- writeFileSync(rootPkgPath, `${JSON.stringify(rootPkg, null, 2)}\n`);
228
- }
229
-
230
- // Copy .env.example to .env if it exists
231
- const envDefaultPath = join(targetDir, ".env.example");
232
- const envPath = join(targetDir, ".env");
233
- if (existsSync(envDefaultPath) && !existsSync(envPath)) {
234
- writeFileSync(envPath, readFileSync(envDefaultPath, "utf-8"));
235
- }
236
-
237
- // Remove storybook baselines
238
- const lostpixelBaseline = join(targetDir, "apps", "storybook", ".lostpixel", "baseline");
239
- if (existsSync(lostpixelBaseline)) {
240
- rmSync(lostpixelBaseline, { recursive: true });
241
- }
242
-
243
- // Remove pnpm-lock.yaml for fresh install
244
- const lockfile = join(targetDir, "pnpm-lock.yaml");
245
- if (existsSync(lockfile)) {
246
- rmSync(lockfile);
247
- }
248
-
249
- // Remove scaffolding artifacts
250
- for (const artifact of [
251
- "agent-os",
252
- "public/cli/scripts/init.sh",
253
- "public/cli/scripts/update.sh",
254
- ".changeset",
255
- ]) {
256
- const p = join(targetDir, artifact);
257
- if (existsSync(p)) {
258
- rmSync(p, { recursive: true, force: true });
259
- }
260
- }
261
-
262
- // Initialize project
263
- console.log("\nInstalling dependencies...");
264
- execSync("pnpm install", { cwd: targetDir, stdio: "inherit" });
265
-
266
- console.log("\nInitializing git repository...");
267
- execSync("git init", { cwd: targetDir, stdio: "inherit" });
268
- execSync("git add -A", { cwd: targetDir, stdio: "inherit" });
269
- execSync('git commit -m "Initial commit from multiplatform.one"', {
270
- cwd: targetDir,
271
- stdio: "inherit",
272
- });
273
-
274
- console.log(`\n✅ Project created at ${targetDir}`);
275
- console.log("\nNext steps:");
276
- console.log(` cd ${projectName}`);
277
- console.log(" pnpm dev");
278
- }
279
-
280
- function convertWorkspaceVersions(dir: string, versionMap: Map<string, string>) {
281
- const files = findPackageJsonFiles(dir);
282
- for (const file of files) {
283
- const content = JSON.parse(readFileSync(file, "utf-8"));
284
- let changed = false;
285
- for (const depType of ["dependencies", "devDependencies", "peerDependencies"]) {
286
- const deps = content[depType] as Record<string, string> | undefined;
287
- if (!deps) continue;
288
- for (const [depName, version] of Object.entries(deps)) {
289
- if (typeof version === "string" && version.startsWith("workspace:")) {
290
- const realVersion = versionMap.get(depName);
291
- if (realVersion) {
292
- // workspace:* -> ^version, workspace:^x -> ^x (keep the range)
293
- const workspaceRange = version.replace("workspace:", "");
294
- deps[depName] =
295
- workspaceRange === "*" || workspaceRange === "^" ? `^${realVersion}` : workspaceRange;
296
- changed = true;
297
- }
298
- }
299
- }
300
- }
301
- if (changed) {
302
- writeFileSync(file, `${JSON.stringify(content, null, 2)}\n`);
303
- }
304
- }
305
- }
306
-
307
- function findPackageJsonFiles(dir: string): string[] {
308
- const results: string[] = [];
309
- function walk(d: string) {
310
- let entries: Dirent[];
311
- try {
312
- entries = readdirSync(d, { withFileTypes: true });
313
- } catch {
314
- return;
315
- }
316
- for (const entry of entries) {
317
- if (entry.name === "node_modules" || entry.name === ".git") continue;
318
- const full = join(d, entry.name);
319
- if (entry.isDirectory()) walk(full);
320
- else if (entry.name === "package.json") results.push(full);
321
- }
322
- }
323
- walk(dir);
324
- return results;
325
- }
326
-
327
- function cleanupPnpmWorkspace(dir: string, _services: Service[], _apps: App[]) {
328
- const wsPath = join(dir, "pnpm-workspace.yaml");
329
- if (!existsSync(wsPath)) return;
330
-
331
- let content = readFileSync(wsPath, "utf-8");
332
-
333
- // If no apps remain, remove apps/* entry
334
- const appsDir = join(dir, "apps");
335
- if (!existsSync(appsDir) || readdirSync(appsDir).length === 0) {
336
- content = content.replace(/^\s*-\s*['"]?apps\/\*['"]?\s*$/gm, "");
337
- }
338
-
339
- // Check if packages dir still has entries
340
- const pkgDir = join(dir, "packages");
341
- if (!existsSync(pkgDir) || readdirSync(pkgDir).length === 0) {
342
- content = content.replace(/^\s*-\s*['"]?packages\/\*['"]?\s*$/gm, "");
343
- }
344
-
345
- // Clean up multiple blank lines
346
- content = `${content.replace(/\n{3,}/g, "\n\n").trim()}\n`;
347
- writeFileSync(wsPath, content);
348
- }
349
-
350
- function cleanupLaunchJson(dir: string, services: Service[], apps: App[]) {
351
- const launchPath = join(dir, ".vscode", "launch.json");
352
- if (!existsSync(launchPath)) return;
353
-
354
- const launch = JSON.parse(readFileSync(launchPath, "utf-8"));
355
- const removedNames = new Set<string>();
356
-
357
- // Collect names to remove for unselected services
358
- for (const service of services) {
359
- if (!services.includes(service)) {
360
- for (const configName of serviceLaunchNames[service]) {
361
- removedNames.add(configName);
362
- }
363
- }
364
- }
365
-
366
- // Collect names to remove for unselected apps
367
- for (const app of apps) {
368
- if (!apps.includes(app)) {
369
- for (const configName of appLaunchNames[app]) {
370
- removedNames.add(configName);
371
- }
372
- }
373
- }
374
-
375
- // Filter configurations
376
- if (launch.configurations) {
377
- launch.configurations = launch.configurations.filter(
378
- (c: { name: string }) => !removedNames.has(c.name),
151
+ if (existsSync(targetDir) && readdirSync(targetDir).length > 0 && !options.into) {
152
+ throw new Error(
153
+ `Directory "${targetDir}" already exists and is not empty. ` +
154
+ "Pass --into to scaffold into it (paths that already exist are kept and pinned in " +
155
+ ".updateignore), or pick another name.",
379
156
  );
380
157
  }
381
158
 
382
- // Filter compound configurations and remove empty compounds
383
- if (launch.compounds) {
384
- for (const compound of launch.compounds) {
385
- if (compound.configurations) {
386
- compound.configurations = compound.configurations.filter(
387
- (configName: string) => !removedNames.has(configName),
388
- );
389
- }
390
- }
391
- launch.compounds = launch.compounds.filter(
392
- (c: { configurations?: string[] }) => c.configurations && c.configurations.length > 0,
393
- );
394
- }
395
-
396
- writeFileSync(launchPath, `${JSON.stringify(launch, null, 2)}\n`);
397
- }
159
+ const spec = resolveSource({ source: options.source, checkout: options.checkout });
160
+ if (spec.warning) console.warn(`⚠️ ${spec.warning}`);
398
161
 
399
- function cleanupEnvFiles(dir: string, services: Service[], apps: App[]) {
400
- for (const envFile of [".env.example", ".env"]) {
401
- const filePath = join(dir, envFile);
402
- if (!existsSync(filePath)) continue;
162
+ console.log(`\nScaffolding ${targetDir} from ${spec.source} @ ${spec.ref}`);
163
+ console.log(` apps ${apps.join(", ")}`);
164
+ console.log(` services ${services.length ? services.join(", ") : "(none)"}`);
403
165
 
404
- let lines = readFileSync(filePath, "utf-8").split("\n");
405
-
406
- // Remove lines for unselected services
407
- for (const service of services) {
408
- if (!services.includes(service)) {
409
- const prefixes = serviceEnvPrefixes[service];
410
- const sections = serviceEnvSections[service];
411
- lines = filterEnvLines(lines, prefixes, sections);
412
- }
413
- }
414
-
415
- // Remove lines for unselected apps
416
- for (const app of apps) {
417
- if (!apps.includes(app)) {
418
- const prefixes = appEnvPrefixes[app];
419
- const sections = appEnvSections[app];
420
- lines = filterEnvLines(lines, prefixes, sections);
421
- }
422
- }
423
-
424
- // Clean up multiple blank lines
425
- const content = lines
426
- .join("\n")
427
- .replace(/\n{3,}/g, "\n\n")
428
- .trim();
429
- writeFileSync(filePath, `${content}\n`);
430
- }
431
- }
432
-
433
- function filterEnvLines(lines: string[], prefixes: string[], sections: string[]): string[] {
434
- return lines.filter((line) => {
435
- const trimmed = line.trim();
436
- // Remove section headers
437
- for (const section of sections) {
438
- if (trimmed === section) return false;
439
- }
440
- // Remove lines with matching prefixes
441
- for (const prefix of prefixes) {
442
- if (prefix.endsWith("=")) {
443
- // Exact match like "VR_DIFFING_ENGINE="
444
- if (trimmed.startsWith(prefix)) return false;
445
- } else {
446
- // Prefix match like "FRAPPE_"
447
- if (trimmed.startsWith(prefix) || trimmed.startsWith(`# ${prefix}`)) return false;
448
- }
449
- }
450
- return true;
166
+ const provenance = await scaffoldWithFallback(spec, {
167
+ source: spec.source,
168
+ ref: spec.ref,
169
+ target: targetDir,
170
+ apps,
171
+ services,
172
+ mpoVersion: options.mpoVersion ? mpoRange(options.mpoVersion) : undefined,
173
+ into: options.into,
174
+ verbose: options.verbose,
451
175
  });
452
- }
453
-
454
- function cleanupTsconfigs(dir: string) {
455
- const tsconfigFiles = findTsconfigFiles(dir);
456
- for (const file of tsconfigFiles) {
457
- try {
458
- const content = readFileSync(file, "utf-8");
459
- // Parse JSON with comments (tsconfig allows comments)
460
- const json = JSON.parse(stripJsonComments(content));
461
- if (!json.references) continue;
462
-
463
- let changed = false;
464
- json.references = json.references.filter((ref: { path: string }) => {
465
- const refPath = resolve(join(file, "..", ref.path));
466
- // Check if the referenced path exists (could be a dir or file)
467
- const pathExists =
468
- existsSync(refPath) ||
469
- existsSync(`${refPath}.json`) ||
470
- existsSync(join(refPath, "tsconfig.json"));
471
- if (!pathExists) changed = true;
472
- return pathExists;
473
- });
474
-
475
- if (changed) {
476
- writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`);
477
- }
478
- } catch {
479
- // Skip files that can't be parsed
480
- }
481
- }
482
- }
483
-
484
- function findTsconfigFiles(dir: string): string[] {
485
- const results: string[] = [];
486
- function walk(d: string) {
487
- let entries: Dirent[];
488
- try {
489
- entries = readdirSync(d, { withFileTypes: true });
490
- } catch {
491
- return;
492
- }
493
- for (const entry of entries) {
494
- if (entry.name === "node_modules" || entry.name === ".git") continue;
495
- const full = join(d, entry.name);
496
- if (entry.isDirectory()) walk(full);
497
- else if (entry.name.startsWith("tsconfig") && entry.name.endsWith(".json")) {
498
- results.push(full);
499
- }
500
- }
501
- }
502
- walk(dir);
503
- return results;
504
- }
505
-
506
- function stripJsonComments(str: string): string {
507
- // Simple JSON comment stripper for tsconfig files
508
- return str.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
509
- }
510
-
511
- function cleanupMisc(dir: string) {
512
- // Remove docker dns dir (if generating from template)
513
- for (const subdir of ["docker/dns"]) {
514
- const p = join(dir, subdir);
515
- if (existsSync(p)) {
516
- rmSync(p, { recursive: true });
517
- }
518
- }
519
-
520
- // Remove VSCode psi-header config from settings.json
521
- const settingsPath = join(dir, ".vscode", "settings.json");
522
- if (existsSync(settingsPath)) {
523
- try {
524
- const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
525
- const psiKeys = Object.keys(settings).filter((k) => k.startsWith("psi-header"));
526
- const cleaned = { ...settings };
527
- for (const key of psiKeys) {
528
- cleaned[key] = undefined;
529
- }
530
- // Filter out undefined keys by serializing
531
- const filtered = JSON.parse(JSON.stringify(cleaned));
532
- writeFileSync(settingsPath, `${JSON.stringify(filtered, null, 2)}\n`);
533
- } catch {
534
- // Skip if settings can't be parsed
535
- }
536
- }
537
-
538
- // Remove psi-header from extensions.json recommendations
539
- const extPath = join(dir, ".vscode", "extensions.json");
540
- if (existsSync(extPath)) {
541
- try {
542
- const ext = JSON.parse(readFileSync(extPath, "utf-8"));
543
- if (ext.recommendations) {
544
- ext.recommendations = ext.recommendations.filter((r: string) => r !== "psioniq.psi-header");
545
- }
546
- writeFileSync(extPath, `${JSON.stringify(ext, null, 2)}\n`);
547
- } catch {
548
- // Skip if can't be parsed
549
- }
550
- }
551
- }
552
-
553
- const licenseIndicators = ["File:", "Project:", "File Created:", "Author:", "Licensed under"];
554
-
555
- function removeLicenseHeaders(dir: string) {
556
- const files = findSourceFiles(dir);
557
- for (const file of files) {
558
- try {
559
- const content = readFileSync(file, "utf-8");
560
- // Check for block comment license header at start of file
561
- const match = content.match(/^\s*\/\*[\s\S]*?\*\//);
562
- if (match) {
563
- const comment = match[0];
564
- const isLicense = licenseIndicators.some((indicator) => comment.includes(indicator));
565
- if (isLicense && match.index !== undefined) {
566
- const cleaned = content.slice(match.index + comment.length).replace(/^\s*\n/, "");
567
- writeFileSync(file, cleaned);
568
- }
569
- }
570
- } catch {
571
- // Skip files that can't be read
572
- }
573
- }
574
-
575
- // Also handle hash-style license headers for shell/yaml/python files
576
- const hashFiles = findHashCommentFiles(dir);
577
- for (const file of hashFiles) {
578
- try {
579
- const content = readFileSync(file, "utf-8");
580
- const lines = content.split("\n");
581
- let headerEnd = 0;
582
- let inHeader = false;
583
-
584
- for (let i = 0; i < lines.length; i++) {
585
- const line = lines[i];
586
- if (i === 0 && line.startsWith("#!")) {
587
- // Skip shebang
588
- continue;
589
- }
590
- if (
591
- line.startsWith("# File:") ||
592
- line.startsWith("# Project:") ||
593
- line.startsWith("# File Created:") ||
594
- line.startsWith("# Author:")
595
- ) {
596
- inHeader = true;
597
- headerEnd = i + 1;
598
- continue;
599
- }
600
- if (inHeader && line.startsWith("#")) {
601
- headerEnd = i + 1;
602
- continue;
603
- }
604
- if (inHeader && line.trim() === "") {
605
- headerEnd = i + 1;
606
- break;
607
- }
608
- if (inHeader) break;
609
- }
610
-
611
- if (inHeader && headerEnd > 0) {
612
- const shebang = lines[0].startsWith("#!") ? `${lines[0]}\n\n` : "";
613
- const remaining = lines
614
- .slice(headerEnd)
615
- .join("\n")
616
- .replace(/^\s*\n/, "");
617
- writeFileSync(file, `${shebang}${remaining}`);
618
- }
619
- } catch {
620
- // Skip files that can't be read
621
- }
176
+ const sha = provenance.source!.sha;
177
+ console.log(` source ${sha}`);
178
+
179
+ // A fresh project gets its scaffold commit; a directory already inside a
180
+ // repository (--into, or nested in another project) is left for the
181
+ // owner to commit.
182
+ let scaffoldCommitted = false;
183
+ if (!options.skipGit && !(await insideGitRepo(targetDir))) {
184
+ await spawn("git", ["init", "--quiet"], { cwd: targetDir });
185
+ await spawn("git", ["add", "-A"], { cwd: targetDir });
186
+ scaffoldCommitted = await spawn(
187
+ "git",
188
+ [
189
+ "commit",
190
+ "--quiet",
191
+ "-m",
192
+ `feat: scaffold from multiplatform.one ${sha.slice(0, 12)} (mpo init)`,
193
+ ],
194
+ { cwd: targetDir },
195
+ ).then(
196
+ () => true,
197
+ () => {
198
+ console.warn("⚠️ git commit failed (missing git identity?) — repo left staged.");
199
+ return false;
200
+ },
201
+ );
622
202
  }
623
- }
624
203
 
625
- function findSourceFiles(dir: string): string[] {
626
- const results: string[] = [];
627
- const extensions = new Set([".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]);
628
- function walk(d: string) {
629
- let entries: Dirent[];
630
- try {
631
- entries = readdirSync(d, { withFileTypes: true });
632
- } catch {
633
- return;
634
- }
635
- for (const entry of entries) {
636
- if (entry.name === "node_modules" || entry.name === ".git") continue;
637
- const full = join(d, entry.name);
638
- if (entry.isDirectory()) walk(full);
639
- else {
640
- const ext = `.${entry.name.split(".").pop()}`;
641
- if (extensions.has(ext)) results.push(full);
642
- }
204
+ if (!options.skipInstall) {
205
+ console.log("\nInstalling dependencies...");
206
+ await spawn("pnpm", ["install"], { cwd: targetDir, stdio: "inherit" });
207
+ if (scaffoldCommitted) {
208
+ // The lockfile belongs to the scaffold commit so a fresh init is clean.
209
+ await spawn("git", ["add", "--", "pnpm-lock.yaml"], { cwd: targetDir })
210
+ .then(() => spawn("git", ["commit", "--amend", "--no-edit", "--quiet"], { cwd: targetDir }))
211
+ .catch(() => {});
643
212
  }
213
+ } else {
214
+ console.log("Skipped pnpm install (--skip-install).");
644
215
  }
645
- walk(dir);
646
- return results;
647
- }
648
216
 
649
- function findHashCommentFiles(dir: string): string[] {
650
- const results: string[] = [];
651
- const extensions = new Set([".sh", ".mk", ".yaml", ".yml", ".py"]);
652
- function walk(d: string) {
653
- let entries: Dirent[];
654
- try {
655
- entries = readdirSync(d, { withFileTypes: true });
656
- } catch {
657
- return;
658
- }
659
- for (const entry of entries) {
660
- if (entry.name === "node_modules" || entry.name === ".git") continue;
661
- const full = join(d, entry.name);
662
- if (entry.isDirectory()) walk(full);
663
- else {
664
- const ext = `.${entry.name.split(".").pop()}`;
665
- if (extensions.has(ext) || entry.name === "Makefile") results.push(full);
666
- }
667
- }
668
- }
669
- walk(dir);
670
- return results;
217
+ console.log(`\n✅ Project created at ${targetDir}`);
218
+ console.log("\nNext steps:");
219
+ if (!options.into) console.log(` cd ${basename(targetDir)}`);
220
+ if (options.skipInstall) console.log(" pnpm install");
221
+ if (options.into || options.skipGit) {
222
+ console.log(' git add -A && git commit -m "feat: scaffold from multiplatform.one (mpo init)"');
223
+ }
224
+ console.log(" pnpm dev # web + native dev server");
225
+ if (services.includes("frappe"))
226
+ console.log(" make frappe/dev # Frappe bench (see apps/frappe)");
227
+ console.log("\nLater: `mpo update` merges monorepo changes into the project without committing;");
228
+ console.log("`mpo add-app <name>` adds another product app.");
671
229
  }