@multiplatform.one/cli 6.0.4 → 6.3.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 (104) hide show
  1. package/README.md +72 -0
  2. package/bin/createMultiplatformApp.mjs +77 -0
  3. package/lib/bin/multiplatformOne.mjs +23 -9
  4. package/lib/commands/e2e.mjs +3 -3
  5. package/lib/commands/init.mjs +16 -12
  6. package/lib/commands/initApp.mjs +145 -0
  7. package/lib/generateVscode.mjs +27 -10
  8. package/package.json +23 -12
  9. package/src/bin/multiplatformOne.ts +54 -16
  10. package/src/commands/init.ts +7 -18
  11. package/src/commands/initApp.spec.ts +210 -0
  12. package/src/commands/initApp.ts +228 -0
  13. package/src/generateVscode.ts +31 -9
  14. package/templates/app/AGENTS.md +142 -0
  15. package/templates/app/README.md +62 -0
  16. package/templates/app/apps/__NAME__/index.html +36 -0
  17. package/templates/app/apps/__NAME__/package.json +30 -0
  18. package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  19. package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  20. package/templates/app/apps/__NAME__/src/App.tsx +9 -0
  21. package/templates/app/apps/__NAME__/src/main.tsx +14 -0
  22. package/templates/app/apps/__NAME__/tsconfig.json +8 -0
  23. package/templates/app/apps/__NAME__/vite.config.ts +17 -0
  24. package/templates/app/features/__NAME__/package.json +34 -0
  25. package/templates/app/features/__NAME__/src/HomeScreen.tsx +70 -0
  26. package/templates/app/features/__NAME__/src/index.ts +1 -0
  27. package/templates/app/features/__NAME__/tsconfig.json +5 -0
  28. package/templates/app/gitignore +8 -0
  29. package/templates/app/npmrc +2 -0
  30. package/templates/app/oxlintrc.json +39 -0
  31. package/templates/app/package.json +20 -0
  32. package/templates/app/packages/config/config.json +4 -0
  33. package/templates/app/packages/config/index.ts +5 -0
  34. package/templates/app/packages/config/package.json +17 -0
  35. package/templates/app/packages/config/tsconfig.json +5 -0
  36. package/templates/app/pnpm-workspace.yaml +4 -0
  37. package/templates/app/tsconfig.base.json +16 -0
  38. package/templates/universal/AGENTS.md +142 -0
  39. package/templates/universal/README.md +97 -0
  40. package/templates/universal/apps/__NAME__/app.config.js +31 -0
  41. package/templates/universal/apps/__NAME__/config/tamagui.config.ts +4 -0
  42. package/templates/universal/apps/__NAME__/package.json +61 -0
  43. package/templates/universal/apps/__NAME__/polyfills.native.ts +23 -0
  44. package/templates/universal/apps/__NAME__/polyfills.ts +3 -0
  45. package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
  46. package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
  47. package/templates/universal/apps/__NAME__/routes/_layout.tsx +22 -0
  48. package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +21 -0
  49. package/templates/universal/apps/__NAME__/routes/about.tsx +3 -0
  50. package/templates/universal/apps/__NAME__/routes/index.tsx +3 -0
  51. package/templates/universal/apps/__NAME__/src/app.ts +10 -0
  52. package/templates/universal/apps/__NAME__/tamagui.css +3 -0
  53. package/templates/universal/apps/__NAME__/tsconfig.json +8 -0
  54. package/templates/universal/apps/__NAME__/types.d.ts +9 -0
  55. package/templates/universal/apps/__NAME__/vite.config.ts +66 -0
  56. package/templates/universal/features/__NAME__/package.json +35 -0
  57. package/templates/universal/features/__NAME__/src/AboutScreen.tsx +27 -0
  58. package/templates/universal/features/__NAME__/src/AppLayout.tsx +50 -0
  59. package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +22 -0
  60. package/templates/universal/features/__NAME__/src/ContactForm.tsx +55 -0
  61. package/templates/universal/features/__NAME__/src/HomeScreen.tsx +40 -0
  62. package/templates/universal/features/__NAME__/src/index.ts +4 -0
  63. package/templates/universal/features/__NAME__/src/screens.spec.ts +12 -0
  64. package/templates/universal/features/__NAME__/tsconfig.json +5 -0
  65. package/templates/universal/features/__NAME__/vitest.config.mjs +7 -0
  66. package/templates/universal/gitignore +12 -0
  67. package/templates/universal/npmrc +3 -0
  68. package/templates/universal/oxlintrc.json +39 -0
  69. package/templates/universal/package.json +27 -0
  70. package/templates/universal/packages/config/config.json +4 -0
  71. package/templates/universal/packages/config/index.ts +5 -0
  72. package/templates/universal/packages/config/package.json +17 -0
  73. package/templates/universal/packages/config/tsconfig.json +5 -0
  74. package/templates/universal/packages/i18n/en/common.json +30 -0
  75. package/templates/universal/packages/i18n/es/common.json +30 -0
  76. package/templates/universal/packages/i18n/i18n.d.ts +16 -0
  77. package/templates/universal/packages/i18n/index.ts +26 -0
  78. package/templates/universal/packages/i18n/package.json +20 -0
  79. package/templates/universal/packages/i18n/resources.ts +13 -0
  80. package/templates/universal/packages/i18n/tsconfig.json +5 -0
  81. package/templates/universal/packages/themes/accent.ts +32 -0
  82. package/templates/universal/packages/themes/base.ts +50 -0
  83. package/templates/universal/packages/themes/fonts.ts +8 -0
  84. package/templates/universal/packages/themes/index.ts +33 -0
  85. package/templates/universal/packages/themes/package.json +23 -0
  86. package/templates/universal/packages/themes/tamagui.config.ts +14 -0
  87. package/templates/universal/packages/themes/tsconfig.json +5 -0
  88. package/templates/universal/pnpm-workspace.yaml +4 -0
  89. package/templates/universal/tsconfig.base.json +21 -0
  90. package/templates/universal/types/env.d.ts +9 -0
  91. package/types/bin/multiplatformOne.d.ts +11 -0
  92. package/types/bin/multiplatformOne.d.ts.map +1 -0
  93. package/types/commands/e2e.d.ts +14 -0
  94. package/types/commands/e2e.d.ts.map +1 -0
  95. package/types/commands/init.d.ts +16 -0
  96. package/types/commands/init.d.ts.map +1 -0
  97. package/types/commands/initApp.d.ts +25 -0
  98. package/types/commands/initApp.d.ts.map +1 -0
  99. package/types/generateVscode.d.ts +7 -0
  100. package/types/generateVscode.d.ts.map +1 -0
  101. package/types/index.d.ts +2 -0
  102. package/types/index.d.ts.map +1 -0
  103. package/types/types.d.ts +6 -0
  104. package/types/types.d.ts.map +1 -0
@@ -11,15 +11,10 @@ const services = ["frappe", "solana", "ethereum", "sui"] as const;
11
11
  function serviceDir(name: string): string {
12
12
  return `apps/${name}`;
13
13
  }
14
- const apps = [
15
- "one",
16
- "keycloak",
17
- "storybook",
18
- "storybook-expo",
19
- "vscode",
20
- "webext",
21
- "vocs",
22
- ] as const;
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;
23
18
 
24
19
  type Service = (typeof services)[number];
25
20
  type App = (typeof apps)[number];
@@ -34,12 +29,10 @@ const serviceEnvPrefixes: Record<Service, string[]> = {
34
29
 
35
30
  /** env variable prefixes to remove per app */
36
31
  const appEnvPrefixes: Record<App, string[]> = {
37
- one: ["ONE_"],
32
+ one: ["ONE_", "VSCODE_", "WEBEXT_"],
38
33
  keycloak: ["KEYCLOAK_"],
39
34
  storybook: ["STORYBOOK_", "VR_DIFFING_ENGINE="],
40
35
  "storybook-expo": [],
41
- vscode: ["VSCODE_"],
42
- webext: ["WEBEXT_"],
43
36
  vocs: ["VOCS_"],
44
37
  };
45
38
 
@@ -52,12 +45,10 @@ const serviceEnvSections: Record<Service, string[]> = {
52
45
  };
53
46
 
54
47
  const appEnvSections: Record<App, string[]> = {
55
- one: ["# one"],
48
+ one: ["# one", "# vscode", "# webext"],
56
49
  keycloak: ["# keycloak"],
57
50
  storybook: ["# storybook"],
58
51
  "storybook-expo": [],
59
- vscode: ["# vscode"],
60
- webext: ["# webext"],
61
52
  vocs: ["# vocs"],
62
53
  };
63
54
 
@@ -70,12 +61,10 @@ const serviceLaunchNames: Record<Service, string[]> = {
70
61
  };
71
62
 
72
63
  const appLaunchNames: Record<App, string[]> = {
73
- one: ["one dev"],
64
+ one: ["one dev", "vscode dev", "webext dev"],
74
65
  keycloak: ["keycloak dev", "keycloak storybook"],
75
66
  storybook: ["storybook dev"],
76
67
  "storybook-expo": ["storybook-expo dev"],
77
- vscode: ["vscode dev"],
78
- webext: ["webext dev"],
79
68
  vocs: ["vocs dev"],
80
69
  };
81
70
 
@@ -0,0 +1,210 @@
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
5
+ import { initApp } from "./initApp";
6
+
7
+ const readJson = (path: string) => JSON.parse(readFileSync(path, "utf-8"));
8
+
9
+ /** Collect dependency ranges by scope for leakage assertions. */
10
+ function depRanges(pkg: {
11
+ dependencies?: Record<string, string>;
12
+ devDependencies?: Record<string, string>;
13
+ }): Record<string, string> {
14
+ return { ...pkg.dependencies, ...pkg.devDependencies };
15
+ }
16
+
17
+ describe("initApp generator", () => {
18
+ let sandbox: string;
19
+ let previousCwd: string;
20
+
21
+ beforeAll(() => {
22
+ sandbox = mkdtempSync(join(tmpdir(), "mpo-initapp-spec-"));
23
+ previousCwd = process.cwd();
24
+ process.chdir(sandbox);
25
+ });
26
+
27
+ afterAll(() => {
28
+ process.chdir(previousCwd);
29
+ rmSync(sandbox, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("universal template", () => {
33
+ const name = "uni-demo";
34
+ let root: string;
35
+
36
+ beforeAll(async () => {
37
+ await initApp(name, {
38
+ template: "universal",
39
+ skipInstall: true,
40
+ version: "6.1.0",
41
+ yes: true,
42
+ });
43
+ root = join(sandbox, name);
44
+ });
45
+
46
+ it("writes the workspace tree", () => {
47
+ for (const file of [
48
+ "package.json",
49
+ "pnpm-workspace.yaml",
50
+ "tsconfig.base.json",
51
+ "oxlintrc.json",
52
+ "AGENTS.md",
53
+ "README.md",
54
+ ".gitignore",
55
+ ".npmrc",
56
+ `apps/${name}/package.json`,
57
+ `apps/${name}/vite.config.ts`,
58
+ `apps/${name}/app.config.js`,
59
+ `apps/${name}/tamagui.css`,
60
+ `apps/${name}/types.d.ts`,
61
+ `apps/${name}/config/tamagui.config.ts`,
62
+ `apps/${name}/routes/_layout.tsx`,
63
+ `apps/${name}/routes/_layout.web.tsx`,
64
+ `apps/${name}/routes/index.tsx`,
65
+ `apps/${name}/routes/about.tsx`,
66
+ `apps/${name}/src/app.ts`,
67
+ `apps/${name}/polyfills.ts`,
68
+ `apps/${name}/polyfills.native.ts`,
69
+ `features/${name}/package.json`,
70
+ `features/${name}/vitest.config.mjs`,
71
+ `features/${name}/src/HomeScreen.tsx`,
72
+ `features/${name}/src/AboutScreen.tsx`,
73
+ `features/${name}/src/AppLayout.tsx`,
74
+ `features/${name}/src/ContactForm.tsx`,
75
+ `features/${name}/src/ContactForm.spec.tsx`,
76
+ "packages/config/package.json",
77
+ "packages/i18n/en/common.json",
78
+ "packages/i18n/es/common.json",
79
+ "packages/i18n/resources.ts",
80
+ "packages/themes/tamagui.config.ts",
81
+ "packages/themes/fonts.ts",
82
+ "packages/themes/base.ts",
83
+ "packages/themes/accent.ts",
84
+ "types/env.d.ts",
85
+ ]) {
86
+ expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
87
+ }
88
+ });
89
+
90
+ it("restores dotfile names stripped by npm pack", () => {
91
+ expect(readFileSync(join(root, ".npmrc"), "utf-8")).toContain("node-linker=hoisted");
92
+ expect(readFileSync(join(root, ".gitignore"), "utf-8")).toContain("node_modules");
93
+ expect(existsSync(join(root, "npmrc"))).toBe(false);
94
+ expect(existsSync(join(root, "gitignore"))).toBe(false);
95
+ });
96
+
97
+ it("pins @multiplatform.one/* to semver and locals to workspace:*", () => {
98
+ for (const pkgPath of [
99
+ `apps/${name}/package.json`,
100
+ `features/${name}/package.json`,
101
+ "packages/i18n/package.json",
102
+ "packages/themes/package.json",
103
+ ]) {
104
+ const ranges = depRanges(readJson(join(root, pkgPath)));
105
+ for (const [dep, range] of Object.entries(ranges)) {
106
+ if (dep.startsWith("@multiplatform.one/")) {
107
+ expect(range, `${pkgPath} ${dep}`).toBe("^6.1.0");
108
+ } else if (dep.startsWith("@features/") || dep.startsWith("@package/")) {
109
+ expect(range, `${pkgPath} ${dep}`).toBe("workspace:*");
110
+ } else {
111
+ expect(range, `${pkgPath} ${dep} leaked a workspace range`).not.toMatch(/^workspace:/u);
112
+ }
113
+ }
114
+ }
115
+ });
116
+
117
+ it("renders template vars (no placeholders left)", () => {
118
+ const appPkg = readJson(join(root, `apps/${name}/package.json`));
119
+ expect(appPkg.name).toBe(`@app/${name}`);
120
+ const rootPkg = readJson(join(root, "package.json"));
121
+ expect(rootPkg.name).toBe(name);
122
+ const appConfig = readFileSync(join(root, `apps/${name}/app.config.js`), "utf-8");
123
+ expect(appConfig).toContain('name: "UniDemo"');
124
+ expect(appConfig).toContain("com.unidemo.app");
125
+ const viteConfig = readFileSync(join(root, `apps/${name}/vite.config.ts`), "utf-8");
126
+ expect(viteConfig).toContain('key: "UniDemo"');
127
+ for (const file of [
128
+ "package.json",
129
+ "README.md",
130
+ `apps/${name}/app.config.js`,
131
+ `features/${name}/src/HomeScreen.tsx`,
132
+ "packages/i18n/en/common.json",
133
+ ]) {
134
+ expect(readFileSync(join(root, file), "utf-8")).not.toMatch(/__NAME(_PASCAL|_APPID)?__/u);
135
+ }
136
+ });
137
+
138
+ it("wires universal scripts", () => {
139
+ const rootPkg = readJson(join(root, "package.json"));
140
+ for (const script of [
141
+ "dev",
142
+ "build",
143
+ "native",
144
+ "ios",
145
+ "android",
146
+ "test",
147
+ "lint",
148
+ "typecheck",
149
+ ]) {
150
+ expect(rootPkg.scripts[script], `root script ${script}`).toBeDefined();
151
+ }
152
+ const appPkg = readJson(join(root, `apps/${name}/package.json`));
153
+ expect(appPkg.scripts.dev).toContain("one dev");
154
+ expect(appPkg.scripts.build).toBe("one build");
155
+ expect(appPkg.scripts["prebuild:native"]).toBe("one prebuild");
156
+ });
157
+ });
158
+
159
+ describe("app (web-only) template regression", () => {
160
+ const name = "web-demo";
161
+ let root: string;
162
+
163
+ beforeAll(async () => {
164
+ await initApp(name, {
165
+ template: "app",
166
+ skipInstall: true,
167
+ version: "6.1.0",
168
+ yes: true,
169
+ });
170
+ root = join(sandbox, name);
171
+ });
172
+
173
+ it("keeps the web-only tree (no universal extras)", () => {
174
+ for (const file of [
175
+ "package.json",
176
+ ".npmrc",
177
+ ".gitignore",
178
+ `apps/${name}/vite.config.ts`,
179
+ `apps/${name}/src/main.tsx`,
180
+ `features/${name}/src/HomeScreen.tsx`,
181
+ "packages/config/package.json",
182
+ ]) {
183
+ expect(existsSync(join(root, file)), `missing ${file}`).toBe(true);
184
+ }
185
+ expect(existsSync(join(root, "packages/i18n"))).toBe(false);
186
+ expect(existsSync(join(root, "packages/themes"))).toBe(false);
187
+ expect(existsSync(join(root, `apps/${name}/routes`))).toBe(false);
188
+ });
189
+
190
+ it("keeps semver deps and web scripts", () => {
191
+ const appPkg = readJson(join(root, `apps/${name}/package.json`));
192
+ expect(appPkg.dependencies["@multiplatform.one/components"]).toBe("^6.1.0");
193
+ expect(appPkg.dependencies[`@features/${name}`]).toBe("workspace:*");
194
+ expect(appPkg.scripts.dev).toBe("vite");
195
+ });
196
+ });
197
+
198
+ it("rejects invalid names", async () => {
199
+ await expect(
200
+ initApp("Bad Name", { template: "app", skipInstall: true, yes: true }),
201
+ ).rejects.toThrow(/Invalid project name/u);
202
+ });
203
+
204
+ it("refuses to scaffold into a non-empty directory", async () => {
205
+ await initApp("occupied", { template: "app", skipInstall: true, yes: true });
206
+ await expect(
207
+ initApp("occupied", { template: "app", skipInstall: true, yes: true }),
208
+ ).rejects.toThrow(/already exists and is not empty/u);
209
+ });
210
+ });
@@ -0,0 +1,228 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { dirname, extname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import inquirer from "inquirer";
12
+ import spawn from "nano-spawn";
13
+
14
+ /**
15
+ * Consumer project templates:
16
+ * - "universal": one-based app serving web + iOS + Android from one codebase
17
+ * (apps/<name> + features/<name> + packages/{config,i18n,themes})
18
+ * - "app": web-only Vite + React shell
19
+ * (apps/<name> + features/<name> + packages/config)
20
+ */
21
+ export type InitAppTemplate = "universal" | "app";
22
+
23
+ export interface InitAppOptions {
24
+ /** Skip pnpm install (useful for CI / dry scaffolding). */
25
+ skipInstall?: boolean;
26
+ /** Pin @multiplatform.one/* to this semver range. Default: ^<cli version>. */
27
+ version?: string;
28
+ /** Project template. When omitted: prompt (interactive) or "universal". */
29
+ template?: InitAppTemplate;
30
+ /** Accept defaults instead of prompting (non-interactive / CI). */
31
+ yes?: boolean;
32
+ }
33
+
34
+ type TemplateVars = {
35
+ NAME: string;
36
+ NAME_PASCAL: string;
37
+ /** Lowercase alphanumeric form for iOS bundle ids / Android packages. */
38
+ NAME_APPID: string;
39
+ MPO_VERSION: string;
40
+ YEAR: string;
41
+ };
42
+
43
+ function toPascalCase(name: string): string {
44
+ return name
45
+ .split(/[-_\s]+/)
46
+ .filter(Boolean)
47
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
48
+ .join("");
49
+ }
50
+
51
+ function validateName(name: string): string {
52
+ const trimmed = name.trim();
53
+ if (!trimmed) {
54
+ throw new Error("Project name is required");
55
+ }
56
+ if (!/^[a-z][a-z0-9-]*$/.test(trimmed)) {
57
+ throw new Error(
58
+ `Invalid project name "${trimmed}". Use lowercase letters, numbers, and hyphens (e.g. my-app).`,
59
+ );
60
+ }
61
+ return trimmed;
62
+ }
63
+
64
+ function resolveMpoVersion(explicit?: string): string {
65
+ if (explicit) {
66
+ return explicit.startsWith("^") || explicit.startsWith("~") || explicit === "*"
67
+ ? explicit
68
+ : `^${explicit}`;
69
+ }
70
+ try {
71
+ const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
72
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
73
+ if (pkg.version) return `^${pkg.version}`;
74
+ } catch {
75
+ // fall through
76
+ }
77
+ return "^6.1.0";
78
+ }
79
+
80
+ function templatesRoot(template: InitAppTemplate): string {
81
+ return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
82
+ }
83
+
84
+ /**
85
+ * npm always strips .gitignore / .npmrc from published tarballs, so the
86
+ * template stores them without the leading dot; restore it at scaffold time.
87
+ */
88
+ const DOTFILE_NAMES: Record<string, string> = {
89
+ gitignore: ".gitignore",
90
+ npmrc: ".npmrc",
91
+ };
92
+
93
+ function render(content: string, vars: TemplateVars): string {
94
+ return content
95
+ .replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL)
96
+ .replaceAll("__NAME_APPID__", vars.NAME_APPID)
97
+ .replaceAll("__NAME__", vars.NAME)
98
+ .replaceAll("__MPO_VERSION__", vars.MPO_VERSION)
99
+ .replaceAll("__YEAR__", vars.YEAR);
100
+ }
101
+
102
+ /**
103
+ * Assets copied byte-for-byte — running them through the utf-8 render pass
104
+ * would corrupt them (fonts ship in the templates for LC-33 stable mount).
105
+ */
106
+ const BINARY_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".png", ".jpg", ".ico"]);
107
+
108
+ function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
109
+ if (!existsSync(srcDir)) {
110
+ throw new Error(`Project template missing at ${srcDir}`);
111
+ }
112
+ mkdirSync(destDir, { recursive: true });
113
+ for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
114
+ const from = join(srcDir, entry.name);
115
+ // Template files may use __NAME__ in path segments.
116
+ const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
117
+ const to = join(destDir, toName);
118
+ if (entry.isDirectory()) {
119
+ writeTree(from, to, vars);
120
+ continue;
121
+ }
122
+ if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
123
+ copyFileSync(from, to);
124
+ continue;
125
+ }
126
+ const raw = readFileSync(from, "utf-8");
127
+ writeFileSync(to, render(raw, vars));
128
+ }
129
+ }
130
+
131
+ async function resolveTemplate(options: InitAppOptions): Promise<InitAppTemplate> {
132
+ if (options.template) return options.template;
133
+ if (options.yes || !process.stdout.isTTY) return "universal";
134
+ const result = await inquirer.prompt([
135
+ {
136
+ message: "Which platforms should this project target?",
137
+ name: "template",
138
+ type: "list",
139
+ choices: [
140
+ { name: "Universal — web + iOS + Android (one)", value: "universal" },
141
+ { name: "Web only — Vite + React", value: "app" },
142
+ ],
143
+ default: "universal",
144
+ },
145
+ ]);
146
+ return result.template as InitAppTemplate;
147
+ }
148
+
149
+ /**
150
+ * Scaffold a consumer project (universal by default, web-only with
151
+ * template: "app") that depends on @multiplatform.one/* at semver ranges
152
+ * (never workspace:*). This is NOT a monorepo fork.
153
+ */
154
+ export async function initApp(
155
+ nameArg: string | undefined,
156
+ options: InitAppOptions = {},
157
+ ): Promise<void> {
158
+ let projectName = nameArg;
159
+ if (!projectName) {
160
+ if (options.yes || !process.stdout.isTTY) {
161
+ projectName = "my-app";
162
+ } else {
163
+ const result = await inquirer.prompt([
164
+ {
165
+ message: "What is the app name?",
166
+ name: "name",
167
+ type: "input",
168
+ default: "my-app",
169
+ },
170
+ ]);
171
+ projectName = result.name as string;
172
+ }
173
+ }
174
+
175
+ const name = validateName(projectName);
176
+ const template = await resolveTemplate(options);
177
+ const targetDir = resolve(name);
178
+
179
+ if (existsSync(targetDir)) {
180
+ const entries = readdirSync(targetDir);
181
+ if (entries.length > 0) {
182
+ throw new Error(`Directory "${targetDir}" already exists and is not empty`);
183
+ }
184
+ }
185
+
186
+ const vars: TemplateVars = {
187
+ NAME: name,
188
+ NAME_PASCAL: toPascalCase(name),
189
+ NAME_APPID: name.replaceAll("-", ""),
190
+ MPO_VERSION: resolveMpoVersion(options.version),
191
+ YEAR: String(new Date().getFullYear()),
192
+ };
193
+
194
+ const label = template === "universal" ? "universal (web + iOS + Android)" : "web-only";
195
+ console.log(`\nScaffolding ${label} project at ${targetDir}`);
196
+ console.log(` apps/${name}`);
197
+ console.log(` features/${name}`);
198
+ console.log(` packages/config`);
199
+ if (template === "universal") {
200
+ console.log(` packages/i18n`);
201
+ console.log(` packages/themes`);
202
+ }
203
+ console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
204
+
205
+ writeTree(templatesRoot(template), targetDir, vars);
206
+
207
+ if (!options.skipInstall) {
208
+ console.log("Installing dependencies...");
209
+ await spawn("pnpm", ["install"], {
210
+ cwd: targetDir,
211
+ stdio: "inherit",
212
+ });
213
+ } else {
214
+ console.log("Skipped pnpm install (--skip-install).");
215
+ }
216
+
217
+ console.log(`\n✅ Project created at ${targetDir}`);
218
+ console.log("\nNext steps:");
219
+ console.log(` cd ${name}`);
220
+ if (options.skipInstall) console.log(" pnpm install");
221
+ console.log(" pnpm dev # web dev server");
222
+ if (template === "universal") {
223
+ console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
224
+ console.log(" pnpm android # Android");
225
+ }
226
+ console.log("\nThis project consumes published @multiplatform.one/* packages.");
227
+ console.log("It is not a fork of the multiplatform.one monorepo.");
228
+ }
@@ -61,15 +61,36 @@ function launchConfigForApp(app: AppInfo): LaunchConfig[] {
61
61
  const configs: LaunchConfig[] = [];
62
62
  const wf = "${workspaceFolder}";
63
63
 
64
- if (name === "vscode" && scripts.dev) {
64
+ // vscode + webext are delivery targets of the One platform app
65
+ // (apps/<name>/vscode, apps/<name>/webext) — their dev flows hang off the
66
+ // app's dev:vscode / dev:webext scripts, staging into dist-vscode /
67
+ // dist-webext.
68
+ if (scripts["dev:vscode"]) {
65
69
  configs.push({
66
70
  name: "vscode dev",
67
71
  type: "extensionHost",
68
72
  request: "launch",
69
- args: ["--disable-extensions", `--extensionDevelopmentPath=${wf}/${relPath}`],
73
+ args: ["--disable-extensions", `--extensionDevelopmentPath=${wf}/${relPath}/dist-vscode`],
70
74
  cwd: `${wf}/${relPath}`,
71
- outFiles: [`${wf}/${relPath}/dist/extension/*.js`],
72
- preLaunchTask: "npm: dev",
75
+ outFiles: [`${wf}/${relPath}/dist-vscode/extension/*.js`],
76
+ preLaunchTask: "npm: dev:vscode",
77
+ });
78
+ configs.push({
79
+ name: "vscode dev",
80
+ type: "node-terminal",
81
+ request: "launch",
82
+ cwd: `${wf}/${relPath}`,
83
+ command: "pnpm dev:vscode",
84
+ });
85
+ }
86
+
87
+ if (scripts["dev:webext"]) {
88
+ configs.push({
89
+ name: "webext dev",
90
+ type: "node-terminal",
91
+ request: "launch",
92
+ cwd: `${wf}/${relPath}`,
93
+ command: "pnpm dev:webext",
73
94
  });
74
95
  }
75
96
 
@@ -159,8 +180,9 @@ interface TasksJson {
159
180
  }
160
181
 
161
182
  async function generateTasksJson(apps: AppInfo[]): Promise<TasksJson | null> {
162
- const vscodeApp = apps.find((a) => a.name === "vscode");
163
- if (!vscodeApp?.scripts?.dev) {
183
+ // The vscode target lives inside the platform app (dev:vscode script).
184
+ const hostApp = apps.find((a) => a.scripts?.["dev:vscode"]);
185
+ if (!hostApp) {
164
186
  return null;
165
187
  }
166
188
 
@@ -169,7 +191,7 @@ async function generateTasksJson(apps: AppInfo[]): Promise<TasksJson | null> {
169
191
  tasks: [
170
192
  {
171
193
  type: "npm",
172
- script: "dev",
194
+ script: "dev:vscode",
173
195
  problemMatcher: {
174
196
  owner: "typescript",
175
197
  fileLocation: "relative",
@@ -190,7 +212,7 @@ async function generateTasksJson(apps: AppInfo[]): Promise<TasksJson | null> {
190
212
  isBackground: true,
191
213
  presentation: { reveal: "never" },
192
214
  group: { kind: "build", isDefault: true },
193
- options: { cwd: "${workspaceFolder}/apps/vscode" },
215
+ options: { cwd: `\${workspaceFolder}/${hostApp.path}` },
194
216
  },
195
217
  ],
196
218
  };
@@ -211,6 +233,6 @@ export async function generateVscodeConfig(root: string): Promise<void> {
211
233
  await fs.writeFile(path.join(vsCodeDir, "tasks.json"), JSON.stringify(tasks, null, 2), "utf8");
212
234
  console.log("Wrote .vscode/tasks.json");
213
235
  } else {
214
- console.log("Skipped .vscode/tasks.json (no apps/vscode with dev script)");
236
+ console.log("Skipped .vscode/tasks.json (no app with a dev:vscode script)");
215
237
  }
216
238
  }