@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 +35 -0
- package/package.json +11 -5
- package/src/cli.mjs +10 -9
- package/src/commands/build.mjs +47 -25
- package/src/commands/clean.mjs +22 -12
- package/src/commands/dev.mjs +47 -22
- package/src/commands/doctor.mjs +265 -33
- package/src/commands/routes.mjs +203 -0
- package/src/commands/typecheck.mjs +22 -11
- package/src/procs.mjs +22 -16
- package/src/targets.mjs +62 -30
- package/src/vite.d.ts +20 -0
- package/src/vite.mjs +286 -0
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.
|
|
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
|
-
"
|
|
13
|
-
|
|
14
|
-
"
|
|
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)
|
package/src/commands/build.mjs
CHANGED
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { command, flag, option, optional, string } from '@alloc/cmd-ts'
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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({
|
|
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
|
-
|
|
41
|
-
|
|
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 =
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
76
|
+
|
|
77
|
+
p.log.success('Build complete')
|
|
56
78
|
},
|
|
57
|
-
})
|
|
79
|
+
})
|
package/src/commands/clean.mjs
CHANGED
|
@@ -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) {
|
|
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) {
|
|
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
|
-
|
|
21
|
-
p.log.success(`Cleaned ${found.join(', ')}`)
|
|
30
|
+
|
|
31
|
+
p.log.success(`Cleaned ${found.join(', ')}`)
|
|
22
32
|
},
|
|
23
|
-
})
|
|
33
|
+
})
|
package/src/commands/dev.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
import { command, option, optional, string } from '@alloc/cmd-ts'
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
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(
|
|
27
|
-
|
|
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(
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
+
})
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -1,14 +1,194 @@
|
|
|
1
|
-
import { command } from '@alloc/cmd-ts'
|
|
2
|
-
import { execFileSync } from 'node:child_process'
|
|
3
|
-
import
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
row('
|
|
25
|
-
row('
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'
|
|
29
|
-
|
|
30
|
-
'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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)
|
|
44
|
-
|
|
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
|
-
|
|
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
|
+
})
|