@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.
- package/README.md +72 -0
- package/bin/createMultiplatformApp.mjs +77 -0
- package/lib/bin/multiplatformOne.mjs +23 -9
- package/lib/commands/e2e.mjs +3 -3
- package/lib/commands/init.mjs +16 -12
- package/lib/commands/initApp.mjs +145 -0
- package/lib/generateVscode.mjs +27 -10
- package/package.json +23 -12
- package/src/bin/multiplatformOne.ts +54 -16
- package/src/commands/init.ts +7 -18
- package/src/commands/initApp.spec.ts +210 -0
- package/src/commands/initApp.ts +228 -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 +25 -0
- package/types/commands/initApp.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
package/src/commands/init.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
+
}
|
package/src/generateVscode.ts
CHANGED
|
@@ -61,15 +61,36 @@ function launchConfigForApp(app: AppInfo): LaunchConfig[] {
|
|
|
61
61
|
const configs: LaunchConfig[] = [];
|
|
62
62
|
const wf = "${workspaceFolder}";
|
|
63
63
|
|
|
64
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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:
|
|
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
|
|
236
|
+
console.log("Skipped .vscode/tasks.json (no app with a dev:vscode script)");
|
|
215
237
|
}
|
|
216
238
|
}
|