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.
Files changed (30) hide show
  1. package/README.md +28 -8
  2. package/index.mjs +112 -14
  3. package/package.json +15 -8
  4. package/scaffold.mjs +531 -0
  5. package/template/.agents/skills/xplat/SKILL.md +109 -40
  6. package/template/.agents/skills/xplat/references/components.md +149 -96
  7. package/template/.agents/skills/xplat/references/navigation.md +81 -31
  8. package/template/.agents/skills/xplat/references/platform.md +74 -35
  9. package/template/.agents/skills/xplat/references/styling.md +80 -27
  10. package/template/AGENTS.md +1 -1
  11. package/template/package.json +11 -9
  12. package/template/pnpm-workspace.yaml +66 -33
  13. package/template/src/App.tsrx +17 -8
  14. package/template/src/process.d.ts +7 -0
  15. package/template/src/style.css +24 -6
  16. package/template/tsconfig.json +1 -1
  17. package/template/vite.config.native.mts +3 -2
  18. package/template/vite.config.ts +5 -3
  19. package/template/App_Resources/iOS/src/OctaneShaders.metal +0 -128
  20. package/template/App_Resources/iOS/src/ShaderEffectRegistration.swift +0 -31
  21. package/template/patches/@nativescript-community__octane@0.2.1.patch +0 -16
  22. package/template/patches/@nativescript-community__vite-octane@0.2.1.patch +0 -41
  23. package/template/patches/@nativescript__core@9.1.2.patch +0 -794
  24. package/template/patches/@nativescript__core@9.1.3-next.2.patch +0 -428
  25. package/template/patches/@nativescript__vite@8.0.11.patch +0 -569
  26. package/template/patches/@nativescript__vite@8.0.13.patch +0 -233
  27. package/template/patches/@tsrx__typescript-plugin@0.4.11.patch +0 -11
  28. package/template/patches/css@3.0.0.patch +0 -52
  29. package/template/patches/octane@0.6.3.patch +0 -1149
  30. /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
+ }