@livx.cc/appwrap 0.39.15 → 0.40.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.39.15",
3
+ "version": "0.40.0",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -131,6 +131,21 @@ async function showDevMenu(): Promise<void> {
131
131
  }
132
132
  }
133
133
 
134
+ /** Native build id — iOS `CFBundleVersion` / Android `versionCode`. This is the number the store shows
135
+ * (TestFlight/Play), so it lets a tester confirm exactly which uploaded build is running. */
136
+ function nativeBuild(): string {
137
+ try {
138
+ if (isIOS) return String(NSBundle.mainBundle.objectForInfoDictionaryKey('CFBundleVersion'));
139
+ if (isAndroid) {
140
+ const ctx = Utils.android.getApplicationContext();
141
+ return String(ctx.getPackageManager().getPackageInfo(ctx.getPackageName(), 0).versionCode);
142
+ }
143
+ } catch (e) {
144
+ console.warn('AppWrap: devmenu nativeBuild read failed', e);
145
+ }
146
+ return '?';
147
+ }
148
+
134
149
  async function showInfo(): Promise<void> {
135
150
  // Running web version: prefer what kit.updates reported, else read the page's embedded global
136
151
  // directly — so the line shows for any server-loader app exposing __APP_VERSION__, even if its
@@ -140,7 +155,8 @@ async function showInfo(): Promise<void> {
140
155
  const lines = [
141
156
  `App: ${SHELL_CONFIG.name}`,
142
157
  `ID: ${SHELL_CONFIG.appId}`,
143
- `Shell: ${SHELL_CONFIG.version} (${SHELL_BUILD})`,
158
+ `Version: ${SHELL_CONFIG.version} (build ${nativeBuild()})`, // native CFBundleVersion/versionCode = the store build id
159
+ `Shell: ${SHELL_BUILD}`,
144
160
  `Platform: ${isIOS ? 'iOS' : 'Android'} ${Device.osVersion}`,
145
161
  `Loader: ${SHELL_CONFIG.loader}`,
146
162
  ];
package/src/cli.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * Config (TS preferred, JSON fallback) — probed in order: appwrap.config.ts → .js → appwrap.json.
9
9
  * Shape: { id, name, version, entry?, backgroundColor?, statusBarStyle?, pwaDist }. See config.ts.
10
10
  */
11
- import { execFileSync } from 'child_process';
11
+ import { execFileSync, spawn } from 'child_process';
12
12
  import { cpSync, existsSync, mkdirSync, openSync, closeSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync, writeSync } from 'fs';
13
13
  import { networkInterfaces, tmpdir } from 'os';
14
14
  import { dirname, join, resolve } from 'path';
@@ -1192,14 +1192,14 @@ async function init(cwd: string, flags: Record<string, string>): Promise<void> {
1192
1192
  writeFileSync(join(outDir, '.gitignore'), 'node_modules/\nplatforms/\nhooks/\n');
1193
1193
  applyOverrides(cwd, outDir, cfg); // escape hatch — last, so custom native code wins
1194
1194
  stampVersionManifest(outDir, cfg); // provenance — also marks the dir appwrap-managed
1195
- console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap run ios (or: appwrap run android)`);
1195
+ console.log(`✓ Wrapper ready (generated — gitignore \`${flags.out ?? 'native'}/\`, regenerate with \`appwrap init\`).\n Run it: appwrap dev ios (or: appwrap dev android)`);
1196
1196
  }
1197
1197
 
1198
1198
  // `sync` = the same regenerate as `init`, minus the first-time guard/scaffold. It is a TRUE refresh from
1199
1199
  // source (shell + config + PWA), so runtime/config edits never silently lag behind. `native/` is
1200
1200
  // disposable; re-copying the shell costs ~ms (the real cost is the later `ns build`, which both share).
1201
- async function sync(cwd: string, flags: Record<string, string>): Promise<void> {
1202
- const cfg = await loadConfig(cwd, flags);
1201
+ async function sync(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
1202
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1203
1203
  const outDir = resolve(cwd, flags.out ?? 'native');
1204
1204
  if (!existsSync(outDir)) {
1205
1205
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
@@ -1221,36 +1221,138 @@ function lanIp(): string | null {
1221
1221
  return null;
1222
1222
  }
1223
1223
 
1224
- /** `appwrap dev` — point the existing wrapper at a LIVE url (loader 'server') instead of bundled www.
1225
- * Dev runs their own web server (vite host:true) or a deployed URL; this just stamps the shell config.
1226
- * `--url <url>` explicit; else http://<lan-ip>:<port> (default 5173). Re-run `appwrap sync`/`init` to revert. */
1227
- async function dev(cwd: string, flags: Record<string, string>): Promise<void> {
1224
+ /** Resolve the `--url <devserver>` / `--port <p>` dev-server URL, or null when neither is given.
1225
+ * Explicit `--url` wins; else `http://<lan-ip>:<port>` (port default 5173). Exits if no LAN IP. */
1226
+ function resolveDevUrl(flags: Record<string, string>): string | null {
1227
+ if (!('url' in flags) && !('port' in flags)) return null;
1228
+ if (flags.url) return flags.url;
1229
+ const ip = lanIp();
1230
+ if (!ip) {
1231
+ console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
1232
+ process.exit(1);
1233
+ }
1234
+ return `http://${ip}:${flags.port ?? '5173'}`;
1235
+ }
1236
+
1237
+ /** `--debug` fold-in: open the on-device WebView inspector. Android adb-forwards the devtools socket →
1238
+ * chrome://inspect; iOS prints the Safari Web Inspector path. Best-effort (a non-debug build / not-running
1239
+ * app just gets a hint). Shared by `dev --debug` and the `debug` back-compat alias. */
1240
+ function openInspector(cfg: AppwrapConfig, flags: Record<string, string>, platform: 'ios' | 'android', outDir: string): void {
1241
+ if (platform === 'android') {
1242
+ const adb = androidAdb();
1243
+ const device = resolveDevice(outDir, 'android', flags).id;
1244
+ const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
1245
+ if (pid) {
1246
+ try {
1247
+ execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
1248
+ console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
1249
+ } catch {
1250
+ console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
1251
+ }
1252
+ } else {
1253
+ console.log(`⚠ ${cfg.id} not running yet — open chrome://inspect once it launches.`);
1254
+ }
1255
+ console.log(' (Needs a DEBUG build — `appwrap dev`/`deploy android` installs one with the inspector enabled.)\n');
1256
+ } else {
1257
+ console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.\n');
1258
+ }
1259
+ }
1260
+
1261
+ /** `appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]` — the
1262
+ * live-dev loop. Subsumes the old `run`/`debug` verbs AND the old `dev` (loader:server stamp).
1263
+ *
1264
+ * Default target = the physical DEVICE: clean deploy (== `deploy`, the shared path — NOT reimplemented)
1265
+ * → stay ATTACHED streaming the WebView console + watch project sources → rebuild+reinstall on save.
1266
+ * We MUST NOT use `ns run` livesync on a device — it throws `Invalid version … Got type "object"`, an
1267
+ * ns-internal semver bug we can't fix; so device-dev is deploy + logs + a plain rebuild watch loop.
1268
+ *
1269
+ * Flags:
1270
+ * --sim → emulator/simulator via `ns run` (HMR is reliable there); `--debug` → `ns debug`.
1271
+ * --url/--port→ stamp loader:'server' at that dev-server URL (web hot-reloads inside the WebView), deploy + attach.
1272
+ * --detached → deploy + exit (install & launch only; don't attach/watch).
1273
+ * --debug → also open the WebView inspector (chrome://inspect / Safari), then attach.
1274
+ */
1275
+ async function dev(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1276
+ const platform = positionals[0];
1277
+ const sim = 'sim' in flags || positionals[1] === 'sim';
1278
+ const wantDebug = 'debug' in flags;
1279
+ if (platform !== 'ios' && platform !== 'android') {
1280
+ console.error('Usage: appwrap dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]');
1281
+ process.exit(1);
1282
+ }
1228
1283
  const cfg = await loadConfig(cwd, flags);
1229
1284
  const outDir = resolve(cwd, flags.out ?? 'native');
1230
1285
  if (!existsSync(outDir)) {
1231
1286
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1232
1287
  process.exit(1);
1233
1288
  }
1234
- let url = flags.url;
1235
- if (!url) {
1236
- const ip = lanIp();
1237
- if (!ip) {
1238
- console.error('✖ Could not detect a LAN IP — pass --url http://<host>:<port> explicitly');
1239
- process.exit(1);
1289
+
1290
+ // `--url`/`--port` → point the wrapper at a live dev server (loader:'server', web HMR inside the WebView).
1291
+ const devUrl = resolveDevUrl(flags);
1292
+ // The cfg the deploy/sim path stamps: server-loader when a dev URL is given, else the bundled config.
1293
+ // debug:true here = keep-awake + WebView inspector + the dev-server SSL bypass (LAN self-signed certs).
1294
+ const effectiveCfg: AppwrapConfig = devUrl
1295
+ ? { ...cfg, loader: 'server', serverUrl: devUrl, debug: true }
1296
+ : cfg;
1297
+ if (devUrl) {
1298
+ console.log(`✓ Dev loader → ${devUrl} (web hot-reloads from the dev server inside the WebView)`);
1299
+ console.log(' Dev server must bind 0.0.0.0 (vite: `server.host: true` / `--host`) so the device can reach it.');
1300
+ if (devUrl.startsWith('https:') && platform === 'android') {
1301
+ console.log(" ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS errors (page loads, HMR won't).");
1240
1302
  }
1241
- url = `http://${ip}:${flags.port ?? '5173'}`;
1242
1303
  }
1243
- // Dev is inherently a debug workflow: enables the WebView inspector, keep-awake, and the
1244
- // debug-only dev-server SSL bypass (LAN dev servers use self-signed/mkcert certs the device
1245
- // doesn't trust). Revert to a non-debug, bundled build with `appwrap sync`.
1246
- stampShellConfig(outDir, { ...cfg, loader: 'server', serverUrl: url, debug: true });
1247
- console.log(`✓ Dev loader → ${url} (debug)`);
1248
- console.log(` Web server must bind 0.0.0.0 (vite: \`server.host: true\` / \`--host\`) so the device can reach it.`);
1249
- if (url.startsWith('https:')) {
1250
- console.log(` ⚠ Android: serve the dev server over HTTP, not HTTPS — the WebView can't bypass wss TLS`);
1251
- console.log(` errors, so HMR won't live-reload on-device (the page still loads). iOS is fine with HTTPS.`);
1304
+
1305
+ // ── --sim: emulator/simulator → ns run (HMR) / ns debug. Reliable there; refresh the wrapper first. ──
1306
+ if (sim) {
1307
+ // Preserve an already-active dev loader (stamped by a prior `dev --url`) if no URL was passed now.
1308
+ const stamped = !devUrl ? readStampedLoader(outDir) : null;
1309
+ const simCfg: AppwrapConfig = devUrl
1310
+ ? effectiveCfg
1311
+ : stamped?.loader === 'server'
1312
+ ? { ...cfg, loader: 'server', serverUrl: stamped.serverUrl, debug: stamped.debug }
1313
+ : cfg;
1314
+ regenerateCore(cwd, outDir, simCfg, { flags });
1315
+ applyOverrides(cwd, outDir, simCfg); // overrides win last — same order as sync
1316
+ stampVersionManifest(outDir, simCfg);
1317
+ console.log(simCfg.loader === 'server' ? `✓ Refreshed wrapper (dev loader → ${simCfg.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1318
+ runNs(outDir, [wantDebug ? 'debug' : 'run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
1319
+ return;
1320
+ }
1321
+
1322
+ // ── device: clean deploy (the shared `deploy` path — NO ns livesync) ──
1323
+ await deploy(cwd, flags, [platform], devUrl ? effectiveCfg : undefined);
1324
+ // Follow-ups reuse the just-deployed device from last-device memory — drop an interactive `-d`.
1325
+ const followFlags = { ...flags }; delete followFlags.d;
1326
+
1327
+ if (wantDebug) openInspector(effectiveCfg, followFlags, platform, outDir);
1328
+
1329
+ if ('detached' in flags) {
1330
+ console.log('\n✓ --detached — installed & launched; not attaching/watching.');
1331
+ return;
1332
+ }
1333
+
1334
+ // Attach: stream the WebView console. With a bundled loader we ALSO watch sources → rebuild+reinstall
1335
+ // on save. With a dev-server loader (--url) the web hot-reloads from the server INSIDE the WebView, so
1336
+ // a native rebuild is pointless (and would re-stamp the bundled loader) — just stream the console.
1337
+ if (devUrl) {
1338
+ console.log('\n▶ dev: web hot-reloads from the dev server inside the WebView; streaming the console. Ctrl-C to stop.');
1339
+ await logs(cwd, followFlags, [platform]);
1340
+ return;
1252
1341
  }
1253
- console.log(` Then: appwrap run ios (revert with \`appwrap sync\`)`);
1342
+ console.log(`\n▶ dev: streaming WebView console + watching sources (edit a file → rebuild+reinstall). Ctrl-C to stop.`);
1343
+ const logArgs = [import.meta.path, 'logs', platform];
1344
+ if (followFlags.device) logArgs.push('--device', followFlags.device);
1345
+ if (followFlags.out) logArgs.push('--out', followFlags.out);
1346
+ if (followFlags.config) logArgs.push('--config', followFlags.config);
1347
+ // `detached: true` puts the log child in its OWN process group so we can kill the WHOLE group —
1348
+ // the child is `bun … logs`, which itself spawns `adb logcat`; `logChild.kill()` would only reap the
1349
+ // `bun` and orphan the `adb logcat` grandchild when the signal hits the leader pid (e.g. `kill <pid>`
1350
+ // / a supervisor, not interactive Ctrl-C which signals the group). `process.kill(-pid)` reaps both.
1351
+ const logChild = spawn('bun', logArgs, { stdio: 'inherit', detached: true });
1352
+ const stop = () => { try { if (logChild.pid) process.kill(-logChild.pid); } catch { /* already gone */ } };
1353
+ process.on('exit', stop);
1354
+ process.on('SIGINT', () => { stop(); process.exit(0); });
1355
+ await watchAndRedeploy(cwd, followFlags, platform);
1254
1356
  }
1255
1357
 
1256
1358
  /** Ensure the wrapper's deps are installed (bun — honoring the repo's package manager, so callers
@@ -1301,7 +1403,7 @@ function runNs(outDir: string, args: string[]): void {
1301
1403
  }
1302
1404
 
1303
1405
  /** Read the loader currently stamped into the generated shell (app/shell/config.ts). Used to
1304
- * preserve an ACTIVE `dev` loader across `run` (see run()'s footgun note). Returns null if the
1406
+ * preserve an ACTIVE dev loader across a `dev --sim` refresh. Returns null if the
1305
1407
  * generated config is absent/unreadable — the caller then falls back to the appwrap config. */
1306
1408
  function readStampedLoader(outDir: string): { loader: string; serverUrl: string; debug: boolean } | null {
1307
1409
  try {
@@ -1318,38 +1420,26 @@ function readStampedLoader(outDir: string): { loader: string; serverUrl: string;
1318
1420
  }
1319
1421
  }
1320
1422
 
1321
- /** `appwrap run <ios|android> [--device <id|name>]` — compile + boot the wrapper in a
1322
- * simulator/emulator (or named device) with live reload: the appwrap-managed replacement for raw
1323
- * `ns run`, driven from the PWA project root with deps auto-installed.
1324
- *
1325
- * Refreshes the generated wrapper from the source template + PWA first (same regenerateCore as
1326
- * `sync`/`build`) so framework `runtime/` edits and PWA rebuilds actually reach the device — `run`
1327
- * used to skip this and silently ship the STALE generated shell (the run-without-sync footgun). An
1328
- * ACTIVE `dev` loader (loader:'server', stamped by `appwrap dev`) is preserved so dev→run
1329
- * live-reload isn't clobbered back to the bundled loader. */
1330
- async function run(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
1331
- const platform = positionals[0];
1332
- if (platform !== 'ios' && platform !== 'android') {
1333
- console.error('Usage: appwrap run <ios|android> [--device <id|name>] [--out native]');
1334
- process.exit(1);
1335
- }
1336
- const outDir = resolve(cwd, flags.out ?? 'native');
1337
- if (!existsSync(outDir)) {
1338
- console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1339
- process.exit(1);
1340
- }
1341
- const cfg = await loadConfig(cwd, flags);
1342
- const stamped = readStampedLoader(outDir);
1343
- const devActive = stamped?.loader === 'server';
1344
- // Preserve a live dev loader; otherwise regenerate from the appwrap config (bundled loader).
1345
- const effectiveCfg = devActive
1346
- ? { ...cfg, loader: 'server' as const, serverUrl: stamped!.serverUrl, debug: stamped!.debug }
1347
- : cfg;
1348
- regenerateCore(cwd, outDir, effectiveCfg, { flags });
1349
- applyOverrides(cwd, outDir, effectiveCfg); // overrides win last — same order as sync (else run wipes them)
1350
- stampVersionManifest(outDir, effectiveCfg); // keep the managed-marker / provenance current
1351
- console.log(devActive ? `✓ Refreshed wrapper (preserved dev loader → ${stamped!.serverUrl})` : '✓ Refreshed wrapper from template + PWA');
1352
- runNs(outDir, ['run', platform, ...(flags.device ? ['--device', flags.device] : [])]);
1423
+ /** Lean watch loop for `dev <platform>`: re-run the clean deploy path whenever a project
1424
+ * source file changes (debounced). Skips generated/output dirs. NOT ns livesync — a full rebuild+
1425
+ * reinstall, which is the only device-safe path (see `run`'s note). macOS recursive fs.watch. */
1426
+ async function watchAndRedeploy(cwd: string, flags: Record<string, string>, platform: 'ios' | 'android'): Promise<void> {
1427
+ const { watch } = await import('fs');
1428
+ const ignore = /(^|\/)(native|node_modules|dist|\.git|\.appwrap)(\/|$)/;
1429
+ console.log(`\n👀 watching ${cwd} for changes → rebuild+reinstall on save (Ctrl-C to stop).`);
1430
+ let timer: ReturnType<typeof setTimeout> | undefined;
1431
+ let busy = false;
1432
+ watch(cwd, { recursive: true }, (_evt, file) => {
1433
+ if (!file || ignore.test(String(file)) || busy) return;
1434
+ clearTimeout(timer);
1435
+ timer = setTimeout(async () => {
1436
+ busy = true;
1437
+ console.log(`\n🔁 change: ${file} → redeploying…`);
1438
+ try { await deploy(cwd, flags, [platform]); } catch (e) { console.error(`⚠ redeploy failed: ${(e as Error).message}`); }
1439
+ busy = false;
1440
+ }, 600);
1441
+ });
1442
+ await new Promise<void>(() => { /* run until Ctrl-C */ });
1353
1443
  }
1354
1444
 
1355
1445
  /** `appwrap build <ios|android> [--release] [--aab]` — store-readiness build path. Re-stamps config,
@@ -1748,36 +1838,85 @@ function listIosDevices(): DeviceInfo[] {
1748
1838
  }
1749
1839
  }
1750
1840
 
1751
- /** Pick a device: explicit --device wins; else auto-select the only one; else list + prompt. */
1752
- function pickDevice(devices: DeviceInfo[], explicitId?: string): DeviceInfo {
1753
- if (explicitId) {
1754
- const m = devices.find((d) => d.id === explicitId || d.name === explicitId);
1755
- if (!m) { console.error(`✖ --device "${explicitId}" not found among connected devices.`); process.exit(1); }
1756
- return m;
1757
- }
1758
- if (devices.length === 0) {
1759
- console.error('✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.');
1841
+ // ─── Shared device resolver ───────────────────────────────────────────────────────────────────────
1842
+ // One helper used by deploy/run/debug/logs/publish so every command shares the SAME device-selection
1843
+ // UX + last-device memory. Resolution order:
1844
+ // --device <id|name> → exact match (error if not connected)
1845
+ // -d → always show the interactive list + number prompt
1846
+ // else → the LAST chosen device (persisted) if still connected; else the only one;
1847
+ // else the interactive list; none → clear error. The choice is persisted on
1848
+ // success so the next command (e.g. `run`→`logs`) reuses it without re-asking.
1849
+ // Persist the last device under the wrapper outDir (already gitignored by consumers, like the build
1850
+ // cache) — not the consumer root, so it never shows up as a stray untracked file.
1851
+ const lastDeviceFile = (outDir: string, platform: string) => join(outDir, `.appwrap-last-device-${platform}`);
1852
+ function readLastDevice(outDir: string, platform: string): string | null {
1853
+ try { return readFileSync(lastDeviceFile(outDir, platform), 'utf8').trim() || null; } catch { return null; }
1854
+ }
1855
+ function writeLastDevice(outDir: string, platform: string, id: string): void {
1856
+ try { writeFileSync(lastDeviceFile(outDir, platform), id); } catch { /* non-fatal */ }
1857
+ }
1858
+
1859
+ /** Enumerate connected devices for a platform as a uniform DeviceInfo[] (iOS via devicectl, Android
1860
+ * via adb — adb serials enriched with the product model for a readable picker). */
1861
+ function listDevices(platform: 'ios' | 'android'): DeviceInfo[] {
1862
+ if (platform === 'ios') return listIosDevices();
1863
+ const adb = androidAdb();
1864
+ return listAndroidDevices(adb).map((serial) => {
1865
+ let model = '';
1866
+ try { model = execFileSync(adb, ['-s', serial, 'shell', 'getprop', 'ro.product.model'], { encoding: 'utf8' }).trim(); } catch { /* offline */ }
1867
+ return { id: serial, name: model || serial, model, transport: 'usb' };
1868
+ });
1869
+ }
1870
+
1871
+ /** Interactive number-prompt picker over a device list. */
1872
+ function pickInteractively(devices: DeviceInfo[]): DeviceInfo {
1873
+ // No TTY (CI / piped) → Bun's prompt() returns null → "Invalid selection". Give a clear directive instead.
1874
+ if (!process.stdout.isTTY) {
1875
+ console.error(`✖ ${devices.length} devices connected and no TTY to prompt — pass --device <id>. Connected: ${devices.map((d) => d.id).join(', ')}`);
1760
1876
  process.exit(1);
1761
1877
  }
1762
- if (devices.length === 1) {
1763
- console.log(`📱 Using ${devices[0].name} (${devices[0].model || devices[0].transport})`);
1764
- return devices[0];
1765
- }
1766
- console.log('Multiple devices connected:');
1767
- devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name} — ${d.model || 'iPhone'} [${d.transport}]`));
1878
+ console.log('Connected devices:');
1879
+ devices.forEach((d, i) => console.log(` ${i + 1}) ${d.name}${d.model && d.model !== d.name ? ` — ${d.model}` : ''}${d.transport ? ` [${d.transport}]` : ''} (${d.id})`));
1768
1880
  const ans = (globalThis as { prompt(msg?: string): string | null }).prompt(`Select device [1-${devices.length}]: `);
1769
1881
  const idx = Number(ans) - 1;
1770
- if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) {
1771
- console.error('✖ Invalid selection.'); process.exit(1);
1772
- }
1882
+ if (!Number.isInteger(idx) || idx < 0 || idx >= devices.length) { console.error('✖ Invalid selection.'); process.exit(1); }
1773
1883
  return devices[idx];
1774
1884
  }
1775
1885
 
1886
+ /** Resolve the target device for a platform command (the reusable core). Persists the choice under
1887
+ * `outDir` so the next command (e.g. `run`→`logs`) reuses it. */
1888
+ function resolveDevice(outDir: string, platform: 'ios' | 'android', flags: Record<string, string>): DeviceInfo {
1889
+ const devices = listDevices(platform);
1890
+ const noneMsg = platform === 'ios'
1891
+ ? '✖ No connected iOS device found. Plug in via USB (unlocked, "Trust") or pair over Wi-Fi.'
1892
+ : '✖ No authorized Android device. Connect via USB + accept the "Allow USB debugging" prompt (check with `adb devices`).';
1893
+
1894
+ // --device <id|name> — exact (or unambiguous prefix) match against connected devices.
1895
+ if (flags.device) {
1896
+ const m = devices.find((d) => d.id === flags.device || d.name === flags.device) ?? devices.find((d) => d.id.startsWith(flags.device));
1897
+ if (!m) { console.error(`✖ --device "${flags.device}" not connected/authorized. Connected: ${devices.map((d) => d.id).join(', ') || '(none)'}`); process.exit(1); }
1898
+ writeLastDevice(outDir, platform, m.id);
1899
+ return m;
1900
+ }
1901
+ if (devices.length === 0) { console.error(noneMsg); process.exit(1); }
1902
+
1903
+ // -d → always prompt. Otherwise prefer the remembered device, then the sole device.
1904
+ if (!('d' in flags)) {
1905
+ const last = readLastDevice(outDir, platform);
1906
+ const remembered = last ? devices.find((d) => d.id === last) : undefined;
1907
+ if (remembered) { console.log(`📱 Using ${remembered.name} (${remembered.id}) — last used.`); return remembered; }
1908
+ if (devices.length === 1) { console.log(`📱 Using ${devices[0].name} (${devices[0].id}) — only device connected.`); writeLastDevice(outDir, platform, devices[0].id); return devices[0]; }
1909
+ }
1910
+ const picked = pickInteractively(devices);
1911
+ writeLastDevice(outDir, platform, picked.id);
1912
+ return picked;
1913
+ }
1914
+
1776
1915
  /** `appwrap deploy <ios|android> [--device <id|name>] [--no-launch]` — build for a device, auto-pick
1777
1916
  * the connected phone (USB or network; prompts if several), install + launch. Debug build (no
1778
1917
  * distribution signing) — for testing on your own device. Run the PWA build first (or via the script).
1779
- * iOS has a bespoke Debug-IPA path (below); Android delegates to `run` (NativeScript builds + installs
1780
- * + launches), so the `deploy <platform>` surface is symmetric across both. */
1918
+ * iOS has a bespoke Debug-IPA path (below); Android uses the adb toolchain (build → install → launch).
1919
+ * `dev` calls THIS shared path for its clean device deploy — deploy is the one-shot ship primitive. */
1781
1920
  /** The project's web-build command: explicit `webBuild` in config, else `bun run build` if the
1782
1921
  * project's package.json has a "build" script. null when there's nothing to run. */
1783
1922
  function detectWebBuildCmd(cwd: string, cfg: AppwrapConfig): string[] | null {
@@ -1835,42 +1974,22 @@ function listAndroidDevices(adb: string): string[] {
1835
1974
  }
1836
1975
  }
1837
1976
 
1838
- /** Pick the target Android device (fail fast, like iOS's pickDevice): explicit --device, else the
1839
- * single connected one; clear errors for none / multiple / unauthorized. */
1840
- function pickAndroidDevice(adb: string, flag?: string): string {
1841
- const devices = listAndroidDevices(adb);
1842
- if (flag) {
1843
- if (devices.includes(flag)) return flag;
1844
- console.error(`✖ Device "${flag}" not connected/authorized. Authorized: ${devices.join(', ') || '(none)'}`);
1845
- process.exit(1);
1846
- }
1847
- if (devices.length === 0) {
1848
- console.error('✖ No authorized Android device. Connect via USB + accept the "Allow USB debugging" prompt (check with `adb devices`).');
1849
- process.exit(1);
1850
- }
1851
- if (devices.length > 1) {
1852
- console.error(`✖ Multiple devices — pass --device <id>: ${devices.join(', ')}`);
1853
- process.exit(1);
1854
- }
1855
- return devices[0];
1856
- }
1857
-
1858
1977
  /** `appwrap deploy android` — ONE-SHOT device deploy, the Android twin of `deploy ios`: sync + debug
1859
1978
  * config → `ns build android` (debug APK) → `adb install -r` to the device → launch (unless --no-launch)
1860
1979
  * → exit. Unlike `run android` (ns watch-mode), it doesn't stay attached. NOTE: like `deploy ios`, it
1861
1980
  * ships the CURRENT `dist/` — build the web first (`bun run build`, or use the `bun run android` script). */
1862
- async function deployAndroid(cwd: string, flags: Record<string, string>): Promise<void> {
1863
- const cfg = await loadConfig(cwd, flags);
1981
+ async function deployAndroid(cwd: string, flags: Record<string, string>, cfgOverride?: AppwrapConfig): Promise<void> {
1982
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1864
1983
  const outDir = resolve(cwd, flags.out ?? 'native');
1865
1984
  if (!existsSync(outDir)) {
1866
1985
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1867
1986
  process.exit(1);
1868
1987
  }
1869
1988
  const adb = androidAdb();
1870
- const device = pickAndroidDevice(adb, flags.device || undefined); // fail fast before the build
1989
+ const device = resolveDevice(outDir, 'android', flags).id; // shared resolver (fail fast before the build)
1871
1990
 
1872
1991
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle; server → skip (both printed)
1873
- await sync(cwd, flags); // re-stamp config + copy latest PWA dist
1992
+ await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist
1874
1993
  stampShellConfig(outDir, { ...cfg, debug: true }); // debug: keep-awake + WebView inspector (parity with deploy ios)
1875
1994
 
1876
1995
  const apk = join(outDir, 'platforms/android/app/build/outputs/apk/debug/app-debug.apk');
@@ -1891,8 +2010,11 @@ async function deployAndroid(cwd: string, flags: Record<string, string>): Promis
1891
2010
 
1892
2011
  console.log(`▶ installing → ${device}`);
1893
2012
  try {
1894
- // Capture (not inherit) so we can recognize the MIUI install-restriction and print guidance.
1895
- const out = execFileSync(adb, ['-s', device, 'install', '-r', apk], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
2013
+ // `--user 0` (primary user) is the robust install target: on MIUI/Xiaomi a BARE `adb install` is
2014
+ // silently auto-denied ("user is restricted from installing apps" — no popup), but scoping it to
2015
+ // user 0 succeeds. On normal single-user devices it's a no-op (install already targets user 0).
2016
+ // Capture (not inherit) so we can still surface guidance if it fails for another reason.
2017
+ const out = execFileSync(adb, ['-s', device, 'install', '-r', '--user', '0', apk], { encoding: 'utf8', stdio: ['inherit', 'pipe', 'pipe'] });
1896
2018
  process.stdout.write(out);
1897
2019
  } catch (e: unknown) {
1898
2020
  const log = execErrText(e);
@@ -1920,28 +2042,28 @@ async function deployAndroid(cwd: string, flags: Record<string, string>): Promis
1920
2042
  console.log(`✓ Deployed to ${device}.`);
1921
2043
  }
1922
2044
 
1923
- async function deploy(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2045
+ async function deploy(cwd: string, flags: Record<string, string>, positionals: string[], cfgOverride?: AppwrapConfig): Promise<void> {
1924
2046
  const platform = positionals[0];
1925
2047
  if (platform === 'android') {
1926
2048
  // Parity with `deploy ios`: a clean ONE-SHOT build → install-to-device → launch → exit (NOT `ns run`,
1927
2049
  // which is watch-mode + hangs on some devices). Mirrors the iOS path with the adb toolchain.
1928
- return deployAndroid(cwd, flags);
2050
+ return deployAndroid(cwd, flags, cfgOverride);
1929
2051
  }
1930
2052
  if (platform !== 'ios') {
1931
2053
  console.error('Usage: appwrap deploy <ios|android> [--device <id|name>] [--no-launch]');
1932
2054
  process.exit(1);
1933
2055
  }
1934
- const cfg = await loadConfig(cwd, flags);
2056
+ const cfg = cfgOverride ?? await loadConfig(cwd, flags);
1935
2057
  const outDir = resolve(cwd, flags.out ?? 'native');
1936
2058
  if (!existsSync(outDir)) {
1937
2059
  console.error(`✖ Wrapper not found at ${outDir} — run \`appwrap init\` first`);
1938
2060
  process.exit(1);
1939
2061
  }
1940
2062
  // Pick the device up front so we fail fast before a long build if nothing's connected.
1941
- const device = pickDevice(listIosDevices(), flags.device || undefined);
2063
+ const device = resolveDevice(outDir, 'ios', flags);
1942
2064
 
1943
2065
  buildWebIfBundled(cwd, cfg, flags); // bundled → fresh web bundle (no stale dist); server → skip (printed)
1944
- await sync(cwd, flags); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
2066
+ await sync(cwd, flags, cfgOverride); // re-stamp config + copy latest PWA dist (+ vendor backend assets)
1945
2067
  // Dev deploy → debug mode: keep-awake + WebView inspector for continuous troubleshooting.
1946
2068
  stampShellConfig(outDir, { ...cfg, debug: true });
1947
2069
 
@@ -2124,11 +2246,12 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2124
2246
  process.exit(1);
2125
2247
  }
2126
2248
  const cfg = await loadConfig(cwd, flags);
2249
+ const outDir = resolve(cwd, flags.out ?? 'native'); // for last-device memory (shared resolver)
2127
2250
 
2128
2251
  // ── Android: adb logcat — WebView console (chromium tag) by default; --native = full app logcat ──
2129
2252
  if (platform === 'android') {
2130
2253
  const adb = androidAdb();
2131
- const device = pickAndroidDevice(adb, flags.device || undefined);
2254
+ const device = resolveDevice(outDir, 'android', flags).id;
2132
2255
  const once = 'once' in flags;
2133
2256
  if ('native' in flags) {
2134
2257
  const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
@@ -2156,7 +2279,7 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2156
2279
  return;
2157
2280
  }
2158
2281
 
2159
- const device = pickDevice(listIosDevices(), flags.device || undefined);
2282
+ const device = resolveDevice(outDir, 'ios', flags);
2160
2283
  const dest = join(tmpdir(), `appwrap-weblog-${process.pid}.log`);
2161
2284
  const pull = (): string => {
2162
2285
  try {
@@ -2188,40 +2311,53 @@ async function logs(cwd: string, flags: Record<string, string>, positionals: str
2188
2311
  }
2189
2312
  }
2190
2313
 
2191
- /** CLI dispatch. Guarded by `import.meta.main` so importing this module (e.g. for the `AppwrapConfig`
2192
- * type via the package entry) doesn't run a command. */
2193
- /** `appwrap debug <ios|android>` — point at the WebView inspector for the (debug-built) app, then
2194
- * stream its console. Debug builds enable the inspector (deploy installs one); this just opens the
2195
- * door + tails logs — no rebuild. Android: adb-forwards the WebView devtools socket → chrome://inspect.
2196
- * iOS: prints the Safari Web Inspector path. Both then fall through to `logs` for the live console. */
2197
- async function debug(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2314
+ /** `appwrap publish <ios|android> [prod]` — distribution. DEFAULT = BETA (iOS TestFlight via the
2315
+ * proven `release` lane; Android → Play internal track via the mcp-appstores `android-upload` CLI).
2316
+ * `prod` → store (iOS App Store via `submit`; Android Play production track). Consolidates the existing
2317
+ * `release`/`submit` (kept as aliases). Android upload reuses the same contract as the CI release
2318
+ * workflow: a signed AAB from `build android --release --aab` + `APPSTORES_REGISTRY` env for the Play
2319
+ * service-account/package mapping (see the emitted appwrap-release-android.yml). */
2320
+ async function publish(cwd: string, flags: Record<string, string>, positionals: string[]): Promise<void> {
2198
2321
  const platform = positionals[0];
2322
+ const prod = positionals[1] === 'prod';
2199
2323
  if (platform !== 'ios' && platform !== 'android') {
2200
- console.error('Usage: appwrap debug <ios|android> [--device <id|name>]');
2324
+ console.error('Usage: appwrap publish <ios|android> [prod] (default: beta — TestFlight / Play internal)');
2201
2325
  process.exit(1);
2202
2326
  }
2203
- const cfg = await loadConfig(cwd, flags);
2204
- if (platform === 'android') {
2205
- const adb = androidAdb();
2206
- const device = pickAndroidDevice(adb, flags.device || undefined);
2207
- const pid = (() => { try { return execFileSync(adb, ['-s', device, 'shell', 'pidof', cfg.id], { encoding: 'utf8' }).trim().split(/\s+/)[0]; } catch { return ''; } })();
2208
- if (pid) {
2209
- try {
2210
- execFileSync(adb, ['-s', device, 'forward', 'tcp:9222', `localabstract:webview_devtools_remote_${pid}`], { stdio: 'pipe' });
2211
- console.log('✓ WebView devtools forwarded → open chrome://inspect (or http://localhost:9222) in desktop Chrome to inspect the page.');
2212
- } catch {
2213
- console.log('⚠ Could not forward the devtools socket — open chrome://inspect and look for the device there.');
2214
- }
2215
- } else {
2216
- console.log(`⚠ ${cfg.id} not running — launch it (or \`appwrap deploy android\`), then open chrome://inspect.`);
2217
- }
2218
- console.log(' (Needs a DEBUG build — `appwrap deploy android` installs one with the inspector enabled.)\n');
2219
- } else {
2220
- console.log('▶ iOS WebView inspector: Safari → Develop → [your iPhone] → [the app]. Enable it first in iOS Settings → Safari → Advanced → Web Inspector.');
2221
- console.log(' (Needs a DEBUG build — `appwrap deploy ios` installs one.)\n');
2327
+ if (platform === 'ios') {
2328
+ // iOS rides the proven fastlane path unchanged: beta → TestFlight, prod → App Store promote.
2329
+ return release(cwd, flags, ['ios'], prod ? 'release' : 'beta');
2330
+ }
2331
+
2332
+ // ── Android: build a signed AAB, then upload via the mcp-appstores CLI (Play Developer API). ──
2333
+ const track = flags.track || (prod ? 'production' : 'internal');
2334
+ console.log(`▶ appwrap build android --release --aab (for Play ${track} track)`);
2335
+ await build(cwd, { ...flags, release: '', aab: '' }, ['android']);
2336
+ const aab = join(resolve(cwd, flags.out ?? 'native'), 'platforms/android/app/build/outputs/bundle/release/app-release.aab');
2337
+ if (!existsSync(aab)) { console.error(`✖ No AAB produced at ${aab}`); process.exit(1); }
2338
+
2339
+ if (!process.env.APPSTORES_REGISTRY) {
2340
+ console.error(
2341
+ '\n✖ Android publish needs the Play upload contract (same as the CI release workflow):\n' +
2342
+ ' • APPSTORES_REGISTRY env — JSON mapping org→serviceAccountPath + app→packageName.\n' +
2343
+ ' • A Play service-account JSON + the app already created in the Play Console (one prior manual release).\n' +
2344
+ ` The signed AAB is ready: ${aab}\n` +
2345
+ ' Then: APPSTORES_ALLOW_WRITES=true bunx @livx.cc/mcp-appstores android-upload \\\n' +
2346
+ ` --org <org> --app <app> --file "${aab}" --track ${track} --status completed`
2347
+ );
2348
+ process.exit(1);
2349
+ }
2350
+ const org = flags.org || (await loadConfig(cwd, flags)).id;
2351
+ const app = flags.app || 'app';
2352
+ console.log(`▶ bunx @livx.cc/mcp-appstores android-upload --org ${org} --app ${app} --track ${track}`);
2353
+ try {
2354
+ execFileSync('bunx', ['@livx.cc/mcp-appstores', 'android-upload', '--org', org, '--app', app, '--file', aab, '--track', track, '--status', 'completed'],
2355
+ { cwd, stdio: 'inherit', env: { ...process.env, APPSTORES_ALLOW_WRITES: process.env.APPSTORES_ALLOW_WRITES ?? 'true' } });
2356
+ } catch {
2357
+ console.error(`\n✖ Play upload failed. Check APPSTORES_REGISTRY (org "${org}", app "${app}") + the service-account permissions. The AAB is ready: ${aab}`);
2358
+ process.exit(1);
2222
2359
  }
2223
- // Tail the live console (reuses logs' per-platform streaming).
2224
- await logs(cwd, flags, positionals);
2360
+ console.log(`✓ Uploaded to Play ${track} track.`);
2225
2361
  }
2226
2362
 
2227
2363
  async function main(): Promise<void> {
@@ -2236,10 +2372,10 @@ async function main(): Promise<void> {
2236
2372
  await sync(cwd, flags);
2237
2373
  break;
2238
2374
  case 'dev':
2239
- await dev(cwd, flags);
2375
+ await dev(cwd, flags, positionals);
2240
2376
  break;
2241
- case 'run':
2242
- await run(cwd, flags, positionals);
2377
+ case 'run': // hidden back-compat alias → dev
2378
+ await dev(cwd, flags, positionals);
2243
2379
  break;
2244
2380
  case 'build':
2245
2381
  await build(cwd, flags, positionals);
@@ -2247,29 +2383,34 @@ async function main(): Promise<void> {
2247
2383
  case 'deploy':
2248
2384
  await deploy(cwd, flags, positionals);
2249
2385
  break;
2250
- case 'release':
2386
+ case 'publish':
2387
+ await publish(cwd, flags, positionals);
2388
+ break;
2389
+ case 'release': // alias: publish <ios|android> (beta)
2251
2390
  await release(cwd, flags, positionals, 'beta');
2252
2391
  break;
2253
- case 'submit':
2392
+ case 'submit': // alias: publish <ios> prod
2254
2393
  await release(cwd, flags, positionals, 'release');
2255
2394
  break;
2256
2395
  case 'logs':
2257
2396
  await logs(cwd, flags, positionals);
2258
2397
  break;
2259
- case 'debug':
2260
- await debug(cwd, flags, positionals);
2398
+ case 'debug': // hidden back-compat alias → dev --debug
2399
+ await dev(cwd, { ...flags, debug: '' }, positionals);
2261
2400
  break;
2262
2401
  default:
2263
- console.log('Usage: appwrap <init|sync|dev|run|build|deploy|release|submit|logs|debug> [--config <path>] [--out native]\n' +
2402
+ console.log('Usage: appwrap <init|sync|dev|build|deploy|publish|logs> [--config <path>] [--out native]\n' +
2264
2403
  ' config: appwrap.config.ts (preferred) → .js → appwrap.json\n' +
2265
- ' run <ios|android> [--device <id|name>] (compile + boot in a simulator/emulator, live reload)\n' +
2266
- ' deploy <ios|android> [--device <id|name>] [--no-launch] [--no-web-build] [-f] (build → install to device → launch; builds web if bundled)\n' +
2267
- ' build <ios|android> [--release] [--aab] (store artifact)\n' +
2268
- ' release ios [--server-url <url>] [--env <name>] [--build-number <n>] (build+sign+upload to TestFlight)\n' +
2269
- ' submit ios [--build-number <n>] [--submit-for-review] (promote the binary to the App Store; metadata stays in ASC)\n' +
2404
+ ' Device selection (dev/deploy/logs/publish): --device <id|name> | -d (pick from a list) | else last-used / sole device.\n\n' +
2405
+ ' dev <ios|android> [--sim] [--detached] [--debug] [--url <devserver>|--port <p>]\n' +
2406
+ ' live-dev: DEVICE → clean deploy + stream console + watch sources (rebuild on save).\n' +
2407
+ ' --sim = ns run/HMR on emulator; --url/--port = web HMR from a dev server inside the WebView;\n' +
2408
+ ' --detached = install & launch then exit; --debug = also open the WebView inspector.\n' +
2409
+ ' deploy <ios|android> [--no-launch] [--no-web-build] [-f] (clean ship-once: build → install → launch → exit)\n' +
2410
+ ' publish <ios|android> [prod] (beta: TestFlight / Play internal. prod: App Store / Play production)\n' +
2411
+ ' build <ios|android> [--release] [--aab] (store artifact only — no install/upload)\n' +
2270
2412
  ' logs <ios|android> [--once] [--native] (stream WebView console; --native = full OS log)\n' +
2271
- ' debug <ios|android> [--device <id|name>] (open the WebView inspector + stream console)\n' +
2272
- ' dev [--url <url> | --port <p>]');
2413
+ ' aliases: `release ios` = `publish ios`; `submit ios` = `publish ios prod`.');
2273
2414
  process.exit(command ? 1 : 0);
2274
2415
  }
2275
2416
  }