@octane-xplat/cli 0.3.0 → 0.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 ADDED
@@ -0,0 +1,35 @@
1
+ # @octane-xplat/cli
2
+
3
+ > `xplat` — the dev/build toolchain for Octane xplat apps (one Octane
4
+ > codebase → web + iOS + Android).
5
+ >
6
+ > Status: `0.x` — the API surface is still moving. iOS/Android targets need
7
+ > the NativeScript toolchain (Xcode/JDK + `ns`).
8
+
9
+ ```sh
10
+ pnpm add -D @octane-xplat/cli
11
+ ```
12
+
13
+ ```sh
14
+ pnpm xplat dev # pick targets (web, ios, android) or --targets web,ios
15
+ pnpm xplat build # production builds
16
+ pnpm xplat doctor # environment check
17
+ pnpm xplat typecheck # web + native tsconfigs
18
+ ```
19
+
20
+ The scaffolded app's `pnpm dev` / `pnpm build` / `pnpm dev:ios` scripts drive
21
+ the same pieces directly; `xplat` is the multi-target front end.
22
+
23
+ Also exports the native vite preset — it absorbs the app-owned native config
24
+ (renderer rules, octane→universal alias, `.ios`/`.android` extension chain,
25
+ HMR watchdog, `px→dip` rewrite):
26
+
27
+ ```ts
28
+ // vite.config.native.mts
29
+ import { defineConfig } from 'vite'
30
+ import { xplatNative } from '@octane-xplat/cli/vite'
31
+
32
+ export default defineConfig(({ mode }) => xplatNative(mode))
33
+ ```
34
+
35
+ Docs: [Running and checking an app](https://octane-xplat.goddardai.org/toolchain)
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "name": "@octane-xplat/cli",
3
- "version": "0.3.0",
4
- "type": "module",
3
+ "version": "0.4.0",
5
4
  "description": "xplat — dev/build/doctor for octane-xplat apps (web + iOS + Android from one codebase)",
6
5
  "bin": {
7
6
  "xplat": "./src/cli.mjs"
@@ -9,11 +8,18 @@
9
8
  "files": [
10
9
  "src"
11
10
  ],
12
- "dependencies": {
13
- "@alloc/cmd-ts": "0.17.1",
14
- "@clack/prompts": "1.8.1"
11
+ "type": "module",
12
+ "exports": {
13
+ "./vite": {
14
+ "types": "./src/vite.d.ts",
15
+ "default": "./src/vite.mjs"
16
+ }
15
17
  },
16
18
  "publishConfig": {
17
19
  "access": "public"
20
+ },
21
+ "dependencies": {
22
+ "@alloc/cmd-ts": "0.17.1",
23
+ "@clack/prompts": "1.8.1"
18
24
  }
19
25
  }
package/src/cli.mjs CHANGED
@@ -1,15 +1,16 @@
1
1
  #!/usr/bin/env node
2
- import { binary, subcommands, run } from '@alloc/cmd-ts';
3
- import { dev } from './commands/dev.mjs';
4
- import { build } from './commands/build.mjs';
5
- import { typecheck } from './commands/typecheck.mjs';
6
- import { doctor } from './commands/doctor.mjs';
7
- import { clean } from './commands/clean.mjs';
2
+ import { binary, subcommands, run } from '@alloc/cmd-ts'
3
+ import { dev } from './commands/dev.mjs'
4
+ import { build } from './commands/build.mjs'
5
+ import { typecheck } from './commands/typecheck.mjs'
6
+ import { doctor } from './commands/doctor.mjs'
7
+ import { clean } from './commands/clean.mjs'
8
+ import { routes } from './commands/routes.mjs'
8
9
 
9
10
  const cli = subcommands({
10
11
  name: 'xplat',
11
12
  description: 'One Octane codebase → web + iOS + Android',
12
- cmds: { dev, build, typecheck, doctor, clean },
13
- });
13
+ cmds: { dev, build, typecheck, doctor, clean, routes },
14
+ })
14
15
 
15
- await run(binary(cli), process.argv);
16
+ await run(binary(cli), process.argv)
@@ -1,13 +1,19 @@
1
- import { command, flag, option, optional, string } from '@alloc/cmd-ts';
2
- import * as p from '@clack/prompts';
3
- import { buildTargets } from '../targets.mjs';
4
- import { runTagged } from '../procs.mjs';
1
+ import { command, flag, option, optional, string } from '@alloc/cmd-ts'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import * as p from '@clack/prompts'
5
+ import { buildTargets } from '../targets.mjs'
6
+ import { runTagged } from '../procs.mjs'
7
+ import { generateRoutes } from './routes.mjs'
5
8
 
6
9
  export const build = command({
7
10
  name: 'build',
8
11
  description: 'Build for web, iOS, Android',
9
12
  args: {
10
- release: flag({ long: 'release', description: 'Native release builds (signed where configured)' }),
13
+ release: flag({
14
+ long: 'release',
15
+ description: 'Native release builds (signed where configured)',
16
+ }),
11
17
  targets: option({
12
18
  long: 'targets',
13
19
  short: 't',
@@ -16,42 +22,58 @@ export const build = command({
16
22
  }),
17
23
  },
18
24
  handler: async (args) => {
19
- const cwd = process.cwd();
20
- const all = buildTargets(cwd);
25
+ const cwd = process.cwd()
26
+ const all = buildTargets(cwd)
21
27
  if (all.length === 0) {
22
- p.log.error('Nothing to build — no vite.config.ts or nativescript.config.ts found.');
23
- process.exit(1);
28
+ p.log.error('Nothing to build — no vite.config.ts or nativescript.config.ts found.')
29
+ process.exit(1)
24
30
  }
25
31
 
26
- let chosen;
32
+ let chosen
27
33
  if (args.targets) {
28
- const kinds = args.targets.split(',').map((s) => s.trim());
29
- chosen = all.filter((t) => kinds.includes(t.kind));
34
+ const kinds = args.targets.split(',').map((s) => s.trim())
35
+ chosen = all.filter((t) => kinds.includes(t.kind))
30
36
  } else if (!process.stdout.isTTY) {
31
- chosen = all;
37
+ chosen = all
32
38
  } else {
33
- p.intro('xplat build');
39
+ p.intro('xplat build')
34
40
  const picked = await p.multiselect({
35
41
  message: 'Build targets',
36
42
  options: all.map((t) => ({ value: t.id, label: t.name })),
37
43
  initialValues: all.map((t) => t.id),
38
44
  required: true,
39
- });
40
- if (p.isCancel(picked)) { p.cancel('Cancelled'); process.exit(0); }
41
- chosen = all.filter((t) => picked.includes(t.id));
45
+ })
46
+
47
+ if (p.isCancel(picked)) {
48
+ p.cancel('Cancelled')
49
+ process.exit(0)
50
+ }
51
+
52
+ chosen = all.filter((t) => picked.includes(t.id))
53
+ }
54
+
55
+ // Route codegen is automatic when a route dir exists — the generated
56
+ // manifest is committed like a lockfile; a stale one is a silent bug.
57
+ const routeDir = ['app', 'src/app'].find((d) => existsSync(join(cwd, d)))
58
+ if (routeDir) {
59
+ const n = generateRoutes(cwd, routeDir)
60
+ p.log.info(`routes.gen regenerated — ${n} route${n === 1 ? '' : 's'}`)
42
61
  }
43
62
 
44
63
  for (const t of chosen) {
45
- const argv = t.kind === 'web'
46
- ? ['exec', 'vite', 'build']
47
- : ['exec', 'ns', 'build', t.kind, ...(args.release ? ['--release'] : [])];
64
+ const argv =
65
+ t.kind === 'web'
66
+ ? ['exec', 'vite', 'build']
67
+ : ['exec', 'ns', 'build', t.kind, ...(args.release ? ['--release'] : [])]
68
+
48
69
  try {
49
- await runTagged(t.kind, 'pnpm', argv, cwd);
70
+ await runTagged(t.kind, 'pnpm', argv, cwd)
50
71
  } catch (e) {
51
- p.log.error(String(e));
52
- process.exit(1);
72
+ p.log.error(String(e))
73
+ process.exit(1)
53
74
  }
54
75
  }
55
- p.log.success('Build complete');
76
+
77
+ p.log.success('Build complete')
56
78
  },
57
- });
79
+ })
@@ -1,23 +1,33 @@
1
- import { command } from '@alloc/cmd-ts';
2
- import { existsSync, rmSync } from 'node:fs';
3
- import * as p from '@clack/prompts';
1
+ import { command } from '@alloc/cmd-ts'
2
+ import { existsSync, rmSync } from 'node:fs'
3
+ import * as p from '@clack/prompts'
4
4
 
5
- const DIRS = ['dist', 'platforms', '.ns-vite-build', 'node_modules/.vite'];
5
+ const DIRS = ['dist', 'platforms', '.ns-vite-build', 'node_modules/.vite']
6
6
 
7
7
  export const clean = command({
8
8
  name: 'clean',
9
9
  description: 'Remove build outputs (dist, platforms, vite caches)',
10
10
  args: {},
11
11
  handler: async () => {
12
- const cwd = process.cwd();
13
- const found = DIRS.filter((d) => existsSync(`${cwd}/${d}`));
14
- if (found.length === 0) { p.log.info('Nothing to clean'); return; }
12
+ const cwd = process.cwd()
13
+ const found = DIRS.filter((d) => existsSync(`${cwd}/${d}`))
14
+ if (found.length === 0) {
15
+ p.log.info('Nothing to clean')
16
+ return
17
+ }
15
18
 
16
19
  if (process.stdout.isTTY) {
17
- const ok = await p.confirm({ message: `Remove ${found.join(', ')}?` });
18
- if (p.isCancel(ok) || !ok) { p.cancel('Cancelled'); return; }
20
+ const ok = await p.confirm({ message: `Remove ${found.join(', ')}?` })
21
+ if (p.isCancel(ok) || !ok) {
22
+ p.cancel('Cancelled')
23
+ return
24
+ }
25
+ }
26
+
27
+ for (const d of found) {
28
+ rmSync(`${cwd}/${d}`, { recursive: true, force: true })
19
29
  }
20
- for (const d of found) rmSync(`${cwd}/${d}`, { recursive: true, force: true });
21
- p.log.success(`Cleaned ${found.join(', ')}`);
30
+
31
+ p.log.success(`Cleaned ${found.join(', ')}`)
22
32
  },
23
- });
33
+ })
@@ -1,12 +1,15 @@
1
- import { command, option, optional, string } from '@alloc/cmd-ts';
2
- import * as p from '@clack/prompts';
3
- import { discoverTargets } from '../targets.mjs';
4
- import { spawnTagged } from '../procs.mjs';
1
+ import { command, option, optional, string } from '@alloc/cmd-ts'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import * as p from '@clack/prompts'
5
+ import { discoverTargets } from '../targets.mjs'
6
+ import { spawnTagged } from '../procs.mjs'
7
+ import { generateRoutes } from './routes.mjs'
5
8
 
6
9
  const spawnFor = (t, cwd) =>
7
10
  t.kind === 'web'
8
11
  ? spawnTagged('web', 'pnpm', ['exec', 'vite'], cwd)
9
- : spawnTagged(t.kind, 'pnpm', ['exec', 'ns', 'run', t.kind, '--device', t.device], cwd);
12
+ : spawnTagged(t.kind, 'pnpm', ['exec', 'ns', 'run', t.kind, '--device', t.device], cwd)
10
13
 
11
14
  export const dev = command({
12
15
  name: 'dev',
@@ -20,36 +23,58 @@ export const dev = command({
20
23
  }),
21
24
  },
22
25
  handler: async (args) => {
23
- const cwd = process.cwd();
24
- const all = discoverTargets(cwd);
26
+ const cwd = process.cwd()
27
+ const all = discoverTargets(cwd)
25
28
  if (all.length === 0) {
26
- p.log.error('No targets found — need vite.config.ts (web) or nativescript.config.ts (native).');
27
- process.exit(1);
29
+ p.log.error(
30
+ 'No targets found — need vite.config.ts (web) or nativescript.config.ts (native).',
31
+ )
32
+
33
+ process.exit(1)
28
34
  }
29
35
 
30
- let chosen;
36
+ let chosen
31
37
  if (args.targets) {
32
- const kinds = args.targets.split(',').map((s) => s.trim());
33
- chosen = all.filter((t) => kinds.includes(t.kind));
38
+ const kinds = args.targets.split(',').map((s) => s.trim())
39
+ chosen = all.filter((t) => kinds.includes(t.kind))
34
40
  if (chosen.length === 0) {
35
- p.log.error(`No targets matched "${args.targets}". Available: ${all.map((t) => t.kind).join(', ')}`);
36
- process.exit(1);
41
+ p.log.error(
42
+ `No targets matched "${args.targets}". Available: ${all.map((t) => t.kind).join(', ')}`,
43
+ )
44
+
45
+ process.exit(1)
37
46
  }
38
47
  } else if (!process.stdout.isTTY) {
39
- chosen = all; // non-interactive: everything detected
48
+ chosen = all // non-interactive: everything detected
40
49
  } else {
41
- p.intro('xplat dev');
50
+ p.intro('xplat dev')
42
51
  const picked = await p.multiselect({
43
52
  message: 'Dev targets',
44
53
  options: all.map((t) => ({ value: t.id, label: t.name })),
45
54
  initialValues: all.map((t) => t.id),
46
55
  required: true,
47
- });
48
- if (p.isCancel(picked)) { p.cancel('Cancelled'); process.exit(0); }
49
- chosen = all.filter((t) => picked.includes(t.id));
56
+ })
57
+
58
+ if (p.isCancel(picked)) {
59
+ p.cancel('Cancelled')
60
+ process.exit(0)
61
+ }
62
+
63
+ chosen = all.filter((t) => picked.includes(t.id))
64
+ }
65
+
66
+ // Route codegen is automatic when a route dir exists — the generated
67
+ // manifest is committed like a lockfile; a stale one is a silent bug.
68
+ const routeDir = ['app', 'src/app'].find((d) => existsSync(join(cwd, d)))
69
+ if (routeDir) {
70
+ const n = generateRoutes(cwd, routeDir)
71
+ p.log.info(`routes.gen regenerated — ${n} route${n === 1 ? '' : 's'}`)
72
+ }
73
+
74
+ for (const t of chosen) {
75
+ spawnFor(t, cwd)
50
76
  }
51
77
 
52
- for (const t of chosen) spawnFor(t, cwd);
53
- p.log.success(`${chosen.length} target(s) running — Ctrl+C stops all`);
78
+ p.log.success(`${chosen.length} target(s) running — Ctrl+C stops all`)
54
79
  },
55
- });
80
+ })
@@ -1,14 +1,23 @@
1
- import { command } from '@alloc/cmd-ts';
2
- import { execFileSync } from 'node:child_process';
3
- import * as p from '@clack/prompts';
1
+ import { command } from '@alloc/cmd-ts'
2
+ import { execFileSync } from 'node:child_process'
3
+ import * as p from '@clack/prompts'
4
4
 
5
5
  const check = (cmd, args) => {
6
6
  try {
7
- return { ok: true, out: execFileSync(cmd, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n')[0] };
7
+ return {
8
+ ok: true,
9
+ out: execFileSync(cmd, args, {
10
+ encoding: 'utf8',
11
+ timeout: 15000,
12
+ stdio: ['ignore', 'pipe', 'pipe'],
13
+ })
14
+ .trim()
15
+ .split('\n')[0],
16
+ }
8
17
  } catch {
9
- return { ok: false, out: '' };
18
+ return { ok: false, out: '' }
10
19
  }
11
- };
20
+ }
12
21
 
13
22
  /** env checks — the things that have actually bitten this stack. */
14
23
  export const doctor = command({
@@ -16,33 +25,72 @@ export const doctor = command({
16
25
  description: 'Check the toolchain for web + native builds',
17
26
  args: {},
18
27
  handler: async () => {
19
- p.intro('xplat doctor');
20
- const rows = [];
21
- const row = (name, ok, detail, hint) => rows.push({ name, ok, detail, hint });
22
-
23
- row('node', check('node', ['--version']).ok, check('node', ['--version']).out);
24
- row('pnpm', check('pnpm', ['--version']).ok, check('pnpm', ['--version']).out);
25
- row('ns CLI', check('pnpm', ['exec', 'ns', '--version']).ok, check('pnpm', ['exec', 'ns', '--version']).out,
26
- 'add the nativescript devDep (the starter ships it)');
27
- row('xcodebuild', check('xcodebuild', ['-version']).ok, check('xcodebuild', ['-version']).out,
28
- 'iOS needs Xcode — App Store install + xcode-select');
29
- row('xcodeproj gem', check('ruby', ['-e', 'require "xcodeproj"']).ok, '',
30
- 'gem install --user-install xcodeproj');
31
- const sims = check('xcrun', ['simctl', 'list', 'devices', 'booted']);
32
- row('iOS simulator', sims.ok, sims.out || 'none booted');
33
- const adb = check('adb', ['devices']);
34
- const devices = adb.ok ? adb.out.split('\n').slice(1).filter((l) => l.includes('\tdevice')).length : 0;
35
- row('adb', adb.ok, `${devices} device(s)`, 'Android SDK platform-tools on PATH');
36
- row('ANDROID_HOME', !!process.env.ANDROID_HOME, process.env.ANDROID_HOME || 'unset',
37
- 'export ANDROID_HOME=$HOME/Library/Android/sdk');
38
- row('JAVA_HOME', !!process.env.JAVA_HOME, process.env.JAVA_HOME || 'unset',
39
- 'JDK 17 (JDK 25 breaks the Android toolchain)');
40
-
41
- let bad = 0;
28
+ p.intro('xplat doctor')
29
+ const rows = []
30
+ const row = (name, ok, detail, hint) => rows.push({ name, ok, detail, hint })
31
+
32
+ row('node', check('node', ['--version']).ok, check('node', ['--version']).out)
33
+ row('pnpm', check('pnpm', ['--version']).ok, check('pnpm', ['--version']).out)
34
+ row(
35
+ 'ns CLI',
36
+ check('pnpm', ['exec', 'ns', '--version']).ok,
37
+ check('pnpm', ['exec', 'ns', '--version']).out,
38
+ 'add the nativescript devDep (the starter ships it)',
39
+ )
40
+
41
+ row(
42
+ 'xcodebuild',
43
+ check('xcodebuild', ['-version']).ok,
44
+ check('xcodebuild', ['-version']).out,
45
+ 'iOS needs Xcode — App Store install + xcode-select',
46
+ )
47
+
48
+ row(
49
+ 'xcodeproj gem',
50
+ check('ruby', ['-e', 'require "xcodeproj"']).ok,
51
+ '',
52
+ 'gem install --user-install xcodeproj',
53
+ )
54
+
55
+ const sims = check('xcrun', ['simctl', 'list', 'devices', 'booted'])
56
+ row('iOS simulator', sims.ok, sims.out || 'none booted')
57
+ const adb = check('adb', ['devices'])
58
+ const devices = adb.ok
59
+ ? adb.out
60
+ .split('\n')
61
+ .slice(1)
62
+ .filter((l) => l.includes('\tdevice')).length
63
+ : 0
64
+
65
+ row('adb', adb.ok, `${devices} device(s)`, 'Android SDK platform-tools on PATH')
66
+ row(
67
+ 'ANDROID_HOME',
68
+ !!process.env.ANDROID_HOME,
69
+ process.env.ANDROID_HOME || 'unset',
70
+ 'export ANDROID_HOME=$HOME/Library/Android/sdk',
71
+ )
72
+
73
+ row(
74
+ 'JAVA_HOME',
75
+ !!process.env.JAVA_HOME,
76
+ process.env.JAVA_HOME || 'unset',
77
+ 'JDK 17 (JDK 25 breaks the Android toolchain)',
78
+ )
79
+
80
+ let bad = 0
42
81
  for (const r of rows) {
43
- if (r.ok) p.log.success(`${r.name} — ${r.detail || 'ok'}`);
44
- else { bad++; p.log.warn(`${r.name} — missing${r.hint ? ` (${r.hint})` : ''}`); }
82
+ if (r.ok) {
83
+ p.log.success(`${r.name} — ${r.detail || 'ok'}`)
84
+ } else {
85
+ bad++
86
+ p.log.warn(`${r.name} — missing${r.hint ? ` (${r.hint})` : ''}`)
87
+ }
45
88
  }
46
- p.outro(bad === 0 ? 'All checks pass' : `${bad} missing — web still works, native targets need the above`);
89
+
90
+ p.outro(
91
+ bad === 0
92
+ ? 'All checks pass'
93
+ : `${bad} missing — web still works, native targets need the above`,
94
+ )
47
95
  },
48
- });
96
+ })
@@ -0,0 +1,203 @@
1
+ import { command, option, optional, string } from '@alloc/cmd-ts'
2
+ import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs'
3
+ import { join, relative, sep } from 'node:path'
4
+ import * as p from '@clack/prompts'
5
+
6
+ // Mirror of packages/ui/src/route-table.ts conventions — the CLI walks the
7
+ // route dir off disk (no vite glob), so the derivation is re-implemented
8
+ // here. Keep the two in sync: same suffix strip order, same index/_layout
9
+ // rules. All platform variants count — the union is cross-platform.
10
+ const EXT = /\.(tsrx|tsx|ts|mts|cts|js|mjs|cjs|jsx)$/
11
+ const SUFFIX = /\.(web|native|ios|android)$/
12
+ const PARAM = /^\[(.+)\]$/
13
+ const PRESENT = /\+(modal|fade|push)$/
14
+
15
+ function walk(dir, out = []) {
16
+ for (const name of readdirSync(dir).sort()) {
17
+ const full = join(dir, name)
18
+ if (statSync(full).isDirectory()) {
19
+ walk(full, out)
20
+ } else if (EXT.test(name)) {
21
+ out.push(full)
22
+ }
23
+ }
24
+
25
+ return out
26
+ }
27
+
28
+ function routeFor(rel) {
29
+ rel = rel.split(sep).join('/').replace(EXT, '')
30
+ const parts = rel.split('/')
31
+ let base = parts[parts.length - 1]
32
+ const sm = SUFFIX.exec(base)
33
+ if (sm) {
34
+ base = base.slice(0, base.length - sm[0].length)
35
+ }
36
+
37
+ const pm = PRESENT.exec(base)
38
+ const presentation = pm ? pm[1] : undefined
39
+ if (pm) {
40
+ base = base.slice(0, base.length - pm[0].length)
41
+ }
42
+
43
+ if (base === '_layout') {
44
+ return null
45
+ }
46
+
47
+ const segs = parts.slice(0, -1).concat(base)
48
+ if (segs[segs.length - 1] === 'index') {
49
+ segs.pop()
50
+ }
51
+
52
+ const segments = segs.map((s) => {
53
+ const m = PARAM.exec(s)
54
+ return m ? ':' + m[1] : s
55
+ })
56
+
57
+ return {
58
+ name: segments.join('/') || 'index',
59
+ params: segments.filter((s) => s.startsWith(':')).map((s) => s.slice(1)),
60
+ presentation,
61
+ }
62
+ }
63
+
64
+ /** Generates routes.gen.types.ts + platform manifest glue for a route dir.
65
+ * Returns true when a dir was found and files written; false when no route
66
+ * dir exists (callers that run this implicitly — dev/build — stay quiet). */
67
+ export function generateRoutes(cwd, dir, out) {
68
+ if (!dir) {
69
+ return false
70
+ }
71
+
72
+ // `out` is the module basename — three files are emitted:
73
+ // <out>.types.ts shared types (RouteName/Params/Presentations)
74
+ // <out>.web.ts web glob + registerRoutes
75
+ // <out>.native.ts native glob + registerRoutes (Device.os prefer)
76
+ // Importing '<out>' resolves the platform leaf automatically.
77
+ const base = (out ?? join(dir, '..', 'routes.gen')).replace(/\.ts$/, '')
78
+
79
+ // name → {params, presentation}; platform variants of one route
80
+ // collapse to a single entry (union across platforms).
81
+ const seen = new Map()
82
+ for (const file of walk(join(cwd, dir))) {
83
+ const r = routeFor(relative(join(cwd, dir), file))
84
+ if (!r) {
85
+ continue
86
+ }
87
+
88
+ if (!seen.has(r.name)) {
89
+ seen.set(r.name, r)
90
+ }
91
+ }
92
+
93
+ const list = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
94
+
95
+ const union = list.length ? list.map((r) => `\n\t| '${r.name}'`).join('') : 'never'
96
+
97
+ const params = list
98
+ .map((r) => `\t'${r.name}': { ${r.params.map((k) => `${k}: string`).join('; ')} }`)
99
+
100
+ const presents = list
101
+ .filter((r) => r.presentation)
102
+ .map((r) => `\t'${r.name}': '${r.presentation}'`)
103
+
104
+ const src = `// Generated by \`xplat routes\` — do not edit. Re-run after
105
+ // touching the route dir (add/remove/rename route files).
106
+ export type RouteName =${union}
107
+
108
+ export interface RouteParams {
109
+ ${params.length ? params.join('\n') : '\t// no param routes'}
110
+ }
111
+
112
+ export interface RoutePresentations {
113
+ ${presents.length ? presents.join('\n') : '\t// no +modal/+fade routes'}
114
+ }
115
+ `
116
+
117
+ writeFileSync(join(cwd, base + '.types.ts'), src)
118
+
119
+ // Platform-suffixed twins carry the actual manifest derivation +
120
+ // registration — routes.gen.web.ts / routes.gen.native.ts resolve
121
+ // through the platform extension chain, so importing './routes.gen'
122
+ // gets the right glob for the platform with no app-side leaf file.
123
+ // Types live in '.types.ts' — './routes.gen' inside a .web.ts sibling
124
+ // would self-resolve.
125
+ const outDir = join(cwd, base, '..')
126
+ const rel = relative(outDir, join(cwd, dir)).split(sep).join('/')
127
+ const globDir = rel.startsWith('.') ? rel : './' + rel
128
+ const baseName = base.split('/').pop()
129
+ const typesRef = `export type { RouteName, RouteParams, RoutePresentations } from './${baseName}.types'`
130
+
131
+ const shared = (prelude, globs, prefer) => `// Generated by \`xplat routes\` — do not edit.
132
+ ${prelude}import { deriveRouteManifest, registerRoutes } from '@octane-xplat/ui'
133
+ ${typesRef}
134
+
135
+ const files = import.meta.glob(
136
+ [
137
+ '${globDir}/**/*.{tsrx,tsx}',
138
+ ${globs}
139
+ ],
140
+ { eager: true },
141
+ )
142
+
143
+ export const routes = deriveRouteManifest(files, ${prefer})
144
+ registerRoutes(routes)
145
+ export const screens = routes.screens
146
+ `
147
+
148
+ writeFileSync(
149
+ join(cwd, base + '.web.ts'),
150
+ shared(
151
+ '',
152
+ [
153
+ `\t\t'!${globDir}/**/*.native.{tsrx,tsx}'`,
154
+ `\t\t'!${globDir}/**/*.ios.{tsrx,tsx}'`,
155
+ `\t\t'!${globDir}/**/*.android.{tsrx,tsx}'`,
156
+ ].join(',\n'),
157
+ `['web']`,
158
+ ),
159
+ )
160
+
161
+ writeFileSync(
162
+ join(cwd, base + '.native.ts'),
163
+ shared(
164
+ `import { Device } from '@nativescript/core'\n`,
165
+ `\t\t'!${globDir}/**/*.web.{tsrx,tsx}'`,
166
+ `Device.os === 'Android' ? ['android', 'native'] : ['ios', 'native']`,
167
+ ),
168
+ )
169
+
170
+ return list.length
171
+ }
172
+
173
+ export const routes = command({
174
+ name: 'routes',
175
+ description: 'Generate routes.gen.ts (typed RouteName/params) from the route dir',
176
+ args: {
177
+ dir: option({
178
+ long: 'dir',
179
+ short: 'd',
180
+ type: optional(string),
181
+ description: 'Route dir — default: first of ./app, ./src/app',
182
+ }),
183
+ out: option({
184
+ long: 'out',
185
+ short: 'o',
186
+ type: optional(string),
187
+ description: 'Output file — default: <route dir>/../routes.gen.ts',
188
+ }),
189
+ },
190
+ handler: async (args) => {
191
+ const cwd = process.cwd()
192
+ const dir = args.dir ?? ['app', 'src/app'].find((d) => existsSync(join(cwd, d)))
193
+ if (!dir) {
194
+ p.log.error('No route dir found — expected ./app or ./src/app (or pass --dir).')
195
+ process.exit(1)
196
+ }
197
+
198
+ const count = generateRoutes(cwd, dir, args.out)
199
+ p.log.success(
200
+ `Wrote routes.gen.{types,web,native}.ts — ${count} route${count === 1 ? '' : 's'}`,
201
+ )
202
+ },
203
+ })
@@ -1,23 +1,34 @@
1
- import { command } from '@alloc/cmd-ts';
2
- import { existsSync } from 'node:fs';
3
- import * as p from '@clack/prompts';
4
- import { runTagged } from '../procs.mjs';
1
+ import { command } from '@alloc/cmd-ts'
2
+ import { existsSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import * as p from '@clack/prompts'
5
+ import { runTagged } from '../procs.mjs'
6
+ import { generateRoutes } from './routes.mjs'
5
7
 
6
8
  export const typecheck = command({
7
9
  name: 'typecheck',
8
10
  description: 'tsrx-tsc --noEmit for every tsconfig present',
9
11
  args: {},
10
12
  handler: async () => {
11
- const cwd = process.cwd();
12
- const configs = ['tsconfig.json', 'tsconfig.native.json'].filter((f) => existsSync(`${cwd}/${f}`));
13
+ const cwd = process.cwd()
14
+ const routeDir = ['app', 'src/app'].find((d) => existsSync(join(cwd, d)))
15
+ if (routeDir) {
16
+ generateRoutes(cwd, routeDir)
17
+ }
18
+
19
+ const configs = ['tsconfig.json', 'tsconfig.native.json'].filter((f) =>
20
+ existsSync(`${cwd}/${f}`),
21
+ )
22
+
13
23
  for (const c of configs) {
14
24
  try {
15
- await runTagged('tsc', 'pnpm', ['exec', 'tsrx-tsc', '--noEmit', '-p', c], cwd);
25
+ await runTagged('tsc', 'pnpm', ['exec', 'tsrx-tsc', '--noEmit', '-p', c], cwd)
16
26
  } catch (e) {
17
- p.log.error(String(e));
18
- process.exit(1);
27
+ p.log.error(String(e))
28
+ process.exit(1)
19
29
  }
20
30
  }
21
- p.log.success('Typecheck clean');
31
+
32
+ p.log.success('Typecheck clean')
22
33
  },
23
- });
34
+ })
package/src/procs.mjs CHANGED
@@ -1,33 +1,39 @@
1
1
  // Prefixed process plumbing — dev runs several tools side by side, so each
2
2
  // gets a short tag on its output lines. SIGINT fans out to every child.
3
- import { spawn } from 'node:child_process';
3
+ import { spawn } from 'node:child_process'
4
4
 
5
- const children = new Set();
5
+ const children = new Set()
6
6
 
7
7
  process.on('SIGINT', () => {
8
- for (const p of children) p.kill('SIGINT');
9
- process.exit(130);
10
- });
8
+ for (const p of children) {
9
+ p.kill('SIGINT')
10
+ }
11
+
12
+ process.exit(130)
13
+ })
11
14
 
12
15
  /** Spawn long-running, output prefixed `[tag]`. */
13
16
  export function spawnTagged(tag, cmd, args, cwd) {
14
- const p = spawn(cmd, args, { cwd, env: process.env });
15
- children.add(p);
17
+ const p = spawn(cmd, args, { cwd, env: process.env })
18
+ children.add(p)
16
19
  const prefix = (chunk) => {
17
20
  for (const line of chunk.toString().split('\n')) {
18
- if (line.trim()) process.stdout.write(`[${tag}] ${line}\n`);
21
+ if (line.trim()) {
22
+ process.stdout.write(`[${tag}] ${line}\n`)
23
+ }
19
24
  }
20
- };
21
- p.stdout.on('data', prefix);
22
- p.stderr.on('data', prefix);
23
- p.on('exit', () => children.delete(p));
24
- return p;
25
+ }
26
+
27
+ p.stdout.on('data', prefix)
28
+ p.stderr.on('data', prefix)
29
+ p.on('exit', () => children.delete(p))
30
+ return p
25
31
  }
26
32
 
27
33
  /** Spawn to completion, output prefixed `[tag]`. Resolves on exit 0. */
28
34
  export function runTagged(tag, cmd, args, cwd) {
29
35
  return new Promise((resolve, reject) => {
30
- const p = spawnTagged(tag, cmd, args, cwd);
31
- p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${tag} exited ${code}`))));
32
- });
36
+ const p = spawnTagged(tag, cmd, args, cwd)
37
+ p.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`${tag} exited ${code}`))))
38
+ })
33
39
  }
package/src/targets.mjs CHANGED
@@ -1,78 +1,110 @@
1
1
  // Target + device discovery. Everything degrades quietly — a missing
2
2
  // toolchain means the target is absent from prompts, not an error.
3
- import { existsSync } from 'node:fs';
4
- import { execFileSync } from 'node:child_process';
3
+ import { existsSync } from 'node:fs'
4
+ import { execFileSync } from 'node:child_process'
5
5
 
6
6
  const run = (cmd, args) => {
7
7
  try {
8
- return execFileSync(cmd, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] });
8
+ return execFileSync(cmd, args, {
9
+ encoding: 'utf8',
10
+ timeout: 15000,
11
+ stdio: ['ignore', 'pipe', 'ignore'],
12
+ })
9
13
  } catch {
10
- return null;
14
+ return null
11
15
  }
12
- };
16
+ }
17
+
18
+ export const hasWeb = (cwd) =>
19
+ existsSync(`${cwd}/vite.config.ts`) || existsSync(`${cwd}/vite.config.mts`)
13
20
 
14
- export const hasWeb = (cwd) => existsSync(`${cwd}/vite.config.ts`) || existsSync(`${cwd}/vite.config.mts`);
15
- export const hasNative = (cwd) => existsSync(`${cwd}/nativescript.config.ts`);
21
+ export const hasNative = (cwd) => existsSync(`${cwd}/nativescript.config.ts`)
16
22
 
17
23
  /** iOS targets: booted sims first, then other available sims, then physical devices. */
18
24
  export function iosTargets() {
19
- const out = run('xcrun', ['simctl', 'list', 'devices', 'available', '-j']);
20
- if (!out) return [];
25
+ const out = run('xcrun', ['simctl', 'list', 'devices', 'available', '-j'])
26
+ if (!out) {
27
+ return []
28
+ }
29
+
21
30
  try {
22
- const j = JSON.parse(out);
23
- const sims = [];
31
+ const j = JSON.parse(out)
32
+ const sims = []
24
33
  for (const list of Object.values(j.devices ?? {})) {
25
34
  for (const d of list) {
26
- if (!d.isAvailable) continue;
35
+ if (!d.isAvailable) {
36
+ continue
37
+ }
38
+
27
39
  sims.push({
28
40
  kind: 'ios',
29
41
  id: d.udid,
30
42
  name: d.name + (d.state === 'Booted' ? ' (booted)' : ''),
31
43
  device: d.udid,
32
44
  booted: d.state === 'Booted',
33
- });
45
+ })
34
46
  }
35
47
  }
48
+
36
49
  // Booted first — that's almost always the one you mean.
37
- return sims.sort((a, b) => (b.booted ? 1 : 0) - (a.booted ? 1 : 0));
50
+ return sims.sort((a, b) => (b.booted ? 1 : 0) - (a.booted ? 1 : 0))
38
51
  } catch {
39
- return [];
52
+ return []
40
53
  }
41
54
  }
42
55
 
43
56
  /** Android targets: emulators + physical devices from adb. */
44
57
  export function androidTargets() {
45
- const out = run('adb', ['devices']);
46
- if (!out) return [];
58
+ const out = run('adb', ['devices'])
59
+ if (!out) {
60
+ return []
61
+ }
62
+
47
63
  return out
48
64
  .split('\n')
49
65
  .slice(1)
50
66
  .map((l) => l.trim())
51
67
  .filter((l) => l.endsWith('\tdevice'))
52
68
  .map((l) => {
53
- const serial = l.split('\t')[0];
54
- const emu = serial.startsWith('emulator-');
55
- return { kind: 'android', id: serial, name: emu ? `${serial} (emulator)` : `${serial} (device)`, device: serial };
56
- });
69
+ const serial = l.split('\t')[0]
70
+ const emu = serial.startsWith('emulator-')
71
+ return {
72
+ kind: 'android',
73
+ id: serial,
74
+ name: emu ? `${serial} (emulator)` : `${serial} (device)`,
75
+ device: serial,
76
+ }
77
+ })
57
78
  }
58
79
 
59
80
  /** Every launchable target for this project + machine. */
60
81
  export function discoverTargets(cwd) {
61
- const targets = [];
62
- if (hasWeb(cwd)) targets.push({ kind: 'web', id: 'web', name: 'Web (vite :5200)' });
82
+ const targets = []
83
+ if (hasWeb(cwd)) {
84
+ targets.push({ kind: 'web', id: 'web', name: 'Web (vite :5200)' })
85
+ }
86
+
63
87
  if (hasNative(cwd)) {
64
- targets.push(...iosTargets(), ...androidTargets());
88
+ targets.push(...iosTargets(), ...androidTargets())
65
89
  }
66
- return targets;
90
+
91
+ return targets
67
92
  }
68
93
 
69
94
  /** Distinct platform buckets for `build` — one entry per platform, no device picks. */
70
95
  export function buildTargets(cwd) {
71
- const t = [];
72
- if (hasWeb(cwd)) t.push({ kind: 'web', id: 'web', name: 'Web (vite build)' });
96
+ const t = []
97
+ if (hasWeb(cwd)) {
98
+ t.push({ kind: 'web', id: 'web', name: 'Web (vite build)' })
99
+ }
100
+
73
101
  if (hasNative(cwd)) {
74
- if (iosTargets().length || run('xcrun', ['--version'])) t.push({ kind: 'ios', id: 'ios', name: 'iOS (ns build ios)' });
75
- t.push({ kind: 'android', id: 'android', name: 'Android (ns build android)' });
102
+ if (iosTargets().length || run('xcrun', ['--version'])) {
103
+ t.push({ kind: 'ios', id: 'ios', name: 'iOS (ns build ios)' })
104
+ }
105
+
106
+ t.push({ kind: 'android', id: 'android', name: 'Android (ns build android)' })
76
107
  }
77
- return t;
108
+
109
+ return t
78
110
  }
package/src/vite.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ import type { UserConfig } from 'vite'
2
+
3
+ export const nativeExtensions: string[]
4
+
5
+ export interface XplatNativeOptions {
6
+ /** Extra optimizeDeps.exclude entries — app-shipped @nativescript plugins. */
7
+ deps?: string[]
8
+ /** Renderer rules override — defaults cover src/ + linked package source. */
9
+ rules?: unknown[]
10
+ /** App-specific config merged in last (plugins, server, …). */
11
+ extra?: UserConfig
12
+ }
13
+
14
+ /** Full native (iOS/Android) Vite config — the shared preset. Resolves the
15
+ * app's own vite/vite-octane/octane toolchain (async because it loads
16
+ * through the app's node_modules). */
17
+ export function xplatNative(
18
+ env: { mode: string } | string,
19
+ opts?: XplatNativeOptions,
20
+ ): Promise<UserConfig>
package/src/vite.mjs ADDED
@@ -0,0 +1,271 @@
1
+ // @octane-xplat/cli/vite — the shared native Vite preset.
2
+ //
3
+ // Everything in here was previously per-app boilerplate (copied between
4
+ // vite.config.native.mts files) or an app-owned workaround: the nativescript
5
+ // renderer rules, the octane→universal/native alias, the platform-suffix
6
+ // extension chain, the deps-bundle plugin exclusions, the HMR watchdog, and
7
+ // the px→dip CSS rewrite. Apps now write:
8
+ //
9
+ // import { defineConfig } from 'vite'
10
+ // import { xplatNative } from '@octane-xplat/cli/vite'
11
+ // export default defineConfig(({ mode }) => xplatNative(mode))
12
+ //
13
+ // The preset calls octaneConfig itself — consumers only merge in
14
+ // app-specific extras via the `extra` option or a vite mergeConfig wrapper.
15
+
16
+ import { createRequire } from 'node:module'
17
+ import { realpathSync } from 'node:fs'
18
+ import { join } from 'node:path'
19
+ import { pathToFileURL } from 'node:url'
20
+
21
+ // The toolchain modules (vite, vite-octane, the nativescript renderer)
22
+ // belong to the CONSUMING app, not to this package — under pnpm's isolated
23
+ // linker a bare import here would miss them entirely. createRequire from
24
+ // the app's cwd makes the preset use the app's own pinned versions.
25
+ // realpathSync matters: resolve() returns the symlinked node_modules path
26
+ // and ESM import() does not realpath — loading vite through the symlink
27
+ // leaves its internal 'rolldown' bare-import stranded outside its .pnpm
28
+ // peer dir.
29
+ const req = createRequire(join(process.cwd(), 'package.json'))
30
+ const importApp = (spec) => import(pathToFileURL(realpathSync(req.resolve(spec))).href)
31
+
32
+ /** CSS that NS parses but silently ignores or misreads — the app looks
33
+ * identical in source but diverges at runtime. Warned at build time so the
34
+ * divergence is loud instead of invisible. Each entry: pattern + the
35
+ * portable alternative. */
36
+ const CSS_DIVERGENCES = [
37
+ [
38
+ /margin-(?:left|right|top|bottom)\s*:\s*auto|margin\s*:[^;{}]*\bauto\b/,
39
+ 'auto margins are ignored on native — use justify-content, alignSelf, or a <Spacer/>',
40
+ ],
41
+ [
42
+ /position\s*:\s*(fixed|sticky)\b/,
43
+ 'position: fixed/sticky does not exist on native — overlays go through Overlay/Modal services, not positioning',
44
+ ],
45
+ [/\bz-index\s*:/, 'z-index is inert on native — paint order follows document order'],
46
+ [/\bfloat\s*:/, 'float is unsupported on native — use flex rows'],
47
+ [
48
+ /\bbox-shadow\s*:/,
49
+ 'box-shadow is inert on native — Android elevation and iOS shadows do not map to it (framework mapping is a TODO)',
50
+ ],
51
+ [
52
+ /white-space\s*:\s*pre-wrap\b/,
53
+ 'Label rejects white-space:pre-wrap — "wrap" is the native wrap value (no space/newline preservation)',
54
+ ],
55
+ ]
56
+
57
+ /**
58
+ * Shared stylesheets are authored in web units and web semantics. For the
59
+ * native bundle this transform (a) rewrites `px` lengths to `dip` — NS CSS
60
+ * reads `px` as _device_ pixels, not dips (`width:88px` measures 29 dips on
61
+ * a 3x device); inline `style` props are already dips and untouched — and
62
+ * (b) warns once per file on declarations NS silently ignores, so the
63
+ * divergence is loud at build time.
64
+ */
65
+ function pxToDip() {
66
+ const warned = new Set()
67
+ const process = (code, id, warn) => {
68
+ // Framework authors mark web-only rule blocks — overlays, popovers,
69
+ // dialog modals render through RootLayout/showModal natively, so
70
+ // their CSS is dead weight (and would trip the divergence warnings).
71
+ // Stripped before the warn pass.
72
+ code = code.replace(
73
+ /\/\*\s*xplat-web-only:start[\s\S]*?\*\/[\s\S]*?\/\*\s*xplat-web-only:end[\s\S]*?\*\//g,
74
+ '',
75
+ )
76
+
77
+ for (const [re, hint] of CSS_DIVERGENCES) {
78
+ const key = id + '|' + hint
79
+ if (re.test(code) && !warned.has(key)) {
80
+ warned.add(key)
81
+ warn(`${id}: ${hint}`)
82
+ }
83
+ }
84
+
85
+ return code.replace(/(-?\d+(?:\.\d+)?)px\b/g, '$1dip')
86
+ }
87
+
88
+ return {
89
+ name: 'xplat-native-css',
90
+ enforce: 'pre',
91
+ // Per-file pass — covers dev serving where css is transformed
92
+ // per module (the /ns/m bridge path).
93
+ transform(code, id) {
94
+ if (!id.split('?')[0].endsWith('.css')) {
95
+ return
96
+ }
97
+
98
+ return process(code, id, (m) => this.warn(m))
99
+ },
100
+ // Build pass — @nativescript/vite collects emitted .css assets in
101
+ // generateBundle and serializes them via addTaggedAdditionalCSS;
102
+ // @import inlining bypasses the transform hook, so the asset text
103
+ // must be rewritten here. 'pre' ordering lands us before it.
104
+ generateBundle(_opts, bundle) {
105
+ for (const file of Object.values(bundle)) {
106
+ if (file.type === 'asset' && file.fileName.endsWith('.css')) {
107
+ const src =
108
+ typeof file.source === 'string' ? file.source : new TextDecoder().decode(file.source)
109
+
110
+ file.source = process(src, file.fileName, (m) => this.warn(m))
111
+ }
112
+ }
113
+ },
114
+ }
115
+ }
116
+
117
+ /**
118
+ * On-device HMR needs the app's websocket client to attach to /ns-hmr after
119
+ * the HTTP boot. When it never does — the websockets polyfill missing from
120
+ * the bundle, `adb reverse` not covering the vite port, or a boot error
121
+ * before the client import — every save logs `recipients=0` and the device
122
+ * silently stays stale. Warn once when a dev session was fetched but no
123
+ * client ever attached.
124
+ */
125
+ function nsHmrClientWatchdog() {
126
+ return {
127
+ name: 'xplat-ns-hmr-client-watchdog',
128
+ configureServer(server) {
129
+ let everConnected = false
130
+ let timer
131
+ // Hook the raw 'request' event — middlewares.use() appends after
132
+ // the ns plugin's session handler, which ends the response without
133
+ // next(), so a connect middleware never observes /__ns_dev__/session.
134
+ server.httpServer?.on('request', (req) => {
135
+ if (!everConnected && req.url?.startsWith('/__ns_dev__/session')) {
136
+ clearTimeout(timer)
137
+ timer = setTimeout(() => {
138
+ if (!everConnected) {
139
+ console.warn(
140
+ '[xplat] the app fetched its dev session but no /ns-hmr ' +
141
+ 'websocket client connected — edits will not reach the ' +
142
+ 'device. Check that @valor/nativescript-websockets is ' +
143
+ 'installed, `adb reverse tcp:<port>` covers this vite ' +
144
+ 'port (physical Android), and the device log for ' +
145
+ 'hmr-client errors.',
146
+ )
147
+ }
148
+ }, 15_000)
149
+ }
150
+ })
151
+
152
+ server.httpServer?.on('upgrade', (req) => {
153
+ if (req.url?.startsWith('/ns-hmr')) {
154
+ everConnected = true
155
+ clearTimeout(timer)
156
+ }
157
+ })
158
+ },
159
+ }
160
+ }
161
+
162
+ /** The full extension chain, most-specific first: .ios/.android → .native →
163
+ * shared. NS's own file qualifiers (.land, .minWH600…) still apply to
164
+ * assets on top of this. */
165
+ export const nativeExtensions = [
166
+ '.ios.tsrx',
167
+ '.android.tsrx',
168
+ '.native.tsrx',
169
+ '.tsrx',
170
+ '.ios.tsx',
171
+ '.android.tsx',
172
+ '.native.tsx',
173
+ '.tsx',
174
+ '.ios.ts',
175
+ '.android.ts',
176
+ '.native.ts',
177
+ '.mjs',
178
+ '.mts',
179
+ '.ts',
180
+ '.jsx',
181
+ '.js',
182
+ '.json',
183
+ ]
184
+
185
+ /** Default renderer rules: every component file the native graph can reach —
186
+ * src plus linked package source — compiles under the nativescript
187
+ * renderer. `.web.*` leaves legitimately use DOM globals; they're
188
+ * unreachable from the native entry but must not fail validation, so each
189
+ * rule excludes them. */
190
+ const nativeRules = [
191
+ {
192
+ include: 'src/**/*.{ts,tsx,tsrx}',
193
+ exclude: 'src/**/*.web.*',
194
+ renderer: 'nativescript',
195
+ },
196
+ {
197
+ include: '**/packages/**/*.{ts,tsx,tsrx}',
198
+ exclude: '**/*.web.*',
199
+ renderer: 'nativescript',
200
+ },
201
+ ]
202
+
203
+ /**
204
+ * Native (iOS/Android) Vite config. `env` is defineConfig's { mode }; `extra`
205
+ * is merged in last for app-specific additions (own plugins, extra
206
+ * optimizeDeps, server options).
207
+ *
208
+ * opts:
209
+ * - deps: extra optimizeDeps.exclude entries (app-shipped NS plugins)
210
+ * - rules: renderer rules override (defaults cover src + packages source)
211
+ */
212
+ export async function xplatNative(env, opts = {}) {
213
+ const mode = typeof env === 'string' ? env : env.mode
214
+ const [{ mergeConfig }, { octaneConfig }, { nativeScriptRenderer }] = await Promise.all([
215
+ importApp('vite'),
216
+ importApp('@nativescript-community/vite-octane'),
217
+ importApp('@nativescript-community/octane/config'),
218
+ ])
219
+
220
+ return mergeConfig(
221
+ octaneConfig(
222
+ { mode },
223
+ {
224
+ octane: {
225
+ renderers: {
226
+ // The stock renderer ships validation.forbiddenGlobals/Imports
227
+ // by default since 0.2.1 (upstream #6).
228
+ registry: { nativescript: nativeScriptRenderer },
229
+ rules: opts.rules ?? nativeRules,
230
+ },
231
+ },
232
+ },
233
+ ),
234
+ {
235
+ plugins: [pxToDip(), nsHmrClientWatchdog()],
236
+ optimizeDeps: {
237
+ // Flattened optimizeDeps chunks get mangled by the /ns/m device
238
+ // transform (`import import "/ns/core/utils"`) and miss the vendor
239
+ // manifest — serve @nativescript plugins per-module instead.
240
+ exclude: [
241
+ '@nativescript/biometrics',
242
+ '@nativescript/geolocation',
243
+ '@nativescript/haptics',
244
+ '@nativescript/imagepicker',
245
+ '@nativescript/local-notifications',
246
+ '@nativescript-community/ui-document-picker',
247
+ '@nativescript/secure-storage',
248
+ '@nativescript/social-share',
249
+ '@nativescript-community/ui-svg',
250
+ 'nativescript-clipboard',
251
+ ...(opts.deps ?? []),
252
+ ],
253
+ },
254
+ resolve: {
255
+ conditions: ['native'],
256
+ // The compiler retargets hook imports to @nativescript-community/
257
+ // octane, but the deps-bundle scanner sees source-level 'octane'
258
+ // first — without this it vendors octane/dist/index.js (the full
259
+ // DOM runtime). Exact-match only: 'octane/universal/native' itself
260
+ // must not be rewritten.
261
+ alias: [{ find: /^octane$/, replacement: 'octane/universal/native' }],
262
+ // ns-vite sets preserveSymlinks:true; under pnpm's isolated layout
263
+ // that resolves a dep's imports from the symlink path instead of
264
+ // its real .pnpm dir, so declared transitive deps can't be found.
265
+ preserveSymlinks: false,
266
+ extensions: nativeExtensions,
267
+ },
268
+ },
269
+ opts.extra ?? {},
270
+ )
271
+ }