@aarwitz/tapp 0.17.0-rc.9 → 0.17.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/bin/tapp.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // tapp CLI — ship with proof.
2
+ // Tapp CLI — agent-driven app testing on real app surfaces.
3
3
  //
4
4
  // Zero-config verbs (the same engine the MCP tools use, exported by mcp-server/src/index.js):
5
5
  // tapp explore <bundleId|appId|url> Autonomous exploration → findings + evidence (observation)
@@ -28,8 +28,8 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
28
  const packageRoot = path.resolve(__dirname, "..");
29
29
  const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
30
30
 
31
- // Redirect all writable output away from the (possibly read-only) package dir.
32
- // The old environment alias remains a read-only fallback for older integrations.
31
+ // Redirect all writable output away from the (possibly read-only) package dir. TAPP_HOME and
32
+ // ~/.tapp are the only current runtime locations; retired environment/path aliases are ignored.
33
33
  const tappHome = (process.env.TAPP_HOME || path.join(os.homedir(), ".tapp")).trim();
34
34
  process.env.TAPP_HOME = tappHome;
35
35
  // TAPP_HOME is created lazily (just before the switch) so `--help`, `help`, and `version` never
@@ -53,6 +53,19 @@ function bad(label, detail = "") {
53
53
  console.log(` ❌ ${label}${detail ? ` — ${detail}` : ""}`);
54
54
  }
55
55
 
56
+ let lastProgressLine = "";
57
+ function writeProgress(line) {
58
+ const text = String(line || "").trimEnd();
59
+ if (process.stderr.isTTY) process.stderr.write(`\r${text} `);
60
+ else if (text && text !== lastProgressLine) console.error(text);
61
+ lastProgressLine = text;
62
+ }
63
+
64
+ function finishProgress() {
65
+ if (process.stderr.isTTY && lastProgressLine) process.stderr.write("\n");
66
+ lastProgressLine = "";
67
+ }
68
+
56
69
  function bootedSims() {
57
70
  const r = run("xcrun", ["simctl", "list", "devices", "booted", "-j"]);
58
71
  if (r.code !== 0) return [];
@@ -155,7 +168,7 @@ const engineImport = () => import(path.join(packageRoot, "mcp-server", "src", "i
155
168
 
156
169
  function requireMacFor(what) {
157
170
  if (process.platform === "darwin") return;
158
- console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: tapp explore https://localhost:3000`);
171
+ console.error(`❌ ${what} requires macOS (Xcode + iOS simulator). The web beta runs anywhere: npx -y @aarwitz/tapp@latest explore https://localhost:3000`);
159
172
  process.exit(1);
160
173
  }
161
174
 
@@ -206,6 +219,26 @@ function printEngineError(r) {
206
219
  }
207
220
  }
208
221
 
222
+ async function promptForInitTarget(details) {
223
+ const choices = Array.isArray(details?.choices) ? details.choices : [];
224
+ if (!choices.length || !process.stdin.isTTY || !process.stderr.isTTY || process.env.CI) return null;
225
+ const { createInterface } = await import("node:readline/promises");
226
+ const terminal = createInterface({ input: process.stdin, output: process.stderr });
227
+ console.error("\nTapp found multiple application targets. Which one should it explore?");
228
+ choices.forEach((choice, index) => console.error(` ${index + 1}) ${choice.platform} · ${choice.name} (${choice.sourcePath})`));
229
+ try {
230
+ while (true) {
231
+ const answer = String(await terminal.question(`Select 1-${choices.length} (or q to cancel): `)).trim();
232
+ if (/^(?:q|quit|cancel)$/i.test(answer)) return null;
233
+ const selected = Number(answer);
234
+ if (Number.isInteger(selected) && selected >= 1 && selected <= choices.length) return choices[selected - 1];
235
+ console.error(`Enter a number from 1 to ${choices.length}, or q to cancel.`);
236
+ }
237
+ } finally {
238
+ terminal.close();
239
+ }
240
+ }
241
+
209
242
  // Turn whatever the user gave us (nothing / repo dir / .app / bundle id) into an installed
210
243
  // bundle id, narrating build/install progress on stderr.
211
244
  async function resolveTargetOrExit(engine, input) {
@@ -220,14 +253,14 @@ async function resolveTargetOrExit(engine, input) {
220
253
 
221
254
  function safeCommandUsage(verb) {
222
255
  const usage = {
223
- explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
224
- init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--dry-run]",
256
+ explore: "tapp explore [target] [--platform ios|android|web] [--actions N] [--timeout SEC] [--email VALUE] [--password VALUE] [--baseline FILE] [--json FILE]\n Web: [--watch] opens Tapp's controlled browser and shows its actions\n iOS launch configuration: [--launch-arg VALUE ...] [--launch-env '{\"KEY\":\"VALUE\"}']\n Android: [--app-id ID] [--apk FILE] [--serial ID] [--keep-data]",
257
+ init: "tapp init [repo] [--explore] [--refresh] [--platform PLATFORM] [--target NAME] [--url URL] [--watch] [--dry-run]",
225
258
  open: "tapp open [target] [--platform ios|android|web] [--out FILE] [--tap TEXT] [--wait-for TEXT]",
226
259
  tree: "tapp tree [target] [--platform ios|android|web] [--json] [--tap TEXT] [--wait-for TEXT]",
227
260
  shot: "tapp shot [--out FILE]",
228
261
  apps: "tapp apps",
229
262
  build: "tapp build [repo] [--scheme NAME] [--configuration NAME]",
230
- flow: "tapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
263
+ flow: "tapp flow example\ntapp flow validate FILE [--platform PLATFORM] [--map FILE]\ntapp flow run FILE [--email VALUE] [--password VALUE]",
231
264
  task: "tapp task validate FILE [--platform PLATFORM] [--map FILE]\ntapp task compile FILE --platform PLATFORM [--inputs JSON] [--out FILE]\ntapp task run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID] [--inputs JSON]",
232
265
  contract: "tapp contract validate FILE [--platform PLATFORM] [--map FILE]\ntapp contract compile FILE --platform PLATFORM [--out FILE]\ntapp contract run FILE --platform PLATFORM [--url URL|--bundle-id ID|--app-id ID]",
233
266
  scenario: "tapp scenario validate FILE [--project-dir DIR]\ntapp scenario run FILE --platform web --url URL [--project-dir DIR]",
@@ -259,9 +292,8 @@ const safeHelpRequested = (rest.includes("--help") || rest.includes("-h"))
259
292
  && !["help", "version", "--version", "-v"].includes(command)
260
293
  && (command !== "ci" || rest[0] === "install");
261
294
  if (safeHelpRequested) {
262
- console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Full command reference:\n`);
263
- command = "help";
264
- rest = [];
295
+ console.log(`Usage:\n ${safeCommandUsage(command).replaceAll("\n", "\n ")}\n\nℹ️ --help never builds, launches, writes, or opens. Run \`tapp --help\` for the full command reference.`);
296
+ process.exit(0);
265
297
  }
266
298
 
267
299
  // Create TAPP_HOME only for commands that actually use it — never for help/version/--help.
@@ -302,7 +334,7 @@ switch (command) {
302
334
  }
303
335
  const platform = typeof flags.platform === "string" ? flags.platform.toLowerCase()
304
336
  : typeof flags.url === "string" ? "web"
305
- : typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "ios";
337
+ : typeof flags["app-id"] === "string" || typeof flags.apk === "string" ? "android" : "";
306
338
  if (explore && platform === "ios") requireMacFor("iOS init exploration");
307
339
  const actions = flags.actions === undefined ? 40 : Number(flags.actions);
308
340
  const timeout = flags.timeout === undefined ? 600 : Number(flags.timeout);
@@ -312,51 +344,85 @@ switch (command) {
312
344
  }
313
345
  const engine = explore ? await engineImport() : null;
314
346
  const { initializeProductProject } = await import(path.join(packageRoot, "mcp-server", "src", "product-operations.js"));
347
+ const initOptions = {
348
+ projectDir,
349
+ mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
350
+ ownedUrl: typeof flags.url === "string" ? flags.url : "",
351
+ platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
352
+ target: typeof flags.target === "string" ? flags.target : "",
353
+ bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
354
+ appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
355
+ apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
356
+ serial: typeof flags.serial === "string" ? flags.serial : undefined,
357
+ maxActions: actions,
358
+ timeout,
359
+ testEmail: typeof flags.email === "string" ? flags.email : undefined,
360
+ testPassword: typeof flags.password === "string" ? flags.password : undefined,
361
+ watch: flags.watch === true,
362
+ runExploration: engine?.runInitExploration,
363
+ onProgress: (progress) => {
364
+ const activePlatform = progress.platform || platform;
365
+ writeProgress(`🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${activePlatform === "web" ? "pages reached" : activePlatform === "ios" ? "structural states observed" : "screens reached"}`);
366
+ },
367
+ onStatus: (status) => console.error(`⏳ ${status}`),
368
+ outDir,
369
+ maxContracts,
370
+ };
315
371
  let result;
372
+ let failure = null;
316
373
  try {
317
- result = await initializeProductProject({
318
- projectDir,
319
- mode: flags["dry-run"] === true ? "inspect" : explore ? "explore" : flags.refresh === true ? "refresh" : "write",
320
- ownedUrl: typeof flags.url === "string" ? flags.url : "",
321
- platform: typeof flags.platform === "string" ? flags.platform.toLowerCase() : explore ? platform : "",
322
- target: typeof flags.target === "string" ? flags.target : projectDir,
323
- bundleId: typeof flags["bundle-id"] === "string" ? flags["bundle-id"] : "",
324
- appId: typeof flags["app-id"] === "string" ? flags["app-id"] : "",
325
- apkPath: typeof flags.apk === "string" ? path.resolve(flags.apk) : undefined,
326
- serial: typeof flags.serial === "string" ? flags.serial : undefined,
327
- maxActions: actions,
328
- timeout,
329
- testEmail: typeof flags.email === "string" ? flags.email : undefined,
330
- testPassword: typeof flags.password === "string" ? flags.password : undefined,
331
- runExploration: engine?.runInitExploration,
332
- onProgress: (progress) => process.stderr.write(`\r🔍 Import exploration… ${progress.action}/${progress.max || actions} actions · ${progress.states} ${platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached"} `),
333
- onStatus: (status) => console.error(`⏳ ${status}`),
334
- outDir,
335
- maxContracts,
336
- });
374
+ result = await initializeProductProject(initOptions);
337
375
  } catch (error) {
338
- if (explore) process.stderr.write("\n");
339
- console.error(`❌ Could not initialize repository: ${error.message || String(error)}`);
376
+ failure = error;
377
+ const choice = explore && error.details?.reason === "target-selection-required"
378
+ ? await promptForInitTarget(error.details)
379
+ : null;
380
+ if (choice) {
381
+ if (choice.platform === "ios") requireMacFor("iOS init exploration");
382
+ console.error(`🎯 Exploring ${choice.platform}:${choice.name} (${choice.sourcePath})`);
383
+ try {
384
+ result = await initializeProductProject({ ...initOptions, platform: choice.platform, target: choice.selector });
385
+ failure = null;
386
+ } catch (retryError) {
387
+ failure = retryError;
388
+ }
389
+ }
390
+ }
391
+ if (failure) {
392
+ if (explore) finishProgress();
393
+ printEngineError({ error: `Could not initialize repository: ${failure.message || String(failure)}`, details: failure.details || {} });
340
394
  process.exit(2);
341
395
  }
342
- if (explore) process.stderr.write("\n");
396
+ if (explore) finishProgress();
343
397
  const built = { model: result.model, plan: result.plan };
344
398
  const written = result.written;
345
399
  const exploration = result.exploration;
346
400
  if (typeof flags["json-out"] === "string") {
347
401
  const out = path.resolve(flags["json-out"]);
348
402
  fs.mkdirSync(path.dirname(out), { recursive: true });
349
- fs.writeFileSync(out, JSON.stringify({ model: built.model, plan: written?.plan || built.plan, ...(exploration ? { exploration } : {}) }, null, 2) + "\n");
350
- }
351
- const blocking = built.model.requirements.filter((item) => item.severity === "blocking");
403
+ fs.writeFileSync(out, JSON.stringify({
404
+ model: built.model,
405
+ plan: written?.plan || built.plan,
406
+ ...(exploration ? { exploration } : {}),
407
+ ...(result.selectedTarget ? { selectedTarget: result.selectedTarget } : {}),
408
+ requirementScope: result.requirementScope,
409
+ }, null, 2) + "\n");
410
+ }
411
+ const activeRequirements = result.requirementScope?.active || built.model.requirements;
412
+ const deferredRequirements = result.requirementScope?.deferred || [];
413
+ const blocking = activeRequirements.filter((item) => item.severity === "blocking");
414
+ const deferredBlocking = deferredRequirements.filter((item) => item.severity === "blocking");
352
415
  const pending = (written?.plan || built.plan).items.filter((item) => item.decision === "pending");
353
416
  console.log(`🧭 Tapp init — ${built.model.application.name}`);
354
417
  console.log(` targets: ${built.model.targets.length ? built.model.targets.map((target) => `${target.platform}:${target.name}`).join(", ") : "none"}`);
355
418
  console.log(` UI Map: ${built.model.uiMap.status} · ${built.model.uiMap.nodeCount} states · ${built.model.uiMap.edgeCount} transitions`);
356
419
  if (exploration) console.log(` Exploration: ${(exploration.findings || []).length} finding(s)${exploration.inconclusive ? " (inconclusive)" : ""} · ${exploration.uiMap.nodeCount} states · evidence: ${exploration.reportHtml || exploration.capture?.path || "capture recorded"}`);
357
420
  if (exploration?.managedRuntime) console.log(` Managed web runtime: built/started ${exploration.target} for exploration and stopped it afterward · log: ${exploration.runtime.logPath}`);
358
- console.log(` release plan: ${(written?.plan || built.plan).items.length} item(s) · ${pending.length} pending review · ${blocking.length} blocking requirement(s)`);
359
- for (const requirement of built.model.requirements) console.log(` ${requirement.severity === "blocking" ? "❌" : "⚠️"} ${requirement.message} Next: ${requirement.remediation}`);
421
+ const selectedLabel = result.selectedTarget ? ` for ${result.selectedTarget.platform}:${result.selectedTarget.name}` : "";
422
+ const deferredLabel = deferredBlocking.length ? ` · ${deferredBlocking.length} setup gap(s) on unselected target(s)` : "";
423
+ console.log(` release plan: ${(written?.plan || built.plan).items.length} item(s) · ${pending.length} pending review · ${blocking.length} blocking requirement(s)${selectedLabel}${deferredLabel}`);
424
+ for (const requirement of activeRequirements) console.log(` ${requirement.severity === "blocking" ? "❌" : "⚠️"} ${requirement.message} Next: ${requirement.remediation}`);
425
+ for (const requirement of deferredRequirements) console.log(` ℹ️ Unselected ${requirement.targetPlatform}:${requirement.targetName} setup gap: ${requirement.message} Next: ${requirement.remediation}`);
360
426
  if (written) console.log(` model: ${written.modelPath}\n plan: ${written.planPath}`);
361
427
  else console.log(" dry run: repository files were not changed");
362
428
  break;
@@ -411,7 +477,10 @@ switch (command) {
411
477
  testPassword: typeof flags.password === "string" ? flags.password : undefined,
412
478
  startWebTarget: engine.startManagedWebTarget,
413
479
  stopWebTarget: engine.stopManagedWebTarget,
414
- onProgress: (entry) => { if (entry.text) console.error(`⏳ ${entry.text}`); },
480
+ // Contract execution is emitted in full on stdout below. Keep build/runtime/replay
481
+ // status live on stderr, but do not echo the execution transcript there as well — an
482
+ // interactive terminal merges the streams and would otherwise show every result twice.
483
+ onProgress: (entry) => { if (entry.text && entry.phase !== "execute") console.error(`⏳ ${entry.text}`); },
415
484
  });
416
485
  } catch (error) {
417
486
  console.error(`❌ Could not validate contract drafts: ${error.message || String(error)}`);
@@ -490,7 +559,7 @@ switch (command) {
490
559
  const modelPlatform = typeof flags.platform === "string" ? flags.platform.toLowerCase() : "";
491
560
  if (modelPlatform === "ios") requireMacFor("iOS testing");
492
561
  const onProgress = (p) =>
493
- process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed `);
562
+ writeProgress(`🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} states observed`);
494
563
  const r = await engine.runExploreTarget({
495
564
  projectDir: process.cwd(),
496
565
  platform: modelPlatform,
@@ -501,11 +570,12 @@ switch (command) {
501
570
  testPassword: flags.password,
502
571
  ...launchOptions,
503
572
  baselineFindings,
573
+ watch: flags.watch === true,
504
574
  surface: "cli",
505
575
  onProgress,
506
576
  onStatus: (t) => console.error(`ℹ️ ${t}`),
507
577
  });
508
- process.stderr.write("\n");
578
+ finishProgress();
509
579
  if (r.error) { printEngineError(r); process.exit(1); }
510
580
  console.log(r.text);
511
581
  if (flags.json && typeof flags.json === "string") {
@@ -521,19 +591,23 @@ switch (command) {
521
591
  process.exit(2);
522
592
  }
523
593
  if (platform === "ios") requireMacFor("iOS testing");
594
+ if (flags.watch === true && platform !== "web") {
595
+ console.error("❌ --watch is currently available for web exploration only");
596
+ process.exit(2);
597
+ }
524
598
  if (platform !== "ios" && Object.keys(launchOptions).length) {
525
599
  console.error("❌ --launch-arg and --launch-env apply only to iOS targets");
526
600
  process.exit(2);
527
601
  }
528
602
  if (platform === "web" && !/^https?:\/\//i.test(target)) {
529
- console.error("❌ Web QA needs an http(s) URL");
603
+ console.error("❌ Web exploration needs an http(s) URL");
530
604
  process.exit(2);
531
605
  }
532
606
  const bundleId = platform === "ios" ? await resolveTargetOrExit(engine, target) : null;
533
607
  const android = platform === "android" ? androidTarget(flags, target) : null;
534
608
  const progressMetric = platform === "web" ? "pages reached" : platform === "ios" ? "structural states observed" : "screens reached";
535
609
  const onProgress = (p) =>
536
- process.stderr.write(`\r🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric} `);
610
+ writeProgress(`🔍 Exploring… ${p.action}/${p.max || flags.actions || 60} actions · ${p.states} ${progressMetric}`);
537
611
  const r = platform === "web"
538
612
  ? await engine.runQaWeb({
539
613
  url: target,
@@ -542,6 +616,7 @@ switch (command) {
542
616
  testEmail: flags.email,
543
617
  testPassword: flags.password,
544
618
  baselineFindings,
619
+ watch: flags.watch === true,
545
620
  surface: "cli",
546
621
  onProgress,
547
622
  })
@@ -565,7 +640,7 @@ switch (command) {
565
640
  surface: "cli",
566
641
  onProgress,
567
642
  });
568
- process.stderr.write("\n");
643
+ finishProgress();
569
644
  if (r.error) {
570
645
  printEngineError(r);
571
646
  process.exit(1);
@@ -776,8 +851,22 @@ switch (command) {
776
851
  printEngineError(inst);
777
852
  process.exit(1);
778
853
  }
854
+ let modelRefresh = null;
855
+ try {
856
+ const { persistIosBuildValidation } = await import(path.join(packageRoot, "mcp-server", "src", "application-model.js"));
857
+ modelRefresh = await persistIosBuildValidation({
858
+ projectDir: dir,
859
+ bundleId: inst.bundleId,
860
+ container: built.container,
861
+ scheme: built.scheme,
862
+ configuration: built.configuration,
863
+ });
864
+ } catch (error) {
865
+ console.error(`⚠️ Build succeeded, but Tapp could not refresh the existing application model: ${error.message || String(error)}`);
866
+ }
779
867
  console.log(`🔨 Built ${path.basename(built.appPath)} (scheme ${built.scheme}) — installed as ${inst.bundleId}`);
780
- console.log(`\nNext: tapp explore ${inst.bundleId}`);
868
+ if (modelRefresh) console.log(` application model refreshed: ${modelRefresh.modelPath}`);
869
+ console.log(`\nNext: npx -y @aarwitz/tapp@latest explore ${inst.bundleId}`);
781
870
  break;
782
871
  }
783
872
 
@@ -860,8 +949,12 @@ switch (command) {
860
949
  const { flags, positionals } = parseVerbArgs(rest);
861
950
  const verb = positionals[0] || "run";
862
951
  const flowPath = positionals[1] || (verb === "run" || verb === "validate" ? "" : verb);
952
+ if (verb === "example") {
953
+ console.log(`# Tapp Flow — deterministic, keyless replay\nname: sign-in-smoke\nplatform: web\nurl: https://example.test/login\nsteps:\n - login:\n email: $TEST_EMAIL\n password: $TEST_PASSWORD\n - wait_for: Dashboard\n - assert_screen: Dashboard\n`);
954
+ break;
955
+ }
863
956
  if (!["run", "validate"].includes(verb) || !flowPath) {
864
- console.error("usage: tapp flow run <flow.yml> [--platform ios|android|web] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
957
+ console.error("usage: tapp flow example\n tapp flow run <flow.yml> [--platform ios|android|web] [--url URL] [--app-id ID] [--apk FILE] [--serial ID]\n tapp flow validate <flow.yml>");
865
958
  process.exit(2);
866
959
  }
867
960
  const absolute = path.resolve(flowPath);
@@ -991,7 +1084,7 @@ switch (command) {
991
1084
  try {
992
1085
  const adopted = adoptPrCoverageProposal({ projectDir, prPlanPath, item: flags.item, releasePlanPath: typeof flags["release-plan"] === "string" ? flags["release-plan"] : undefined });
993
1086
  console.log(`📥 ${adopted.mode === "reconciled-existing" ? "Reconciled PR evidence into" : "Adopted"} ${adopted.item.name}${adopted.mode === "reconciled-existing" ? ` while preserving decision '${adopted.item.decision}'` : " as a pending release-plan item"}; no Task or contract was generated or trusted`);
994
- console.log(` plan: ${adopted.path}\n next: tapp plan review ${adopted.path} --approve ${adopted.item.id}`);
1087
+ console.log(` plan: ${adopted.path}\n next: npx -y @aarwitz/tapp@latest plan review ${adopted.path} --approve ${adopted.item.id}`);
995
1088
  } catch (error) { console.error(`❌ Could not adopt PR coverage proposal: ${error.message || String(error)}`); process.exit(2); }
996
1089
  break;
997
1090
  }
@@ -1151,6 +1244,13 @@ switch (command) {
1151
1244
  const python = run("python3", ["--version"]);
1152
1245
  python.code === 0 ? ok("python3", `${python.stdout} (used by Flows)`) : bad("python3", "not found — Flow replay needs python3 + pyyaml (everything else works)");
1153
1246
 
1247
+ const { storagePreflight } = await import(path.join(packageRoot, "mcp-server", "src", "environment-preflight.js"));
1248
+ const storage = storagePreflight(tappHome);
1249
+ if (storage.level === "blocked") { bad("Disk space", storage.message); healthy = false; }
1250
+ else if (storage.level === "warning") console.log(` ⚠️ Disk space — ${storage.message}`);
1251
+ else if (storage.level === "ok") ok("Disk space", storage.message);
1252
+ else console.log(` ⬜ Disk space — ${storage.message || "could not be checked"}`);
1253
+
1154
1254
  console.log("\n Platforms:");
1155
1255
  if (process.platform === "darwin") {
1156
1256
  const xcode = run("xcode-select", ["-p"]);
@@ -1160,7 +1260,7 @@ switch (command) {
1160
1260
  const booted = bootedSims();
1161
1261
  ok("iOS", `${ver || "Xcode"}; ${booted.length ? `${booted[0].name} booted` : "no simulator booted yet"}`);
1162
1262
  const xctestrun = harnessXctestrun();
1163
- xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: tapp install)");
1263
+ xctestrun ? ok("iOS harness cache", xctestrun) : console.log(" ⬜ iOS harness cache — builds on first use (or: npx -y @aarwitz/tapp@latest install)");
1164
1264
  } else {
1165
1265
  console.log(" ⬜ iOS — unavailable (install Xcode + simulator runtime)");
1166
1266
  }
@@ -1179,16 +1279,22 @@ switch (command) {
1179
1279
  }
1180
1280
 
1181
1281
  try {
1182
- await import("playwright");
1183
- ok("Web", "Playwright installed");
1282
+ const { chromium } = await import("playwright");
1283
+ let executable = "";
1284
+ try { executable = chromium.executablePath(); } catch { /* report the missing browser below */ }
1285
+ if (executable && fs.existsSync(executable)) {
1286
+ ok("Web", `Playwright + Chromium (${executable})`);
1287
+ } else {
1288
+ console.log(" ⬜ Web — Playwright installed; Chromium browser missing (run: npx playwright install chromium)");
1289
+ }
1184
1290
  } catch {
1185
1291
  console.log(" ⬜ Web — install Playwright in the app workspace: npm install -D playwright && npx playwright install chromium");
1186
1292
  }
1187
1293
 
1188
1294
  console.log(`\n Home: ${tappHome}`);
1189
1295
  console.log(healthy
1190
- ? "\nReady. Start with:\n npx -y @aarwitz/tapp open [target]\n npx -y @aarwitz/tapp explore [target]"
1191
- : "\nFix the ❌ items above, then re-run: tapp doctor");
1296
+ ? "\nReady. Start with:\n npx -y @aarwitz/tapp@latest open [target]\n npx -y @aarwitz/tapp@latest explore [target]"
1297
+ : "\nFix the ❌ items above, then re-run: npx -y @aarwitz/tapp@latest doctor");
1192
1298
  process.exit(healthy ? 0 : 1);
1193
1299
  }
1194
1300
 
@@ -1263,7 +1369,13 @@ switch (command) {
1263
1369
  console.log(`✅ Actor '${name}' configured — ${result.actor.session} session · ${result.actor.provisioning} provisioning`);
1264
1370
  console.log(` ${result.path}`);
1265
1371
  console.log(` bindings: ${Object.entries(result.actor.credentials).map(([key, binding]) => `${key}=$${binding.env}`).join(", ") || "none"}`);
1266
- console.log(" No credential values were accepted or written. Rerun tapp init --refresh to update the application model.");
1372
+ const { refreshExistingInitArtifacts } = await import(path.join(packageRoot, "mcp-server", "src", "application-model.js"));
1373
+ let refreshed = null;
1374
+ let refreshWarning = "";
1375
+ try { refreshed = await refreshExistingInitArtifacts({ projectDir }); }
1376
+ catch (error) { refreshWarning = error.message || String(error); }
1377
+ console.log(` No credential values were accepted or written.${refreshed ? ` Application model refreshed: ${refreshed.modelPath}` : " Run npx -y @aarwitz/tapp@latest init when you are ready to create the application model."}`);
1378
+ if (refreshWarning) console.error(`⚠️ Actor was saved, but Tapp could not refresh the existing application model: ${refreshWarning}`);
1267
1379
  } catch (error) { console.error(`❌ Actor not configured: ${error.message || String(error)}`); process.exit(2); }
1268
1380
  break;
1269
1381
  }
@@ -1278,7 +1390,7 @@ switch (command) {
1278
1390
  const projectDir = fs.realpathSync(path.resolve(positionals[1] || (typeof flags["project-dir"] === "string" ? flags["project-dir"] : process.cwd())));
1279
1391
  const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
1280
1392
  if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
1281
- console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore, review/generate/validate/promote the plan, then create the baseline.`);
1393
+ console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run npx -y @aarwitz/tapp@latest init --explore, review/generate/validate/promote the plan, then create the baseline.`);
1282
1394
  process.exit(2);
1283
1395
  }
1284
1396
  let model;
@@ -1385,7 +1497,7 @@ switch (command) {
1385
1497
  catch { console.error(`❌ Repository directory not found: ${positionals[0] || flags["project-dir"] || process.cwd()}`); process.exit(2); }
1386
1498
  const modelPath = typeof flags.model === "string" ? path.resolve(projectDir, flags.model) : existingProjectArtifactPath(projectDir, "application-model.json");
1387
1499
  if (!modelPath.startsWith(projectDir + path.sep) || !fs.existsSync(modelPath)) {
1388
- console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run tapp init --explore first.`);
1500
+ console.error(`❌ Application model not found inside the repository: ${modelPath}\n Run npx -y @aarwitz/tapp@latest init --explore first.`);
1389
1501
  process.exit(2);
1390
1502
  }
1391
1503
  const actionRef = typeof flags["action-ref"] === "string" ? flags["action-ref"] : `aarwitz/tapp@v${pkg.version}`;
@@ -1502,17 +1614,17 @@ switch (command) {
1502
1614
  }
1503
1615
 
1504
1616
  default: {
1505
- console.log(`tapp v${pkg.version} — ship with proof. Autonomous exploration and deterministic release gates for iOS, Android, and web.
1617
+ console.log(`tapp v${pkg.version} — agent-driven app testing for iOS, Android, and web.
1506
1618
 
1507
- Core — explore, prove, gate (agents and humans can just run these — no server, no setup):
1619
+ Core — inspect, explore, gate (no Tapp account or server required):
1508
1620
  tapp explore [target] Autonomous exploration → findings + evidence (an observation, NOT a
1509
- release decision — run 'tapp ci' to gate a merge)
1510
- (--platform ios|android|web · --app-id ID · --apk FILE · --actions N)
1621
+ release decision — run 'npx -y @aarwitz/tapp@latest ci' to gate a merge)
1622
+ (web: --watch · all: --platform ios|android|web · --actions N)
1511
1623
  tapp contract run FILE Replay a business-level release contract — the guarantees that must hold
1512
1624
  tapp ci ... Merge-blocking release gate — explore + suites + baseline → pass/fail/inconclusive
1513
1625
  (see: tapp ci --help)
1514
1626
 
1515
- Primitives — an agent's eyes and hands (no setup):
1627
+ Primitives — an agent's eyes and hands:
1516
1628
  tapp open [target] Launch the app → screen summary + screenshot saved to a file
1517
1629
  (web: --tap TEXT · --wait-for TEXT · --out FILE)
1518
1630
  tapp tree [target] Accessibility tree of the current screen (--json for every element)
@@ -1528,6 +1640,7 @@ Repository & release:
1528
1640
  tapp actor list [repo] Inspect named actors, sessions, provisioning, and secret env bindings
1529
1641
 
1530
1642
  Advanced — deterministic suites, lifecycle & compilers:
1643
+ tapp flow example Print a complete starter Flow YAML (no target or MCP required)
1531
1644
  tapp flow run FILE Replay a committed deterministic Flow (no AI/API key)
1532
1645
  tapp flow validate FILE Validate a Flow without launching a target
1533
1646
  tapp task validate FILE Validate a reusable deterministic Task (+ optional UI Map grounding)
@@ -1567,10 +1680,15 @@ Setup:
1567
1680
  tapp doctor Check Xcode / simulators / toolchain
1568
1681
  tapp mcp Start the MCP server on stdio (adds inline screenshots + interactive sessions)
1569
1682
 
1683
+ Agent Skill (recommended — so a short “Use Tapp to test this app” prompt is enough):
1684
+ Any supported agent: npx -y skills add aarwitz/tapp --skill tapp
1685
+ Claude skill + MCP: claude plugin marketplace add aarwitz/tapp
1686
+ claude plugin install tapp@tapp
1687
+
1570
1688
  MCP hookup (optional — for inline screenshots and the tap/type/inspect session loop):
1571
- Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp mcp
1689
+ Claude Code: claude mcp add tapp -- npx -y @aarwitz/tapp@latest mcp
1572
1690
  Cursor/VS Code (mcp.json):
1573
- { "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp", "mcp"] } } }
1691
+ { "servers": { "tapp": { "type": "stdio", "command": "npx", "args": ["-y", "@aarwitz/tapp@latest", "mcp"] } } }
1574
1692
 
1575
1693
  Then ask your agent things like:
1576
1694
  "Explore com.mycompany.app and show me what breaks"
@@ -12,7 +12,7 @@ in [`PRODUCT-ENGINE.md`](PRODUCT-ENGINE.md).
12
12
  ## Start locally
13
13
 
14
14
  ```bash
15
- npx -y @aarwitz/tapp app
15
+ npx -y @aarwitz/tapp@latest app
16
16
  ```
17
17
 
18
18
  Tapp prints an authenticated one-time launch URL and opens it in the default browser. Use
@@ -16,6 +16,15 @@ resolution actually builds and installs the detected Xcode container, the model
16
16
  scheme as runtime-observed validation and removes the corresponding confirmation blocker. Merely
17
17
  supplying a bundle id or prebuilt `.app` does not prove repository build configuration.
18
18
 
19
+ A repository with more than one detected application target is never resolved by detection order
20
+ during explicit initialization, even when a prior default exists. Bare `tapp init . --explore`
21
+ prompts in a human TTY; non-interactive CLI/MCP callers receive
22
+ exact `--platform`/`--target` commands before any build or write, and MCP also carries them as
23
+ structured `target-selection-required` choices. The selected run records that target as the default
24
+ for later bare `tapp explore`, but the application model retains the repository's other detected
25
+ targets and their unmet coverage. The selected init run reports other-target setup gaps as deferred
26
+ information rather than presenting them as failures of the target that was actually explored.
27
+
19
28
  ## First inspection
20
29
 
21
30
  ```bash
@@ -49,8 +58,8 @@ tapp init . --refresh --explore --platform web --url http://127.0.0.1:3000
49
58
 
50
59
  MCP clients use `tapp_init` with `operation: inspect|write|refresh|explore`. `inspect` is the safe
51
60
  default. `explore` writes real evidence, so the CLI rejects `--explore --dry-run`; the CLI also
52
- requires `--refresh --explore` once model/plan artifacts exist. Credentials are passed only to the
53
- runtime and are never written into the model, map, or plan.
61
+ refreshes existing model/plan artifacts through the same decision-preserving semantics. Credentials
62
+ are passed only to the runtime and are never written into the model, map, or plan.
54
63
 
55
64
  Successful repository-driven iOS build validation is portable and durable. The application model
56
65
  stores the repository-relative container, scheme, configuration, bundle id, and a
@@ -227,7 +236,7 @@ tapp baseline create . --platform web
227
236
  tapp baseline create . --platform web --from /path/to/tapp-report.json
228
237
 
229
238
  # Generate one target-aware job per model target plus a machine-readable manifest.
230
- tapp ci install . --action-ref aarwitz/tapp@v0.13.1
239
+ tapp ci install . --action-ref aarwitz/tapp@v0.17.0
231
240
  ```
232
241
 
233
242
  Baseline creation rejects non-gate JSON, missing or mismatched target identity, platform mismatch,
@@ -74,6 +74,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
74
74
  const visited = new Map();
75
75
  let issues = 0;
76
76
  let actions = 0;
77
+ let loginTried = false;
77
78
  const crashExitBaseline = typeof d.latestCrashExitInfo === "function" ? await d.latestCrashExitInfo() : null;
78
79
  let snap = await d.launch({ clearData });
79
80
  let crashReported = false;
@@ -210,6 +211,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
210
211
  if (candidate) {
211
212
  const target = controlLabel(candidate);
212
213
  const loginSubmit = inputs.some((input) => input.secure) && isAndroidAuthSubmit(candidate);
214
+ if (loginSubmit) loginTried = true;
213
215
  tried.add(`${hash}|tap|${target}`);
214
216
  const before = hash;
215
217
  const r = await d.tap(target, snap);
@@ -260,7 +262,7 @@ export async function exploreAndroid({ appId, apkPath, serial, maxActions = 40,
260
262
  }
261
263
 
262
264
  const timedOut = Date.now() >= deadline;
263
- emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
265
+ emit("COMPLETE", { actions, states: visited.size, issues, screens: [...new Set(visited.values())].join(","), outcome: timedOut ? "timeout" : "complete", timedOut, credentialsProvided: !!(testEmail || testPassword), credentialsUsed: loginTried, ...(timedOut ? { timeoutSeconds: timeoutSec } : {}) });
264
266
  onProgress({ action: actions, max: maxActions, states: visited.size });
265
267
  return { markersPath, outDir, actions, states: visited.size, issues, timedOut, seedTargets: normalizedTargets };
266
268
  }
@@ -43,6 +43,23 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
43
43
  } else if (action === "type") {
44
44
  const r = await d.type(target, value, snap);
45
45
  if (r.status !== "ok") throw new Error(r.detail || `no field ‘${target}’ to type into`);
46
+ } else if (action === "login") {
47
+ const fields = snap.elements.filter((element) => /EditText/i.test(element.type));
48
+ const emailField = fields.find((element) => /email|user/i.test(`${element.id} ${element.label}`)) || fields.find((element) => !element.secure);
49
+ const passwordField = fields.find((element) => element.secure || /password|passcode/i.test(`${element.id} ${element.label}`));
50
+ if (!emailField || !passwordField) throw new Error("could not identify email and password fields");
51
+ const emailValue = substituteFlowValue(raw.params.email || "$TEST_EMAIL", vars);
52
+ const passwordValue = substituteFlowValue(raw.params.password || "$TEST_PASSWORD", vars);
53
+ const emailResult = await d.type(emailField.id || emailField.label, emailValue, snap);
54
+ if (emailResult.status !== "ok") throw new Error(emailResult.detail || "could not fill the email field");
55
+ snap = await d.settle();
56
+ const passwordResult = await d.type(passwordField.id || passwordField.label, passwordValue, snap);
57
+ if (passwordResult.status !== "ok") throw new Error(passwordResult.detail || "could not fill the password field");
58
+ snap = await d.settle();
59
+ const submit = snap.elements.find((element) => element.clickable && /sign in|log in|login|continue|submit/i.test(`${element.text} ${element.label} ${element.id}`));
60
+ if (!submit) throw new Error("could not identify a sign-in control");
61
+ const submitResult = await d.tap(submit.id || submit.description || submit.text, snap);
62
+ if (submitResult.status !== "ok") throw new Error(submitResult.detail || "could not submit the login form");
46
63
  } else if (action === "swipe") {
47
64
  await d.swipe(target || "up");
48
65
  } else if (action === "back") {
@@ -81,7 +98,7 @@ export async function runAndroidFlow({ flow, appId, apkPath, serial, logPath, sc
81
98
  detail = error.message || String(error);
82
99
  if (screenshotDir) await d.screenshot(path.join(screenshotDir, `flow-failure-${i + 1}.png`)).catch(() => {});
83
100
  }
84
- log.step({ index: i + 1, action, target: target || value, status, detail, task: raw.task });
101
+ log.step({ index: i + 1, action, target: action === "login" ? "sign-in form" : target || value, status, detail, task: raw.task });
85
102
  if (status === "fail" && !flow.continueOnFailure) break;
86
103
  }
87
104
  if (screenshotDir) await d.screenshot(path.join(screenshotDir, "flow-final.png")).catch(() => {});