create-octane-xplat 0.7.3 → 0.10.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 +28 -8
- package/index.mjs +112 -14
- package/package.json +15 -8
- package/scaffold.mjs +531 -0
- package/template/.agents/skills/xplat/SKILL.md +109 -40
- package/template/.agents/skills/xplat/references/components.md +149 -96
- package/template/.agents/skills/xplat/references/navigation.md +81 -31
- package/template/.agents/skills/xplat/references/platform.md +74 -35
- package/template/.agents/skills/xplat/references/styling.md +80 -27
- package/template/AGENTS.md +1 -1
- package/template/package.json +11 -9
- package/template/pnpm-workspace.yaml +66 -33
- package/template/src/App.tsrx +17 -8
- package/template/src/process.d.ts +7 -0
- package/template/src/style.css +24 -6
- package/template/tsconfig.json +1 -1
- package/template/vite.config.native.mts +3 -2
- package/template/vite.config.ts +5 -3
- package/template/App_Resources/iOS/src/OctaneShaders.metal +0 -128
- package/template/App_Resources/iOS/src/ShaderEffectRegistration.swift +0 -31
- package/template/patches/@nativescript-community__octane@0.2.1.patch +0 -16
- package/template/patches/@nativescript-community__vite-octane@0.2.1.patch +0 -41
- package/template/patches/@nativescript__core@9.1.2.patch +0 -794
- package/template/patches/@nativescript__core@9.1.3-next.2.patch +0 -428
- package/template/patches/@nativescript__vite@8.0.11.patch +0 -569
- package/template/patches/@nativescript__vite@8.0.13.patch +0 -233
- package/template/patches/@tsrx__typescript-plugin@0.4.11.patch +0 -11
- package/template/patches/css@3.0.0.patch +0 -52
- package/template/patches/octane@0.6.3.patch +0 -1149
- /package/template/src/{index.ts → main.ts} +0 -0
package/scaffold.mjs
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
// Scaffold manifest + compose/apply shared by create-octane-xplat and the
|
|
2
|
+
// CLI's `xplat add`. template/package.json is the canonical store for dep
|
|
3
|
+
// versions and script bodies — targets name which keys they own; unclaimed
|
|
4
|
+
// files, deps, and scripts are part of the base scaffold. Keeping versions in
|
|
5
|
+
// template/package.json means scripts/bump-versions.mjs keeps working
|
|
6
|
+
// unchanged at release time.
|
|
7
|
+
import {
|
|
8
|
+
cpSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
statSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
16
|
+
|
|
17
|
+
import { dirname, join } from 'node:path'
|
|
18
|
+
import { fileURLToPath } from 'node:url'
|
|
19
|
+
|
|
20
|
+
export const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'template')
|
|
21
|
+
|
|
22
|
+
/** Scaffoldable targets. `files` entries are template-relative paths —
|
|
23
|
+
* directories copy recursively. `deps`/`devDeps`/`scripts` name keys whose
|
|
24
|
+
* values come from template/package.json. `typecheck` contributes one clause
|
|
25
|
+
* to the generated typecheck script. */
|
|
26
|
+
export const targets = {
|
|
27
|
+
web: {
|
|
28
|
+
label: 'Web (Vite)',
|
|
29
|
+
files: ['index.html', 'vite.config.ts', 'src/main.web.tsrx'],
|
|
30
|
+
scripts: ['dev', 'build'],
|
|
31
|
+
devDeps: ['@octanejs/vite-plugin'],
|
|
32
|
+
},
|
|
33
|
+
ios: {
|
|
34
|
+
label: 'iOS (NativeScript)',
|
|
35
|
+
requires: ['native-shared'],
|
|
36
|
+
files: ['App_Resources/iOS'],
|
|
37
|
+
scripts: ['dev:ios', 'build:ios'],
|
|
38
|
+
devDeps: ['@nativescript/ios'],
|
|
39
|
+
note: 'needs macOS + Xcode — `pnpm xplat doctor` checks the toolchain',
|
|
40
|
+
},
|
|
41
|
+
android: {
|
|
42
|
+
label: 'Android (NativeScript)',
|
|
43
|
+
requires: ['native-shared'],
|
|
44
|
+
files: ['App_Resources/Android'],
|
|
45
|
+
scripts: ['dev:android', 'build:android'],
|
|
46
|
+
devDeps: ['@nativescript/android'],
|
|
47
|
+
note: 'needs Android SDK + a compatible JDK — `pnpm xplat doctor` checks the toolchain',
|
|
48
|
+
},
|
|
49
|
+
// Shared NativeScript machinery pulled in by ios/android. Not user-
|
|
50
|
+
// selectable on its own. `patches` names the packages whose
|
|
51
|
+
// patchedDependencies entries in pnpm-workspace.yaml belong to this
|
|
52
|
+
// fragment — pnpm hard-fails installs on unused patch entries, so a
|
|
53
|
+
// scaffold must not carry patches for deps it doesn't install.
|
|
54
|
+
'native-shared': {
|
|
55
|
+
hidden: true,
|
|
56
|
+
patches: [
|
|
57
|
+
'@nativescript/core',
|
|
58
|
+
'@nativescript/types',
|
|
59
|
+
'css',
|
|
60
|
+
'@nativescript/vite',
|
|
61
|
+
'@nativescript-community/octane',
|
|
62
|
+
'@nativescript-community/vite-octane',
|
|
63
|
+
],
|
|
64
|
+
files: [
|
|
65
|
+
'nativescript.config.ts',
|
|
66
|
+
'vite.config.native.mts',
|
|
67
|
+
'tsconfig.native.json',
|
|
68
|
+
'references.d.ts',
|
|
69
|
+
'src/main.ts',
|
|
70
|
+
],
|
|
71
|
+
deps: [
|
|
72
|
+
'@nativescript-community/gesturehandler',
|
|
73
|
+
'@nativescript-community/octane',
|
|
74
|
+
'@nativescript-community/ui-drawer',
|
|
75
|
+
'@nativescript/core',
|
|
76
|
+
'@octane-xplat/effects',
|
|
77
|
+
'@valor/nativescript-websockets',
|
|
78
|
+
],
|
|
79
|
+
devDeps: [
|
|
80
|
+
'@nativescript-community/vite-octane',
|
|
81
|
+
'@nativescript/types',
|
|
82
|
+
'@nativescript/vite',
|
|
83
|
+
'nativescript',
|
|
84
|
+
],
|
|
85
|
+
typecheck: 'tsrx-tsc --noEmit -p tsconfig.native.json',
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const selectableTargets = Object.keys(targets).filter((id) => !targets[id].hidden)
|
|
90
|
+
|
|
91
|
+
/** `pnpm create` without --targets and `xplat add`'s prompt default. */
|
|
92
|
+
export const defaultTargets = ['web', 'ios', 'android']
|
|
93
|
+
|
|
94
|
+
const BASE_TYPECHECK = 'tsrx-tsc --noEmit'
|
|
95
|
+
|
|
96
|
+
// package.json keys every scaffolded app keeps regardless of target set —
|
|
97
|
+
// the targets' `scripts` lists own the rest.
|
|
98
|
+
const BASE_SCRIPTS = ['xplat', 'lint', 'lint:fix']
|
|
99
|
+
|
|
100
|
+
/** Expand `requires` edges, dependencies first. Throws on unknown ids. */
|
|
101
|
+
export function resolveTargets(ids) {
|
|
102
|
+
const resolved = []
|
|
103
|
+
const visit = (id) => {
|
|
104
|
+
const t = targets[id]
|
|
105
|
+
if (!t) {
|
|
106
|
+
throw new Error(`unknown target "${id}" — expected one of: ${selectableTargets.join(', ')}`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const req of t.requires ?? []) {
|
|
110
|
+
visit(req)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!resolved.includes(id)) {
|
|
114
|
+
resolved.push(id)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
visit(id)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return resolved
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const templateManifest = () => JSON.parse(readFileSync(join(TEMPLATE_DIR, 'package.json'), 'utf8'))
|
|
126
|
+
|
|
127
|
+
/** Which target claims a template-relative path (entries may be dirs). */
|
|
128
|
+
function ownerOf(path) {
|
|
129
|
+
for (const [id, t] of Object.entries(targets)) {
|
|
130
|
+
for (const entry of t.files) {
|
|
131
|
+
if (path === entry || path.startsWith(entry + '/')) {
|
|
132
|
+
return id
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return null
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function walk(dir, prefix = '') {
|
|
141
|
+
const out = []
|
|
142
|
+
for (const name of readdirSync(dir)) {
|
|
143
|
+
if (name === 'node_modules') {
|
|
144
|
+
continue
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const rel = prefix ? `${prefix}/${name}` : name
|
|
148
|
+
if (statSync(join(dir, name)).isDirectory()) {
|
|
149
|
+
out.push(...walk(join(dir, name), rel))
|
|
150
|
+
} else {
|
|
151
|
+
out.push(rel)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function pickKeys(source, names, label) {
|
|
159
|
+
const out = {}
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
if (source[name] === undefined) {
|
|
162
|
+
throw new Error(`template/package.json has no ${label} entry "${name}"`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
out[name] = source[name]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The package.json a given target set produces. With every target selected
|
|
173
|
+
* this is identical to template/package.json.
|
|
174
|
+
*/
|
|
175
|
+
export function composeManifest(ids) {
|
|
176
|
+
const resolved = resolveTargets(ids)
|
|
177
|
+
const tpl = templateManifest()
|
|
178
|
+
const manifest = { ...tpl }
|
|
179
|
+
|
|
180
|
+
for (const section of ['dependencies', 'devDependencies']) {
|
|
181
|
+
const key = section === 'dependencies' ? 'deps' : 'devDeps'
|
|
182
|
+
const wanted = new Set()
|
|
183
|
+
for (const id of resolved) {
|
|
184
|
+
for (const name of targets[id][key] ?? []) {
|
|
185
|
+
wanted.add(name)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const next = {}
|
|
190
|
+
for (const [name, spec] of Object.entries(tpl[section] ?? {})) {
|
|
191
|
+
// Unclaimed entries are base; claimed entries follow their target.
|
|
192
|
+
const claimed = Object.keys(targets).some((id) => (targets[id][key] ?? []).includes(name))
|
|
193
|
+
|
|
194
|
+
if (!claimed || wanted.has(name)) {
|
|
195
|
+
next[name] = spec
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
manifest[section] = next
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const wantedScripts = new Set(BASE_SCRIPTS)
|
|
203
|
+
for (const id of resolved) {
|
|
204
|
+
for (const name of targets[id].scripts ?? []) {
|
|
205
|
+
wantedScripts.add(name)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const scripts = pickKeys(tpl.scripts ?? {}, [...wantedScripts].sort(), 'script')
|
|
210
|
+
const typecheck = [BASE_TYPECHECK]
|
|
211
|
+
for (const id of resolved) {
|
|
212
|
+
if (targets[id].typecheck) {
|
|
213
|
+
typecheck.push(targets[id].typecheck)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
scripts.typecheck = typecheck.join(' && ')
|
|
218
|
+
// Keep the template's key order: scripts sit where they were.
|
|
219
|
+
manifest.scripts = {}
|
|
220
|
+
for (const name of Object.keys(tpl.scripts ?? {})) {
|
|
221
|
+
if (name === 'typecheck' || wantedScripts.has(name)) {
|
|
222
|
+
manifest.scripts[name] = scripts[name]
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (!resolved.includes('native-shared')) {
|
|
227
|
+
// "main" points at the native entry — meaningless without it.
|
|
228
|
+
delete manifest.main
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return manifest
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- pnpm-workspace.yaml patchedDependencies --------------------------------
|
|
235
|
+
// Patch entries look like ` octane@0.6.3: <path>` or
|
|
236
|
+
// ` "@nativescript/core@9.1.2": <path>`, each preceded by a comment block.
|
|
237
|
+
// Ownership comes from each target's `patches` list; unclaimed entries are
|
|
238
|
+
// base and ship in every scaffold.
|
|
239
|
+
const PATCH_ENTRY = /^\s+"?(@?[\w./-]+@[\w.-]+)"?:\s+\S+\s*$/
|
|
240
|
+
|
|
241
|
+
const patchPkg = (key) => key.replace(/@[^@]+$/, '')
|
|
242
|
+
|
|
243
|
+
// Split a pnpm-workspace.yaml into per-package patch blocks (leading comment
|
|
244
|
+
// lines included) so compose can drop entries and apply can transplant them.
|
|
245
|
+
function parsePatchEntries(lines) {
|
|
246
|
+
const entries = []
|
|
247
|
+
let inPatches = false
|
|
248
|
+
let pending = []
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
if (/^\S/.test(line)) {
|
|
251
|
+
inPatches = line.startsWith('patchedDependencies')
|
|
252
|
+
pending = []
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!inPatches) {
|
|
257
|
+
continue
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const m = line.match(PATCH_ENTRY)
|
|
261
|
+
if (m) {
|
|
262
|
+
entries.push({ name: patchPkg(m[1]), comments: pending, line })
|
|
263
|
+
pending = []
|
|
264
|
+
} else {
|
|
265
|
+
pending.push(line)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return entries
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The pnpm-workspace.yaml a given target set produces. */
|
|
273
|
+
export function composeWorkspaceYaml(ids) {
|
|
274
|
+
const resolved = new Set(resolveTargets(ids))
|
|
275
|
+
const drop = new Set()
|
|
276
|
+
for (const [id, t] of Object.entries(targets)) {
|
|
277
|
+
if (!resolved.has(id)) {
|
|
278
|
+
for (const name of t.patches ?? []) {
|
|
279
|
+
drop.add(name)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const lines = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8').split('\n')
|
|
285
|
+
const out = []
|
|
286
|
+
let inPatches = false
|
|
287
|
+
let pending = []
|
|
288
|
+
for (const line of lines) {
|
|
289
|
+
if (/^\S/.test(line)) {
|
|
290
|
+
out.push(...pending, line)
|
|
291
|
+
pending = []
|
|
292
|
+
inPatches = line.startsWith('patchedDependencies')
|
|
293
|
+
continue
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!inPatches) {
|
|
297
|
+
out.push(line)
|
|
298
|
+
continue
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const m = line.match(PATCH_ENTRY)
|
|
302
|
+
if (!m) {
|
|
303
|
+
pending.push(line) // comment/blank inside the section
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (drop.has(patchPkg(m[1]))) {
|
|
308
|
+
pending = []
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
out.push(...pending, line)
|
|
313
|
+
pending = []
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
out.push(...pending)
|
|
317
|
+
return out.join('\n')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Add the patch entries that `enabledIds` owns to the app's
|
|
322
|
+
* pnpm-workspace.yaml. Entries already present (by package name, any
|
|
323
|
+
* version) are kept. Returns true if the file changed.
|
|
324
|
+
*/
|
|
325
|
+
function ensurePatchEntries(appRoot, enabledIds) {
|
|
326
|
+
const needed = new Set()
|
|
327
|
+
for (const id of enabledIds) {
|
|
328
|
+
for (const name of targets[id].patches ?? []) {
|
|
329
|
+
needed.add(name)
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (!needed.size) {
|
|
334
|
+
return false
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const yamlPath = join(appRoot, 'pnpm-workspace.yaml')
|
|
338
|
+
const appText = existsSync(yamlPath) ? readFileSync(yamlPath, 'utf8') : ''
|
|
339
|
+
const present = new Set(parsePatchEntries(appText.split('\n')).map((e) => e.name))
|
|
340
|
+
const missing = [...needed].filter((name) => !present.has(name))
|
|
341
|
+
if (!missing.length) {
|
|
342
|
+
return false
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const tplText = readFileSync(join(TEMPLATE_DIR, 'pnpm-workspace.yaml'), 'utf8')
|
|
346
|
+
const blocks = parsePatchEntries(tplText.split('\n'))
|
|
347
|
+
.filter((e) => missing.includes(e.name))
|
|
348
|
+
.map((e) => [...e.comments, e.line].join('\n'))
|
|
349
|
+
|
|
350
|
+
if (!appText.trim()) {
|
|
351
|
+
writeFileSync(yamlPath, composeWorkspaceYaml(enabledIds))
|
|
352
|
+
return true
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let next = appText
|
|
356
|
+
const lines = next.split('\n')
|
|
357
|
+
const sectionStart = lines.findIndex((l) => l.startsWith('patchedDependencies'))
|
|
358
|
+
if (sectionStart === -1) {
|
|
359
|
+
next = `${appText.trimEnd()}\npatchedDependencies:\n${blocks.join('\n')}\n`
|
|
360
|
+
} else {
|
|
361
|
+
// Insert before the next top-level key (or at EOF).
|
|
362
|
+
let insertAt = lines.length
|
|
363
|
+
for (let i = sectionStart + 1; i < lines.length; i++) {
|
|
364
|
+
if (/^\S/.test(lines[i])) {
|
|
365
|
+
insertAt = i
|
|
366
|
+
break
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
lines.splice(insertAt, 0, ...blocks.join('\n').split('\n'))
|
|
371
|
+
next = lines.join('\n')
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
writeFileSync(yamlPath, next.endsWith('\n') ? next : next + '\n')
|
|
375
|
+
return true
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Write a scaffold for `ids` into `dir` (must not exist or be empty). */
|
|
379
|
+
export function composeTargets(ids, dir) {
|
|
380
|
+
const resolved = new Set(resolveTargets(ids))
|
|
381
|
+
mkdirSync(dir, { recursive: true })
|
|
382
|
+
|
|
383
|
+
for (const rel of walk(TEMPLATE_DIR)) {
|
|
384
|
+
if (rel === 'package.json' || rel === 'pnpm-workspace.yaml') {
|
|
385
|
+
continue // generated below
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const owner = ownerOf(rel)
|
|
389
|
+
if (owner && !resolved.has(owner)) {
|
|
390
|
+
continue
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const dest = join(dir, rel === 'gitignore' ? '.gitignore' : rel)
|
|
394
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
395
|
+
cpSync(join(TEMPLATE_DIR, rel), dest, { recursive: true })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify(composeManifest(ids), null, '\t') + '\n')
|
|
399
|
+
writeFileSync(join(dir, 'pnpm-workspace.yaml'), composeWorkspaceYaml(ids))
|
|
400
|
+
return [...resolved]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const readJson = (file) => {
|
|
404
|
+
try {
|
|
405
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
406
|
+
} catch {
|
|
407
|
+
return null
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Sentinel-based enablement check, mirroring the CLI's targets.mjs. */
|
|
412
|
+
export function targetEnabled(appRoot, id) {
|
|
413
|
+
if (id === 'web') {
|
|
414
|
+
return (
|
|
415
|
+
existsSync(join(appRoot, 'vite.config.ts')) || existsSync(join(appRoot, 'vite.config.mts'))
|
|
416
|
+
)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (id === 'native-shared') {
|
|
420
|
+
return existsSync(join(appRoot, 'nativescript.config.ts'))
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ios/android: declared runtime devDep is the canonical signal — App_Resources
|
|
424
|
+
// alone doesn't reach the bundler config.
|
|
425
|
+
const manifest = readJson(join(appRoot, 'package.json')) ?? {}
|
|
426
|
+
const declared = { ...manifest.dependencies, ...manifest.devDependencies }
|
|
427
|
+
const runtime = { ios: '@nativescript/ios', android: '@nativescript/android' }[id]
|
|
428
|
+
return runtime ? typeof declared[runtime] === 'string' : false
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Copy a template file/dir into the app unless the destination exists. */
|
|
432
|
+
function copyMissing(rel, appRoot, report) {
|
|
433
|
+
const src = join(TEMPLATE_DIR, rel)
|
|
434
|
+
const dest = join(appRoot, rel === 'gitignore' ? '.gitignore' : rel)
|
|
435
|
+
if (existsSync(dest)) {
|
|
436
|
+
report.skipped.push(rel)
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
441
|
+
cpSync(src, dest, { recursive: true })
|
|
442
|
+
report.copied.push(rel)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Enable `id` (plus unmet `requires`) inside an existing app at `appRoot`.
|
|
447
|
+
* Additive only: existing files, scripts, and dep entries are skipped, never
|
|
448
|
+
* overwritten. Returns a report for the caller to render.
|
|
449
|
+
*/
|
|
450
|
+
export function applyTarget(appRoot, id) {
|
|
451
|
+
const manifestPath = join(appRoot, 'package.json')
|
|
452
|
+
const raw = readFileSync(manifestPath, 'utf8')
|
|
453
|
+
const manifest = JSON.parse(raw)
|
|
454
|
+
const tpl = templateManifest()
|
|
455
|
+
const report = {
|
|
456
|
+
enabled: [],
|
|
457
|
+
already: [],
|
|
458
|
+
copied: [],
|
|
459
|
+
skipped: [],
|
|
460
|
+
scriptsAdded: [],
|
|
461
|
+
scriptsSkipped: [],
|
|
462
|
+
depsAdded: [],
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
let touched = false
|
|
466
|
+
for (const tid of resolveTargets([id])) {
|
|
467
|
+
if (targetEnabled(appRoot, tid)) {
|
|
468
|
+
report.already.push(tid)
|
|
469
|
+
continue
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
report.enabled.push(tid)
|
|
473
|
+
const t = targets[tid]
|
|
474
|
+
for (const rel of t.files) {
|
|
475
|
+
copyMissing(rel, appRoot, report)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
for (const [section, key] of [
|
|
479
|
+
['dependencies', 'deps'],
|
|
480
|
+
['devDependencies', 'devDeps'],
|
|
481
|
+
]) {
|
|
482
|
+
for (const name of t[key] ?? []) {
|
|
483
|
+
manifest[section] ??= {}
|
|
484
|
+
if (manifest[section][name] === undefined) {
|
|
485
|
+
manifest[section][name] = tpl[section][name]
|
|
486
|
+
report.depsAdded.push(name)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
manifest.scripts ??= {}
|
|
492
|
+
for (const name of t.scripts ?? []) {
|
|
493
|
+
if (manifest.scripts[name] === undefined) {
|
|
494
|
+
manifest.scripts[name] = tpl.scripts[name]
|
|
495
|
+
report.scriptsAdded.push(name)
|
|
496
|
+
} else if (manifest.scripts[name] !== tpl.scripts[name]) {
|
|
497
|
+
report.scriptsSkipped.push(name)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (t.typecheck) {
|
|
502
|
+
const current = manifest.scripts.typecheck ?? ''
|
|
503
|
+
if (!current.includes(t.typecheck)) {
|
|
504
|
+
manifest.scripts.typecheck = current ? `${current} && ${t.typecheck}` : t.typecheck
|
|
505
|
+
report.scriptsAdded.push('typecheck')
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (tid === 'native-shared' && manifest.main === undefined) {
|
|
510
|
+
manifest.main = tpl.main
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
touched = true
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Newly enabled targets may carry patch entries the scaffolded
|
|
517
|
+
// pnpm-workspace.yaml never had — without them the next install misses the
|
|
518
|
+
// framework patches. enabled+already covers the yaml-missing case, where
|
|
519
|
+
// the file is regenerated for the whole enabled set.
|
|
520
|
+
if (ensurePatchEntries(appRoot, [...report.enabled, ...report.already])) {
|
|
521
|
+
report.patchesAdded = true
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
if (touched) {
|
|
525
|
+
// Match the file's existing indent so diffs stay minimal.
|
|
526
|
+
const indent = raw.match(/\n(\s+)"/)?.[1] ?? '\t'
|
|
527
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, indent) + '\n')
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return report
|
|
531
|
+
}
|