@octane-xplat/cli 0.1.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/package.json +19 -0
- package/src/cli.mjs +15 -0
- package/src/commands/build.mjs +57 -0
- package/src/commands/clean.mjs +23 -0
- package/src/commands/dev.mjs +55 -0
- package/src/commands/doctor.mjs +48 -0
- package/src/commands/typecheck.mjs +23 -0
- package/src/procs.mjs +33 -0
- package/src/targets.mjs +78 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octane-xplat/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "xplat — dev/build/doctor for octane-xplat apps (web + iOS + Android from one codebase)",
|
|
6
|
+
"bin": {
|
|
7
|
+
"xplat": "./src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@alloc/cmd-ts": "0.17.1",
|
|
14
|
+
"@clack/prompts": "1.8.1"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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';
|
|
8
|
+
|
|
9
|
+
const cli = subcommands({
|
|
10
|
+
name: 'xplat',
|
|
11
|
+
description: 'One Octane codebase → web + iOS + Android',
|
|
12
|
+
cmds: { dev, build, typecheck, doctor, clean },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
await run(binary(cli), process.argv);
|
|
@@ -0,0 +1,57 @@
|
|
|
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';
|
|
5
|
+
|
|
6
|
+
export const build = command({
|
|
7
|
+
name: 'build',
|
|
8
|
+
description: 'Build for web, iOS, Android',
|
|
9
|
+
args: {
|
|
10
|
+
release: flag({ long: 'release', description: 'Native release builds (signed where configured)' }),
|
|
11
|
+
targets: option({
|
|
12
|
+
long: 'targets',
|
|
13
|
+
short: 't',
|
|
14
|
+
type: optional(string),
|
|
15
|
+
description: 'Comma list (web,ios,android) — skips the prompt',
|
|
16
|
+
}),
|
|
17
|
+
},
|
|
18
|
+
handler: async (args) => {
|
|
19
|
+
const cwd = process.cwd();
|
|
20
|
+
const all = buildTargets(cwd);
|
|
21
|
+
if (all.length === 0) {
|
|
22
|
+
p.log.error('Nothing to build — no vite.config.ts or nativescript.config.ts found.');
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let chosen;
|
|
27
|
+
if (args.targets) {
|
|
28
|
+
const kinds = args.targets.split(',').map((s) => s.trim());
|
|
29
|
+
chosen = all.filter((t) => kinds.includes(t.kind));
|
|
30
|
+
} else if (!process.stdout.isTTY) {
|
|
31
|
+
chosen = all;
|
|
32
|
+
} else {
|
|
33
|
+
p.intro('xplat build');
|
|
34
|
+
const picked = await p.multiselect({
|
|
35
|
+
message: 'Build targets',
|
|
36
|
+
options: all.map((t) => ({ value: t.id, label: t.name })),
|
|
37
|
+
initialValues: all.map((t) => t.id),
|
|
38
|
+
required: true,
|
|
39
|
+
});
|
|
40
|
+
if (p.isCancel(picked)) { p.cancel('Cancelled'); process.exit(0); }
|
|
41
|
+
chosen = all.filter((t) => picked.includes(t.id));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const t of chosen) {
|
|
45
|
+
const argv = t.kind === 'web'
|
|
46
|
+
? ['exec', 'vite', 'build']
|
|
47
|
+
: ['exec', 'ns', 'build', t.kind, ...(args.release ? ['--release'] : [])];
|
|
48
|
+
try {
|
|
49
|
+
await runTagged(t.kind, 'pnpm', argv, cwd);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
p.log.error(String(e));
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
p.log.success('Build complete');
|
|
56
|
+
},
|
|
57
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { command } from '@alloc/cmd-ts';
|
|
2
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
|
|
5
|
+
const DIRS = ['dist', 'platforms', '.ns-vite-build', 'node_modules/.vite'];
|
|
6
|
+
|
|
7
|
+
export const clean = command({
|
|
8
|
+
name: 'clean',
|
|
9
|
+
description: 'Remove build outputs (dist, platforms, vite caches)',
|
|
10
|
+
args: {},
|
|
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; }
|
|
15
|
+
|
|
16
|
+
if (process.stdout.isTTY) {
|
|
17
|
+
const ok = await p.confirm({ message: `Remove ${found.join(', ')}?` });
|
|
18
|
+
if (p.isCancel(ok) || !ok) { p.cancel('Cancelled'); return; }
|
|
19
|
+
}
|
|
20
|
+
for (const d of found) rmSync(`${cwd}/${d}`, { recursive: true, force: true });
|
|
21
|
+
p.log.success(`Cleaned ${found.join(', ')}`);
|
|
22
|
+
},
|
|
23
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
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';
|
|
5
|
+
|
|
6
|
+
const spawnFor = (t, cwd) =>
|
|
7
|
+
t.kind === 'web'
|
|
8
|
+
? spawnTagged('web', 'pnpm', ['exec', 'vite'], cwd)
|
|
9
|
+
: spawnTagged(t.kind, 'pnpm', ['exec', 'ns', 'run', t.kind, '--device', t.device], cwd);
|
|
10
|
+
|
|
11
|
+
export const dev = command({
|
|
12
|
+
name: 'dev',
|
|
13
|
+
description: 'Run dev servers — web, iOS simulators, Android emulators/devices',
|
|
14
|
+
args: {
|
|
15
|
+
targets: option({
|
|
16
|
+
long: 'targets',
|
|
17
|
+
short: 't',
|
|
18
|
+
type: optional(string),
|
|
19
|
+
description: 'Comma list (web,ios,android) — skips the prompt',
|
|
20
|
+
}),
|
|
21
|
+
},
|
|
22
|
+
handler: async (args) => {
|
|
23
|
+
const cwd = process.cwd();
|
|
24
|
+
const all = discoverTargets(cwd);
|
|
25
|
+
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);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let chosen;
|
|
31
|
+
if (args.targets) {
|
|
32
|
+
const kinds = args.targets.split(',').map((s) => s.trim());
|
|
33
|
+
chosen = all.filter((t) => kinds.includes(t.kind));
|
|
34
|
+
if (chosen.length === 0) {
|
|
35
|
+
p.log.error(`No targets matched "${args.targets}". Available: ${all.map((t) => t.kind).join(', ')}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
} else if (!process.stdout.isTTY) {
|
|
39
|
+
chosen = all; // non-interactive: everything detected
|
|
40
|
+
} else {
|
|
41
|
+
p.intro('xplat dev');
|
|
42
|
+
const picked = await p.multiselect({
|
|
43
|
+
message: 'Dev targets',
|
|
44
|
+
options: all.map((t) => ({ value: t.id, label: t.name })),
|
|
45
|
+
initialValues: all.map((t) => t.id),
|
|
46
|
+
required: true,
|
|
47
|
+
});
|
|
48
|
+
if (p.isCancel(picked)) { p.cancel('Cancelled'); process.exit(0); }
|
|
49
|
+
chosen = all.filter((t) => picked.includes(t.id));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const t of chosen) spawnFor(t, cwd);
|
|
53
|
+
p.log.success(`${chosen.length} target(s) running — Ctrl+C stops all`);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { command } from '@alloc/cmd-ts';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import * as p from '@clack/prompts';
|
|
4
|
+
|
|
5
|
+
const check = (cmd, args) => {
|
|
6
|
+
try {
|
|
7
|
+
return { ok: true, out: execFileSync(cmd, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'pipe'] }).trim().split('\n')[0] };
|
|
8
|
+
} catch {
|
|
9
|
+
return { ok: false, out: '' };
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** env checks — the things that have actually bitten this stack. */
|
|
14
|
+
export const doctor = command({
|
|
15
|
+
name: 'doctor',
|
|
16
|
+
description: 'Check the toolchain for web + native builds',
|
|
17
|
+
args: {},
|
|
18
|
+
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;
|
|
42
|
+
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})` : ''}`); }
|
|
45
|
+
}
|
|
46
|
+
p.outro(bad === 0 ? 'All checks pass' : `${bad} missing — web still works, native targets need the above`);
|
|
47
|
+
},
|
|
48
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
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';
|
|
5
|
+
|
|
6
|
+
export const typecheck = command({
|
|
7
|
+
name: 'typecheck',
|
|
8
|
+
description: 'tsrx-tsc --noEmit for every tsconfig present',
|
|
9
|
+
args: {},
|
|
10
|
+
handler: async () => {
|
|
11
|
+
const cwd = process.cwd();
|
|
12
|
+
const configs = ['tsconfig.json', 'tsconfig.native.json'].filter((f) => existsSync(`${cwd}/${f}`));
|
|
13
|
+
for (const c of configs) {
|
|
14
|
+
try {
|
|
15
|
+
await runTagged('tsc', 'pnpm', ['exec', 'tsrx-tsc', '--noEmit', '-p', c], cwd);
|
|
16
|
+
} catch (e) {
|
|
17
|
+
p.log.error(String(e));
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
p.log.success('Typecheck clean');
|
|
22
|
+
},
|
|
23
|
+
});
|
package/src/procs.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Prefixed process plumbing — dev runs several tools side by side, so each
|
|
2
|
+
// gets a short tag on its output lines. SIGINT fans out to every child.
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
const children = new Set();
|
|
6
|
+
|
|
7
|
+
process.on('SIGINT', () => {
|
|
8
|
+
for (const p of children) p.kill('SIGINT');
|
|
9
|
+
process.exit(130);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
/** Spawn long-running, output prefixed `[tag]`. */
|
|
13
|
+
export function spawnTagged(tag, cmd, args, cwd) {
|
|
14
|
+
const p = spawn(cmd, args, { cwd, env: process.env });
|
|
15
|
+
children.add(p);
|
|
16
|
+
const prefix = (chunk) => {
|
|
17
|
+
for (const line of chunk.toString().split('\n')) {
|
|
18
|
+
if (line.trim()) process.stdout.write(`[${tag}] ${line}\n`);
|
|
19
|
+
}
|
|
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
|
+
/** Spawn to completion, output prefixed `[tag]`. Resolves on exit 0. */
|
|
28
|
+
export function runTagged(tag, cmd, args, cwd) {
|
|
29
|
+
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
|
+
});
|
|
33
|
+
}
|
package/src/targets.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Target + device discovery. Everything degrades quietly — a missing
|
|
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';
|
|
5
|
+
|
|
6
|
+
const run = (cmd, args) => {
|
|
7
|
+
try {
|
|
8
|
+
return execFileSync(cmd, args, { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9
|
+
} catch {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
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`);
|
|
16
|
+
|
|
17
|
+
/** iOS targets: booted sims first, then other available sims, then physical devices. */
|
|
18
|
+
export function iosTargets() {
|
|
19
|
+
const out = run('xcrun', ['simctl', 'list', 'devices', 'available', '-j']);
|
|
20
|
+
if (!out) return [];
|
|
21
|
+
try {
|
|
22
|
+
const j = JSON.parse(out);
|
|
23
|
+
const sims = [];
|
|
24
|
+
for (const list of Object.values(j.devices ?? {})) {
|
|
25
|
+
for (const d of list) {
|
|
26
|
+
if (!d.isAvailable) continue;
|
|
27
|
+
sims.push({
|
|
28
|
+
kind: 'ios',
|
|
29
|
+
id: d.udid,
|
|
30
|
+
name: d.name + (d.state === 'Booted' ? ' (booted)' : ''),
|
|
31
|
+
device: d.udid,
|
|
32
|
+
booted: d.state === 'Booted',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// Booted first — that's almost always the one you mean.
|
|
37
|
+
return sims.sort((a, b) => (b.booted ? 1 : 0) - (a.booted ? 1 : 0));
|
|
38
|
+
} catch {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Android targets: emulators + physical devices from adb. */
|
|
44
|
+
export function androidTargets() {
|
|
45
|
+
const out = run('adb', ['devices']);
|
|
46
|
+
if (!out) return [];
|
|
47
|
+
return out
|
|
48
|
+
.split('\n')
|
|
49
|
+
.slice(1)
|
|
50
|
+
.map((l) => l.trim())
|
|
51
|
+
.filter((l) => l.endsWith('\tdevice'))
|
|
52
|
+
.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
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every launchable target for this project + machine. */
|
|
60
|
+
export function discoverTargets(cwd) {
|
|
61
|
+
const targets = [];
|
|
62
|
+
if (hasWeb(cwd)) targets.push({ kind: 'web', id: 'web', name: 'Web (vite :5200)' });
|
|
63
|
+
if (hasNative(cwd)) {
|
|
64
|
+
targets.push(...iosTargets(), ...androidTargets());
|
|
65
|
+
}
|
|
66
|
+
return targets;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Distinct platform buckets for `build` — one entry per platform, no device picks. */
|
|
70
|
+
export function buildTargets(cwd) {
|
|
71
|
+
const t = [];
|
|
72
|
+
if (hasWeb(cwd)) t.push({ kind: 'web', id: 'web', name: 'Web (vite build)' });
|
|
73
|
+
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)' });
|
|
76
|
+
}
|
|
77
|
+
return t;
|
|
78
|
+
}
|