@multiplatform.one/cli 6.1.0 → 6.4.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.
- package/README.md +72 -0
- package/bin/createMultiplatformApp.mjs +77 -0
- package/lib/bin/multiplatformOne.mjs +35 -10
- package/lib/commands/e2e.mjs +3 -3
- package/lib/commands/init.mjs +16 -12
- package/lib/commands/initApp.mjs +175 -0
- package/lib/commands/updateApp.mjs +260 -0
- package/lib/generateVscode.mjs +27 -10
- package/package.json +23 -12
- package/src/bin/multiplatformOne.ts +83 -20
- package/src/commands/init.ts +7 -18
- package/src/commands/initApp.spec.ts +210 -0
- package/src/commands/initApp.ts +274 -0
- package/src/commands/updateApp.ts +277 -0
- package/src/generateVscode.ts +31 -9
- package/templates/app/AGENTS.md +142 -0
- package/templates/app/README.md +62 -0
- package/templates/app/apps/__NAME__/index.html +36 -0
- package/templates/app/apps/__NAME__/package.json +30 -0
- package/templates/app/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/app/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/app/apps/__NAME__/src/App.tsx +9 -0
- package/templates/app/apps/__NAME__/src/main.tsx +14 -0
- package/templates/app/apps/__NAME__/tsconfig.json +8 -0
- package/templates/app/apps/__NAME__/vite.config.ts +17 -0
- package/templates/app/features/__NAME__/package.json +34 -0
- package/templates/app/features/__NAME__/src/HomeScreen.tsx +70 -0
- package/templates/app/features/__NAME__/src/index.ts +1 -0
- package/templates/app/features/__NAME__/tsconfig.json +5 -0
- package/templates/app/gitignore +8 -0
- package/templates/app/npmrc +2 -0
- package/templates/app/oxlintrc.json +39 -0
- package/templates/app/package.json +20 -0
- package/templates/app/packages/config/config.json +4 -0
- package/templates/app/packages/config/index.ts +5 -0
- package/templates/app/packages/config/package.json +17 -0
- package/templates/app/packages/config/tsconfig.json +5 -0
- package/templates/app/pnpm-workspace.yaml +4 -0
- package/templates/app/tsconfig.base.json +16 -0
- package/templates/universal/AGENTS.md +142 -0
- package/templates/universal/README.md +97 -0
- package/templates/universal/apps/__NAME__/app.config.js +31 -0
- package/templates/universal/apps/__NAME__/config/tamagui.config.ts +4 -0
- package/templates/universal/apps/__NAME__/package.json +61 -0
- package/templates/universal/apps/__NAME__/polyfills.native.ts +23 -0
- package/templates/universal/apps/__NAME__/polyfills.ts +3 -0
- package/templates/universal/apps/__NAME__/public/fonts/inter-400.woff2 +0 -0
- package/templates/universal/apps/__NAME__/public/fonts/inter-700.woff2 +0 -0
- package/templates/universal/apps/__NAME__/routes/_layout.tsx +22 -0
- package/templates/universal/apps/__NAME__/routes/_layout.web.tsx +21 -0
- package/templates/universal/apps/__NAME__/routes/about.tsx +3 -0
- package/templates/universal/apps/__NAME__/routes/index.tsx +3 -0
- package/templates/universal/apps/__NAME__/src/app.ts +10 -0
- package/templates/universal/apps/__NAME__/tamagui.css +3 -0
- package/templates/universal/apps/__NAME__/tsconfig.json +8 -0
- package/templates/universal/apps/__NAME__/types.d.ts +9 -0
- package/templates/universal/apps/__NAME__/vite.config.ts +66 -0
- package/templates/universal/features/__NAME__/package.json +35 -0
- package/templates/universal/features/__NAME__/src/AboutScreen.tsx +27 -0
- package/templates/universal/features/__NAME__/src/AppLayout.tsx +50 -0
- package/templates/universal/features/__NAME__/src/ContactForm.spec.tsx +22 -0
- package/templates/universal/features/__NAME__/src/ContactForm.tsx +55 -0
- package/templates/universal/features/__NAME__/src/HomeScreen.tsx +40 -0
- package/templates/universal/features/__NAME__/src/index.ts +4 -0
- package/templates/universal/features/__NAME__/src/screens.spec.ts +12 -0
- package/templates/universal/features/__NAME__/tsconfig.json +5 -0
- package/templates/universal/features/__NAME__/vitest.config.mjs +7 -0
- package/templates/universal/gitignore +12 -0
- package/templates/universal/npmrc +3 -0
- package/templates/universal/oxlintrc.json +39 -0
- package/templates/universal/package.json +27 -0
- package/templates/universal/packages/config/config.json +4 -0
- package/templates/universal/packages/config/index.ts +5 -0
- package/templates/universal/packages/config/package.json +17 -0
- package/templates/universal/packages/config/tsconfig.json +5 -0
- package/templates/universal/packages/i18n/en/common.json +30 -0
- package/templates/universal/packages/i18n/es/common.json +30 -0
- package/templates/universal/packages/i18n/i18n.d.ts +16 -0
- package/templates/universal/packages/i18n/index.ts +26 -0
- package/templates/universal/packages/i18n/package.json +20 -0
- package/templates/universal/packages/i18n/resources.ts +13 -0
- package/templates/universal/packages/i18n/tsconfig.json +5 -0
- package/templates/universal/packages/themes/accent.ts +32 -0
- package/templates/universal/packages/themes/base.ts +50 -0
- package/templates/universal/packages/themes/fonts.ts +8 -0
- package/templates/universal/packages/themes/index.ts +33 -0
- package/templates/universal/packages/themes/package.json +23 -0
- package/templates/universal/packages/themes/tamagui.config.ts +14 -0
- package/templates/universal/packages/themes/tsconfig.json +5 -0
- package/templates/universal/pnpm-workspace.yaml +4 -0
- package/templates/universal/tsconfig.base.json +21 -0
- package/templates/universal/types/env.d.ts +9 -0
- package/types/bin/multiplatformOne.d.ts +11 -0
- package/types/bin/multiplatformOne.d.ts.map +1 -0
- package/types/commands/e2e.d.ts +14 -0
- package/types/commands/e2e.d.ts.map +1 -0
- package/types/commands/init.d.ts +16 -0
- package/types/commands/init.d.ts.map +1 -0
- package/types/commands/initApp.d.ts +41 -0
- package/types/commands/initApp.d.ts.map +1 -0
- package/types/commands/updateApp.d.ts +24 -0
- package/types/commands/updateApp.d.ts.map +1 -0
- package/types/generateVscode.d.ts +7 -0
- package/types/generateVscode.d.ts.map +1 -0
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -0
- package/types/types.d.ts +6 -0
- package/types/types.d.ts.map +1 -0
|
@@ -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,274 @@
|
|
|
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
|
+
const version = cliVersion();
|
|
71
|
+
return version ? `^${version}` : "^6.1.0";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function cliVersion(): string | undefined {
|
|
75
|
+
try {
|
|
76
|
+
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
|
|
77
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
|
|
78
|
+
return pkg.version;
|
|
79
|
+
} catch {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Scaffold provenance (the copier-answers equivalent): which template, which
|
|
86
|
+
* CLI version and which vars produced this project. `mpo update` regenerates
|
|
87
|
+
* the ORIGINAL scaffold from this record to use as the merge base for a
|
|
88
|
+
* three-way template update.
|
|
89
|
+
*/
|
|
90
|
+
export interface MpoProvenance {
|
|
91
|
+
template: InitAppTemplate;
|
|
92
|
+
cliVersion: string;
|
|
93
|
+
name: string;
|
|
94
|
+
mpoVersion: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const PROVENANCE_FILE = ".mpo.json";
|
|
98
|
+
|
|
99
|
+
export function writeProvenance(targetDir: string, provenance: MpoProvenance): void {
|
|
100
|
+
writeFileSync(join(targetDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function readProvenance(projectDir: string): MpoProvenance | undefined {
|
|
104
|
+
const file = join(projectDir, PROVENANCE_FILE);
|
|
105
|
+
if (!existsSync(file)) return undefined;
|
|
106
|
+
try {
|
|
107
|
+
const raw = JSON.parse(readFileSync(file, "utf-8")) as Partial<MpoProvenance>;
|
|
108
|
+
if (!raw.template || !raw.cliVersion || !raw.name) return undefined;
|
|
109
|
+
return {
|
|
110
|
+
template: raw.template,
|
|
111
|
+
cliVersion: raw.cliVersion,
|
|
112
|
+
name: raw.name,
|
|
113
|
+
mpoVersion: raw.mpoVersion ?? `^${raw.cliVersion}`,
|
|
114
|
+
};
|
|
115
|
+
} catch {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function templatesRoot(template: InitAppTemplate): string {
|
|
121
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "../../templates", template);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* npm always strips .gitignore / .npmrc from published tarballs, so the
|
|
126
|
+
* template stores them without the leading dot; restore it at scaffold time.
|
|
127
|
+
*/
|
|
128
|
+
const DOTFILE_NAMES: Record<string, string> = {
|
|
129
|
+
gitignore: ".gitignore",
|
|
130
|
+
npmrc: ".npmrc",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function render(content: string, vars: TemplateVars): string {
|
|
134
|
+
return content
|
|
135
|
+
.replaceAll("__NAME_PASCAL__", vars.NAME_PASCAL)
|
|
136
|
+
.replaceAll("__NAME_APPID__", vars.NAME_APPID)
|
|
137
|
+
.replaceAll("__NAME__", vars.NAME)
|
|
138
|
+
.replaceAll("__MPO_VERSION__", vars.MPO_VERSION)
|
|
139
|
+
.replaceAll("__YEAR__", vars.YEAR);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Assets copied byte-for-byte — running them through the utf-8 render pass
|
|
144
|
+
* would corrupt them (fonts ship in the templates for LC-33 stable mount).
|
|
145
|
+
*/
|
|
146
|
+
const BINARY_EXTENSIONS = new Set([".woff2", ".woff", ".ttf", ".otf", ".png", ".jpg", ".ico"]);
|
|
147
|
+
|
|
148
|
+
function writeTree(srcDir: string, destDir: string, vars: TemplateVars): void {
|
|
149
|
+
if (!existsSync(srcDir)) {
|
|
150
|
+
throw new Error(`Project template missing at ${srcDir}`);
|
|
151
|
+
}
|
|
152
|
+
mkdirSync(destDir, { recursive: true });
|
|
153
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
154
|
+
const from = join(srcDir, entry.name);
|
|
155
|
+
// Template files may use __NAME__ in path segments.
|
|
156
|
+
const toName = render(DOTFILE_NAMES[entry.name] ?? entry.name, vars);
|
|
157
|
+
const to = join(destDir, toName);
|
|
158
|
+
if (entry.isDirectory()) {
|
|
159
|
+
writeTree(from, to, vars);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (BINARY_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
|
|
163
|
+
copyFileSync(from, to);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const raw = readFileSync(from, "utf-8");
|
|
167
|
+
writeFileSync(to, render(raw, vars));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function resolveTemplate(options: InitAppOptions): Promise<InitAppTemplate> {
|
|
172
|
+
if (options.template) return options.template;
|
|
173
|
+
if (options.yes || !process.stdout.isTTY) return "universal";
|
|
174
|
+
const result = await inquirer.prompt([
|
|
175
|
+
{
|
|
176
|
+
message: "Which platforms should this project target?",
|
|
177
|
+
name: "template",
|
|
178
|
+
type: "list",
|
|
179
|
+
choices: [
|
|
180
|
+
{ name: "Universal — web + iOS + Android (one)", value: "universal" },
|
|
181
|
+
{ name: "Web only — Vite + React", value: "app" },
|
|
182
|
+
],
|
|
183
|
+
default: "universal",
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
return result.template as InitAppTemplate;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Scaffold a consumer project (universal by default, web-only with
|
|
191
|
+
* template: "app") that depends on @multiplatform.one/* at semver ranges
|
|
192
|
+
* (never workspace:*). This is NOT a monorepo fork.
|
|
193
|
+
*/
|
|
194
|
+
export async function initApp(
|
|
195
|
+
nameArg: string | undefined,
|
|
196
|
+
options: InitAppOptions = {},
|
|
197
|
+
): Promise<void> {
|
|
198
|
+
let projectName = nameArg;
|
|
199
|
+
if (!projectName) {
|
|
200
|
+
if (options.yes || !process.stdout.isTTY) {
|
|
201
|
+
projectName = "my-app";
|
|
202
|
+
} else {
|
|
203
|
+
const result = await inquirer.prompt([
|
|
204
|
+
{
|
|
205
|
+
message: "What is the app name?",
|
|
206
|
+
name: "name",
|
|
207
|
+
type: "input",
|
|
208
|
+
default: "my-app",
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
projectName = result.name as string;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const name = validateName(projectName);
|
|
216
|
+
const template = await resolveTemplate(options);
|
|
217
|
+
const targetDir = resolve(name);
|
|
218
|
+
|
|
219
|
+
if (existsSync(targetDir)) {
|
|
220
|
+
const entries = readdirSync(targetDir);
|
|
221
|
+
if (entries.length > 0) {
|
|
222
|
+
throw new Error(`Directory "${targetDir}" already exists and is not empty`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const vars: TemplateVars = {
|
|
227
|
+
NAME: name,
|
|
228
|
+
NAME_PASCAL: toPascalCase(name),
|
|
229
|
+
NAME_APPID: name.replaceAll("-", ""),
|
|
230
|
+
MPO_VERSION: resolveMpoVersion(options.version),
|
|
231
|
+
YEAR: String(new Date().getFullYear()),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const label = template === "universal" ? "universal (web + iOS + Android)" : "web-only";
|
|
235
|
+
console.log(`\nScaffolding ${label} project at ${targetDir}`);
|
|
236
|
+
console.log(` apps/${name}`);
|
|
237
|
+
console.log(` features/${name}`);
|
|
238
|
+
console.log(` packages/config`);
|
|
239
|
+
if (template === "universal") {
|
|
240
|
+
console.log(` packages/i18n`);
|
|
241
|
+
console.log(` packages/themes`);
|
|
242
|
+
}
|
|
243
|
+
console.log(` @multiplatform.one/* → ${vars.MPO_VERSION}\n`);
|
|
244
|
+
|
|
245
|
+
writeTree(templatesRoot(template), targetDir, vars);
|
|
246
|
+
writeProvenance(targetDir, {
|
|
247
|
+
template,
|
|
248
|
+
cliVersion: cliVersion() ?? "0.0.0",
|
|
249
|
+
name,
|
|
250
|
+
mpoVersion: vars.MPO_VERSION,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!options.skipInstall) {
|
|
254
|
+
console.log("Installing dependencies...");
|
|
255
|
+
await spawn("pnpm", ["install"], {
|
|
256
|
+
cwd: targetDir,
|
|
257
|
+
stdio: "inherit",
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
console.log("Skipped pnpm install (--skip-install).");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
console.log(`\n✅ Project created at ${targetDir}`);
|
|
264
|
+
console.log("\nNext steps:");
|
|
265
|
+
console.log(` cd ${name}`);
|
|
266
|
+
if (options.skipInstall) console.log(" pnpm install");
|
|
267
|
+
console.log(" pnpm dev # web dev server");
|
|
268
|
+
if (template === "universal") {
|
|
269
|
+
console.log(" pnpm ios # iOS (requires pnpm dev in another terminal)");
|
|
270
|
+
console.log(" pnpm android # Android");
|
|
271
|
+
}
|
|
272
|
+
console.log("\nThis project consumes published @multiplatform.one/* packages.");
|
|
273
|
+
console.log("It is not a fork of the multiplatform.one monorepo.");
|
|
274
|
+
}
|