@octane-xplat/cli 0.3.0 → 0.5.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.5.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,194 @@
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 { existsSync, readdirSync, readFileSync } from 'node:fs'
4
+ import { dirname, join, parse, relative, resolve } from 'node:path'
5
+ import * as p from '@clack/prompts'
6
+
7
+ const frameworkFallbacks = {
8
+ '@octane-xplat/ui': [
9
+ '@nativescript-community/gesturehandler',
10
+ '@nativescript-community/ui-canvas',
11
+ '@nativescript-community/ui-drawer',
12
+ '@nativescript-community/ui-svg',
13
+ ],
14
+ '@octane-xplat/platform': [
15
+ '@nativescript-community/ui-document-picker',
16
+ '@nativescript/biometrics',
17
+ '@nativescript/geolocation',
18
+ '@nativescript/haptics',
19
+ '@nativescript/imagepicker',
20
+ '@nativescript/local-notifications',
21
+ '@nativescript/secure-storage',
22
+ '@nativescript/social-share',
23
+ 'nativescript-clipboard',
24
+ ],
25
+ }
26
+
27
+ const nativePlugin = (name) =>
28
+ (name.startsWith('@nativescript/') ||
29
+ name.startsWith('@nativescript-community/') ||
30
+ name.startsWith('nativescript-')) &&
31
+ !name.endsWith('/octane') &&
32
+ !name.endsWith('/core')
33
+
34
+ const readJson = (file) => {
35
+ try {
36
+ return JSON.parse(readFileSync(file, 'utf8'))
37
+ } catch {
38
+ return null
39
+ }
40
+ }
41
+
42
+ const packageName = (specifier) => {
43
+ if (!specifier.startsWith('@') && !specifier.includes('/')) {return specifier}
44
+ const parts = specifier.split('/')
45
+ return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]
46
+ }
47
+
48
+ const importSpecifiers = (source) => {
49
+ const specs = new Set()
50
+ for (const pattern of [
51
+ /\bfrom\s*['"]([^'"]+)['"]/g,
52
+ /\bimport\s*['"]([^'"]+)['"]/g,
53
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
54
+ ]) {
55
+ for (const match of source.matchAll(pattern)) {specs.add(match[1])}
56
+ }
57
+
58
+ return specs
59
+ }
60
+
61
+ const sourceFiles = (root) => {
62
+ if (!existsSync(root)) {return []}
63
+ const out = []
64
+ const visit = (dir) => {
65
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
66
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {continue}
67
+ const file = join(dir, entry.name)
68
+ if (entry.isDirectory()) {visit(file)}
69
+ else if (/\.(?:m?[jt]sx?|tsrx)$/.test(entry.name)) {out.push(file)}
70
+ }
71
+ }
72
+
73
+ visit(root)
74
+ return out
75
+ }
76
+
77
+ const workspacePackages = (cwd) => {
78
+ const found = new Map()
79
+ let dir = resolve(cwd)
80
+ while (true) {
81
+ for (const group of ['apps', 'packages']) {
82
+ const parent = join(dir, group)
83
+ if (!existsSync(parent)) {continue}
84
+ for (const name of readdirSync(parent)) {
85
+ const root = join(parent, name)
86
+ const manifest = readJson(join(root, 'package.json'))
87
+ if (manifest?.name) {found.set(manifest.name, root)}
88
+ }
89
+ }
90
+
91
+ const next = dirname(dir)
92
+ if (next === dir) {break}
93
+ dir = next
94
+ }
95
+
96
+ return found
97
+ }
98
+
99
+ const packageRoot = (cwd, name, workspaces) => {
100
+ const direct = join(cwd, 'node_modules', ...name.split('/'))
101
+ if (existsSync(join(direct, 'package.json'))) {return direct}
102
+ return workspaces.get(name)
103
+ }
104
+
105
+ const frameworkPlugins = (cwd, name, workspaces) => {
106
+ const root = packageRoot(cwd, name, workspaces)
107
+ const manifest = root && readJson(join(root, 'package.json'))
108
+ const declared = [
109
+ ...Object.keys(manifest?.dependencies ?? {}),
110
+ ...Object.keys(manifest?.peerDependencies ?? {}),
111
+ ].filter(nativePlugin)
112
+
113
+ return declared.length ? declared : frameworkFallbacks[name] ?? []
114
+ }
115
+
116
+ /**
117
+ * Find native plugins required by the framework packages reachable from an app
118
+ * source tree, then compare them with the app's own package.json. This is a
119
+ * warning because the web target does not need native plugin declarations and
120
+ * a package may intentionally keep an optional native capability unused.
121
+ */
122
+ export function findMissingPluginDeclarations(cwd) {
123
+ const manifest = readJson(join(cwd, 'package.json')) ?? {}
124
+ const owned = new Set([
125
+ ...Object.keys(manifest.dependencies ?? {}),
126
+ ...Object.keys(manifest.devDependencies ?? {}),
127
+ ...Object.keys(manifest.optionalDependencies ?? {}),
128
+ ...Object.keys(manifest.peerDependencies ?? {}),
129
+ ])
130
+
131
+ const workspaces = workspacePackages(cwd)
132
+ const pending = sourceFiles(join(cwd, 'src'))
133
+ for (const dir of ['app', 'src/app']) {pending.push(...sourceFiles(join(cwd, dir)))}
134
+ const visited = new Set()
135
+ const frameworks = new Map()
136
+
137
+ while (pending.length) {
138
+ const file = pending.pop()
139
+ if (visited.has(file)) {continue}
140
+ visited.add(file)
141
+ let source
142
+ try {
143
+ source = readFileSync(file, 'utf8')
144
+ } catch {
145
+ continue
146
+ }
147
+
148
+ for (const specifier of importSpecifiers(source)) {
149
+ const name = packageName(specifier)
150
+ if (frameworkFallbacks[name]) {
151
+ if (!frameworks.has(name)) {frameworks.set(name, new Set())}
152
+ frameworks.get(name).add(relative(cwd, file) || parse(file).base)
153
+ continue
154
+ }
155
+
156
+ const root = packageRoot(cwd, name, workspaces)
157
+ if (root && workspaces.has(name)) {pending.push(...sourceFiles(root))}
158
+ }
159
+ }
160
+
161
+ const missing = new Map()
162
+ for (const [framework] of frameworks) {
163
+ for (const plugin of frameworkPlugins(cwd, framework, workspaces)) {
164
+ if (!owned.has(plugin)) {
165
+ if (!missing.has(plugin)) {missing.set(plugin, new Set())}
166
+ missing.get(plugin).add(framework)
167
+ }
168
+ }
169
+ }
170
+
171
+ return [...missing.entries()]
172
+ .sort(([a], [b]) => a.localeCompare(b))
173
+ .map(([plugin, frameworks]) => ({ plugin, frameworks: [...frameworks].sort() }))
174
+ }
4
175
 
5
176
  const check = (cmd, args) => {
6
177
  try {
7
- return { ok: true, out: execFileSync(cmd, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n')[0] };
178
+ return {
179
+ ok: true,
180
+ out: execFileSync(cmd, args, {
181
+ encoding: 'utf8',
182
+ timeout: 15000,
183
+ stdio: ['ignore', 'pipe', 'pipe'],
184
+ })
185
+ .trim()
186
+ .split('\n')[0],
187
+ }
8
188
  } catch {
9
- return { ok: false, out: '' };
189
+ return { ok: false, out: '' }
10
190
  }
11
- };
191
+ }
12
192
 
13
193
  /** env checks — the things that have actually bitten this stack. */
14
194
  export const doctor = command({
@@ -16,33 +196,85 @@ export const doctor = command({
16
196
  description: 'Check the toolchain for web + native builds',
17
197
  args: {},
18
198
  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;
199
+ const cwd = process.cwd()
200
+ p.intro('xplat doctor')
201
+ const rows = []
202
+ const row = (name, ok, detail, hint) => rows.push({ name, ok, detail, hint })
203
+
204
+ row('node', check('node', ['--version']).ok, check('node', ['--version']).out)
205
+ row('pnpm', check('pnpm', ['--version']).ok, check('pnpm', ['--version']).out)
206
+ row(
207
+ 'ns CLI',
208
+ check('pnpm', ['exec', 'ns', '--version']).ok,
209
+ check('pnpm', ['exec', 'ns', '--version']).out,
210
+ 'add the nativescript devDep (the starter ships it)',
211
+ )
212
+
213
+ row(
214
+ 'xcodebuild',
215
+ check('xcodebuild', ['-version']).ok,
216
+ check('xcodebuild', ['-version']).out,
217
+ 'iOS needs Xcode — App Store install + xcode-select',
218
+ )
219
+
220
+ row(
221
+ 'xcodeproj gem',
222
+ check('ruby', ['-e', 'require "xcodeproj"']).ok,
223
+ '',
224
+ 'gem install --user-install xcodeproj',
225
+ )
226
+
227
+ const sims = check('xcrun', ['simctl', 'list', 'devices', 'booted'])
228
+ row('iOS simulator', sims.ok, sims.out || 'none booted')
229
+ const adb = check('adb', ['devices'])
230
+ const devices = adb.ok
231
+ ? adb.out
232
+ .split('\n')
233
+ .slice(1)
234
+ .filter((l) => l.includes('\tdevice')).length
235
+ : 0
236
+
237
+ row('adb', adb.ok, `${devices} device(s)`, 'Android SDK platform-tools on PATH')
238
+ row(
239
+ 'ANDROID_HOME',
240
+ !!process.env.ANDROID_HOME,
241
+ process.env.ANDROID_HOME || 'unset',
242
+ 'export ANDROID_HOME=$HOME/Library/Android/sdk',
243
+ )
244
+
245
+ row(
246
+ 'JAVA_HOME',
247
+ !!process.env.JAVA_HOME,
248
+ process.env.JAVA_HOME || 'unset',
249
+ 'JDK 17 (JDK 25 breaks the Android toolchain)',
250
+ )
251
+
252
+ const pluginWarnings = findMissingPluginDeclarations(cwd)
253
+ for (const { plugin, frameworks } of pluginWarnings) {
254
+ p.log.warn(
255
+ `native plugin declaration — ${plugin} is required by ${frameworks.join(', ')}; add it to this app's package.json`,
256
+ )
257
+ }
258
+
259
+ let bad = 0
42
260
  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})` : ''}`); }
261
+ if (r.ok) {
262
+ p.log.success(`${r.name} — ${r.detail || 'ok'}`)
263
+ } else {
264
+ bad++
265
+ p.log.warn(`${r.name} — missing${r.hint ? ` (${r.hint})` : ''}`)
266
+ }
45
267
  }
46
- p.outro(bad === 0 ? 'All checks pass' : `${bad} missing — web still works, native targets need the above`);
268
+
269
+ const summary =
270
+ bad === 0
271
+ ? 'All checks pass'
272
+ : `${bad} missing — web still works, native targets need the above`
273
+
274
+ p.outro(
275
+ pluginWarnings.length
276
+ ? `${summary}; ${pluginWarnings.length} native plugin declaration warning(s)`
277
+ : summary,
278
+ )
47
279
  },
48
- });
280
+ })