@aarwitz/tapp 0.17.0-rc.11 → 0.17.0-rc.13
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/.claude-plugin/marketplace.json +34 -0
- package/.claude-plugin/plugin.json +33 -0
- package/AGENTS.md +12 -9
- package/README.md +81 -34
- package/bin/tapp.js +18 -6
- package/docs/BROWSER-PRODUCT.md +1 -1
- package/mcp-server/src/index.js +165 -77
- package/mcp-server/src/product-operations.js +2 -1
- package/mcp-server/src/web-explorer.js +120 -8
- package/package.json +4 -2
- package/skills/tapp/SKILL.md +74 -0
- package/skills/tapp/agents/openai.yaml +4 -0
- package/skills/tapp/references/commands.md +102 -0
package/mcp-server/src/index.js
CHANGED
|
@@ -9,6 +9,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
9
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
10
10
|
import {
|
|
11
11
|
CallToolRequestSchema,
|
|
12
|
+
GetPromptRequestSchema,
|
|
13
|
+
ListPromptsRequestSchema,
|
|
12
14
|
ListToolsRequestSchema,
|
|
13
15
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
16
|
|
|
@@ -17,7 +19,15 @@ import { existingProjectArtifactPath, projectArtifactDirectory } from "./project
|
|
|
17
19
|
|
|
18
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
19
21
|
const __dirname = path.dirname(__filename);
|
|
22
|
+
// `repoRoot` is the installed Tapp package root: scripts and bundled harness assets live here.
|
|
23
|
+
// Repository-facing MCP operations must use `workspaceRoot` instead. In an npm/Claude-plugin
|
|
24
|
+
// installation those are different directories, even though source-repo tests historically made
|
|
25
|
+
// them look identical.
|
|
20
26
|
const repoRoot = path.resolve(__dirname, "../..");
|
|
27
|
+
const workspaceRoot = (() => {
|
|
28
|
+
try { return fs.realpathSync(process.cwd()); }
|
|
29
|
+
catch { return path.resolve(process.cwd()); }
|
|
30
|
+
})();
|
|
21
31
|
const scriptsDir = path.join(repoRoot, "scripts");
|
|
22
32
|
// TAPP_HOME (set by the `tapp` CLI when installed) redirects writable output to a user directory.
|
|
23
33
|
// The old alias remains a read-only fallback; unset repository development stays local.
|
|
@@ -1463,7 +1473,7 @@ export function formatScreen(screenTitle, elements) {
|
|
|
1463
1473
|
// `tapp` CLI verbs in bin/tapp.js — same pattern as report.js. Keep orchestration HERE so
|
|
1464
1474
|
// the surfaces can't drift.)
|
|
1465
1475
|
|
|
1466
|
-
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], surface = "mcp", onProgress = () => {} }) {
|
|
1476
|
+
export async function runQaWeb({ url, maxActions, timeout, testEmail, testPassword, baselineFindings, seedRoutes = [], seedTargets = [], watch = false, surface = "mcp", onProgress = () => {} }) {
|
|
1467
1477
|
const actions = Math.max(1, Math.min(1000, asInteger(maxActions, 60)));
|
|
1468
1478
|
const timeoutSec = Math.max(30, Math.min(3600, asInteger(timeout, 600)));
|
|
1469
1479
|
const id = "web-" + new Date().toISOString().replace(/[-:T]/g, "").slice(0, 14).replace(/^(\d{8})/, "$1-");
|
|
@@ -1480,6 +1490,7 @@ export async function runQaWeb({ url, maxActions, timeout, testEmail, testPasswo
|
|
|
1480
1490
|
testPassword: isNonEmptyString(testPassword) ? testPassword.trim() : "",
|
|
1481
1491
|
seedRoutes,
|
|
1482
1492
|
seedTargets,
|
|
1493
|
+
watch: watch === true,
|
|
1483
1494
|
onProgress,
|
|
1484
1495
|
});
|
|
1485
1496
|
} catch (err) {
|
|
@@ -1640,6 +1651,7 @@ export async function runInitExploration({
|
|
|
1640
1651
|
timeout,
|
|
1641
1652
|
testEmail,
|
|
1642
1653
|
testPassword,
|
|
1654
|
+
watch = false,
|
|
1643
1655
|
onProgress = () => {},
|
|
1644
1656
|
onStatus = () => {},
|
|
1645
1657
|
} = {}) {
|
|
@@ -1655,17 +1667,18 @@ export async function runInitExploration({
|
|
|
1655
1667
|
let targetResolution = null;
|
|
1656
1668
|
let qa;
|
|
1657
1669
|
let managedRuntime = null;
|
|
1670
|
+
if (watch && selected !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1658
1671
|
if (selected === "web") {
|
|
1659
1672
|
if (/^https?:\/\//i.test(String(url))) {
|
|
1660
1673
|
resolvedTarget = String(url).trim();
|
|
1661
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1674
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1662
1675
|
} else {
|
|
1663
1676
|
const started = await startManagedWebTarget({ root, requestedTarget: target, timeout, onStatus });
|
|
1664
1677
|
if (started.error) return started;
|
|
1665
1678
|
managedRuntime = started;
|
|
1666
1679
|
resolvedTarget = started.url;
|
|
1667
1680
|
try {
|
|
1668
|
-
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, onProgress });
|
|
1681
|
+
qa = await runQaWeb({ url: resolvedTarget, maxActions, timeout, testEmail, testPassword, watch, onProgress });
|
|
1669
1682
|
} finally {
|
|
1670
1683
|
await stopManagedWebTarget(started);
|
|
1671
1684
|
}
|
|
@@ -1777,6 +1790,7 @@ export async function runExploreTarget({
|
|
|
1777
1790
|
appLaunchArgs,
|
|
1778
1791
|
appLaunchEnv,
|
|
1779
1792
|
baselineFindings,
|
|
1793
|
+
watch = false,
|
|
1780
1794
|
surface = "cli",
|
|
1781
1795
|
onProgress = () => {},
|
|
1782
1796
|
onStatus = () => {},
|
|
@@ -1799,6 +1813,8 @@ export async function runExploreTarget({
|
|
|
1799
1813
|
catch (error) { return { error: error.message || String(error) }; }
|
|
1800
1814
|
const selectedPlatform = selected.platform;
|
|
1801
1815
|
|
|
1816
|
+
if (watch && selectedPlatform !== "web") return { error: "Watch mode is currently available for web exploration only." };
|
|
1817
|
+
|
|
1802
1818
|
if (selectedPlatform !== "ios" && ((Array.isArray(appLaunchArgs) && appLaunchArgs.length) || (appLaunchEnv && Object.keys(appLaunchEnv).length))) {
|
|
1803
1819
|
return { error: "appLaunchArgs/appLaunchEnv apply only to iOS targets." };
|
|
1804
1820
|
}
|
|
@@ -1807,14 +1823,14 @@ export async function runExploreTarget({
|
|
|
1807
1823
|
const ownedUrl = String(selected.runtime?.ownedUrl || "").trim();
|
|
1808
1824
|
if (/^https?:\/\//i.test(ownedUrl)) {
|
|
1809
1825
|
onStatus(`Exploring the owned URL from the application model: ${ownedUrl}`);
|
|
1810
|
-
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1826
|
+
return runQaWeb({ url: ownedUrl, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1811
1827
|
}
|
|
1812
1828
|
// Tapp-managed: build/start the repo's web target, wait for readiness, and ALWAYS stop it.
|
|
1813
1829
|
onStatus(`Preparing the managed web runtime for ${selected.name}…`);
|
|
1814
1830
|
const started = await startManagedWebTarget({ root, requestedTarget: selected.sourcePath || selected.name || "", timeout, onStatus });
|
|
1815
1831
|
if (started.error) return started;
|
|
1816
1832
|
try {
|
|
1817
|
-
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, surface, onProgress });
|
|
1833
|
+
return await runQaWeb({ url: started.url, maxActions, timeout, testEmail, testPassword, baselineFindings, watch, surface, onProgress });
|
|
1818
1834
|
} finally {
|
|
1819
1835
|
await stopManagedWebTarget(started);
|
|
1820
1836
|
onStatus("Stopped the managed web runtime.");
|
|
@@ -2037,11 +2053,61 @@ const server = new Server(
|
|
|
2037
2053
|
},
|
|
2038
2054
|
{
|
|
2039
2055
|
capabilities: {
|
|
2056
|
+
prompts: {},
|
|
2040
2057
|
tools: {},
|
|
2041
2058
|
},
|
|
2042
2059
|
}
|
|
2043
2060
|
);
|
|
2044
2061
|
|
|
2062
|
+
const testAppPrompt = {
|
|
2063
|
+
name: "test-app",
|
|
2064
|
+
title: "Test this app with Tapp",
|
|
2065
|
+
description: "Use Tapp's real app surfaces to inspect, drive, or explore this repository and report evidence honestly.",
|
|
2066
|
+
arguments: [
|
|
2067
|
+
{
|
|
2068
|
+
name: "goal",
|
|
2069
|
+
description: "What to verify, such as finding bugs or exercising checkout",
|
|
2070
|
+
required: false,
|
|
2071
|
+
},
|
|
2072
|
+
{
|
|
2073
|
+
name: "target",
|
|
2074
|
+
description: "Optional repo target, bundle/app id, APK path, or owned URL",
|
|
2075
|
+
required: false,
|
|
2076
|
+
},
|
|
2077
|
+
],
|
|
2078
|
+
};
|
|
2079
|
+
|
|
2080
|
+
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [testAppPrompt] }));
|
|
2081
|
+
|
|
2082
|
+
server.setRequestHandler(GetPromptRequestSchema, async (request) => {
|
|
2083
|
+
if (request.params.name !== testAppPrompt.name) {
|
|
2084
|
+
throw new Error(`Unknown prompt: ${request.params.name}`);
|
|
2085
|
+
}
|
|
2086
|
+
const goal = isNonEmptyString(request.params.arguments?.goal)
|
|
2087
|
+
? request.params.arguments.goal.trim()
|
|
2088
|
+
: "Test the app and find important bugs";
|
|
2089
|
+
const target = isNonEmptyString(request.params.arguments?.target)
|
|
2090
|
+
? ` Use this target: ${request.params.arguments.target.trim()}.`
|
|
2091
|
+
: "";
|
|
2092
|
+
return {
|
|
2093
|
+
description: testAppPrompt.description,
|
|
2094
|
+
messages: [
|
|
2095
|
+
{
|
|
2096
|
+
role: "user",
|
|
2097
|
+
content: {
|
|
2098
|
+
type: "text",
|
|
2099
|
+
text:
|
|
2100
|
+
`${goal}.${target} Use the connected Tapp tools on the real UI surface. ` +
|
|
2101
|
+
"Use the smallest operation that satisfies the request; initialize/explore the source repo only for a general repository test. " +
|
|
2102
|
+
"If Tapp returns multiple target choices, ask me to select one instead of guessing. " +
|
|
2103
|
+
"Read visual evidence before describing it. Report findings, coverage, authority, inconclusive state, and checked/not-checked scope; " +
|
|
2104
|
+
"never turn exploration into a score or ship verdict. Do not edit the app unless I ask for a fix.",
|
|
2105
|
+
},
|
|
2106
|
+
},
|
|
2107
|
+
],
|
|
2108
|
+
};
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2045
2111
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
2046
2112
|
tools: [
|
|
2047
2113
|
{
|
|
@@ -2213,6 +2279,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2213
2279
|
androidSerial: { type: "string", description: "Android: optional adb device serial; defaults to the first authorized device." },
|
|
2214
2280
|
clearData: { type: "boolean", default: true, description: "Android: clear app data before launch for a repeatable starting state." },
|
|
2215
2281
|
url: { type: "string", description: "Web (beta): URL of the app to explore in a real browser (same-origin only; your own app/staging). Provide exactly one of appBundleId | url." },
|
|
2282
|
+
watch: { type: "boolean", default: false, description: "Web only: open Tapp's controlled Chromium window and show a cursor/HUD for each exploration action. Evidence screenshots exclude the overlay." },
|
|
2216
2283
|
maxActions: { type: "integer", minimum: 1, maximum: 1000, default: 60, description: "Exploration action budget" },
|
|
2217
2284
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600, description: "Max wall-clock seconds" },
|
|
2218
2285
|
testEmail: { type: "string", description: "Email for the login preamble, if the app has a sign-in" },
|
|
@@ -2283,6 +2350,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
2283
2350
|
timeout: { type: "integer", minimum: 30, maximum: 3600, default: 600 },
|
|
2284
2351
|
testEmail: { type: "string", description: "Explore: actor/login email; never persisted in the model" },
|
|
2285
2352
|
testPassword: { type: "string", description: "Explore: actor/login password; never persisted in the model" },
|
|
2353
|
+
watch: { type: "boolean", default: false, description: "Web explore only: show the controlled browser and Tapp's actions" },
|
|
2286
2354
|
maxContracts: { type: "integer", minimum: 1, maximum: 50, default: 15 },
|
|
2287
2355
|
outDir: { type: "string", description: "Repo-relative artifact directory; default .tapp" },
|
|
2288
2356
|
},
|
|
@@ -2736,45 +2804,62 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2736
2804
|
const { name, arguments: args = {} } = request.params;
|
|
2737
2805
|
|
|
2738
2806
|
if (name === "tapp_health") {
|
|
2739
|
-
const
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2807
|
+
const coreChecks = [];
|
|
2808
|
+
coreChecks.push({
|
|
2809
|
+
check: "workspace",
|
|
2810
|
+
ok: fs.existsSync(workspaceRoot) && fs.statSync(workspaceRoot).isDirectory(),
|
|
2811
|
+
value: workspaceRoot,
|
|
2812
|
+
required: true,
|
|
2745
2813
|
});
|
|
2746
2814
|
|
|
2747
2815
|
const nodeVersion = await runCommand("node", ["-v"]);
|
|
2748
|
-
|
|
2816
|
+
coreChecks.push({
|
|
2749
2817
|
check: "node",
|
|
2750
2818
|
ok: nodeVersion.code === 0,
|
|
2751
2819
|
value: nodeVersion.stdout.trim() || nodeVersion.stderr.trim(),
|
|
2820
|
+
required: true,
|
|
2752
2821
|
});
|
|
2753
2822
|
|
|
2754
2823
|
const xcodebuildVersion = await runCommand("xcodebuild", ["-version"]);
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
value: (xcodebuildVersion.stdout || xcodebuildVersion.stderr).trim().split("\n")[0] || "not found",
|
|
2759
|
-
});
|
|
2760
|
-
|
|
2761
|
-
const simctl = await runCommand("xcrun", ["simctl", "list", "devices", "booted"]);
|
|
2762
|
-
checks.push({
|
|
2763
|
-
check: "bootedSimulator",
|
|
2764
|
-
ok: simctl.code === 0,
|
|
2765
|
-
value: (simctl.stdout || simctl.stderr).trim(),
|
|
2766
|
-
});
|
|
2767
|
-
|
|
2768
|
-
const allOk = checks.every((c) => c.ok);
|
|
2824
|
+
const simctl = xcodebuildVersion.code === 0
|
|
2825
|
+
? await runCommand("xcrun", ["simctl", "list", "devices", "booted"])
|
|
2826
|
+
: { code: 1, stdout: "", stderr: "Xcode not found" };
|
|
2769
2827
|
const bootedLine = (simctl.stdout || "").split("\n").find((l) => /\(Booted\)/.test(l));
|
|
2770
2828
|
const bootedName = bootedLine ? bootedLine.trim().replace(/\s*\(.*$/, "") : null;
|
|
2771
|
-
const
|
|
2829
|
+
const adb = await runCommand("adb", ["devices"]);
|
|
2830
|
+
const androidDevice = adb.code === 0
|
|
2831
|
+
? (adb.stdout || "").split("\n").find((line) => /\tdevice\s*$/.test(line))
|
|
2832
|
+
: null;
|
|
2833
|
+
let web = { ok: false, value: "Playwright or Chromium not installed" };
|
|
2834
|
+
try {
|
|
2835
|
+
const { chromium } = await import("playwright");
|
|
2836
|
+
const executable = chromium.executablePath();
|
|
2837
|
+
web = { ok: fs.existsSync(executable), value: fs.existsSync(executable) ? executable : "Chromium not installed" };
|
|
2838
|
+
} catch { /* optional dependency may be intentionally absent */ }
|
|
2839
|
+
const platformChecks = [
|
|
2840
|
+
{
|
|
2841
|
+
check: "iOS",
|
|
2842
|
+
ok: xcodebuildVersion.code === 0 && !!bootedName,
|
|
2843
|
+
value: bootedName ? `${bootedName} booted` : xcodebuildVersion.code === 0 ? "Xcode available; no simulator booted" : "Xcode not found",
|
|
2844
|
+
required: false,
|
|
2845
|
+
},
|
|
2846
|
+
{
|
|
2847
|
+
check: "Android",
|
|
2848
|
+
ok: !!androidDevice,
|
|
2849
|
+
value: androidDevice ? `${androidDevice.split("\t")[0]} connected` : adb.code === 0 ? "adb available; no authorized device" : "adb not found",
|
|
2850
|
+
required: false,
|
|
2851
|
+
},
|
|
2852
|
+
{ check: "web", ok: web.ok, value: web.value, required: false },
|
|
2853
|
+
];
|
|
2854
|
+
const checks = [...coreChecks, ...platformChecks];
|
|
2855
|
+
const ready = coreChecks.every((check) => check.ok) && platformChecks.some((check) => check.ok);
|
|
2856
|
+
const L = [`### ${ready ? "🩺 Tapp ready" : "⚠️ Tapp needs a platform runtime"}`, ""];
|
|
2772
2857
|
for (const c of checks) {
|
|
2773
|
-
|
|
2858
|
+
const icon = c.ok ? "✅" : c.required ? "❌" : "⚪️";
|
|
2859
|
+
L.push(`- ${icon} **${c.check}** — ${String(c.value).split("\n")[0] || "—"}`);
|
|
2774
2860
|
}
|
|
2775
|
-
L.push("");
|
|
2776
|
-
L.
|
|
2777
|
-
return richResult(L.join("\n"), { ok: allOk, checks });
|
|
2861
|
+
if (!ready) L.push("", "Run `tapp doctor` in the application repository for exact remediation.");
|
|
2862
|
+
return richResult(L.join("\n"), { ok: ready, workspaceRoot, checks, platforms: Object.fromEntries(platformChecks.map((check) => [check.check.toLowerCase(), check.ok])) });
|
|
2778
2863
|
}
|
|
2779
2864
|
|
|
2780
2865
|
if (name === "tapp_build") {
|
|
@@ -2971,6 +3056,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2971
3056
|
if (targets !== 1) {
|
|
2972
3057
|
return errorResult("Provide exactly one of appBundleId (iOS), androidAppId (Android), or url (web beta)");
|
|
2973
3058
|
}
|
|
3059
|
+
if (args.watch === true && !wantsWeb) return errorResult("watch is currently available for web exploration only");
|
|
2974
3060
|
|
|
2975
3061
|
// Both branches call the shared engine (runQaWeb/runQaIos) — the handler only adds
|
|
2976
3062
|
// MCP concerns: auth, arg validation, and progress notifications.
|
|
@@ -2992,6 +3078,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
2992
3078
|
testEmail: args.testEmail,
|
|
2993
3079
|
testPassword: args.testPassword,
|
|
2994
3080
|
baselineFindings: args.baselineFindings,
|
|
3081
|
+
watch: args.watch === true,
|
|
2995
3082
|
onProgress: notifyProgress("pages reached"),
|
|
2996
3083
|
});
|
|
2997
3084
|
if (r.error) return errorResult(r.error, r.details || {});
|
|
@@ -3036,8 +3123,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3036
3123
|
if (unauthorized) return unauthorized;
|
|
3037
3124
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3038
3125
|
if (!["inspect", "write", "refresh", "explore"].includes(operation)) return errorResult("operation must be inspect|write|refresh|explore");
|
|
3039
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3040
|
-
if (!isInsideDir(
|
|
3126
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3127
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3041
3128
|
const maxContracts = asInteger(args.maxContracts, 15);
|
|
3042
3129
|
if (maxContracts < 1 || maxContracts > 50) return errorResult("maxContracts must be between 1 and 50");
|
|
3043
3130
|
const { initializeProductProject } = await import("./product-operations.js");
|
|
@@ -3061,6 +3148,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3061
3148
|
serial: isNonEmptyString(args.androidSerial) ? args.androidSerial.trim() : undefined,
|
|
3062
3149
|
maxActions: args.maxActions, timeout: args.timeout, maxContracts,
|
|
3063
3150
|
testEmail: args.testEmail, testPassword: args.testPassword,
|
|
3151
|
+
watch: args.watch === true,
|
|
3064
3152
|
runExploration: runInitExploration,
|
|
3065
3153
|
onProgress: (progress) => {
|
|
3066
3154
|
if (progressToken === undefined) return;
|
|
@@ -3092,8 +3180,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3092
3180
|
const allowedArguments = new Set(["authToken", "operation", "projectDir", "name", "role", "session", "provisioning", "credentialBindings", "replace"]);
|
|
3093
3181
|
const unexpectedArguments = Object.keys(args).filter((key) => !allowedArguments.has(key));
|
|
3094
3182
|
if (unexpectedArguments.length) return errorResult("Unsupported actor configuration fields; credential values are never accepted", { fields: unexpectedArguments });
|
|
3095
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3096
|
-
if (!isInsideDir(
|
|
3183
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3184
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3097
3185
|
const { configureActor, readProjectConfig } = await import("./project-config.js");
|
|
3098
3186
|
if (operation === "read") {
|
|
3099
3187
|
const loaded = readProjectConfig(projectDir);
|
|
@@ -3122,8 +3210,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3122
3210
|
if (unauthorized) return unauthorized;
|
|
3123
3211
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3124
3212
|
if (!["read", "review", "generate", "validate", "promote"].includes(operation)) return errorResult("operation must be read|review|generate|validate|promote");
|
|
3125
|
-
const planPath = isNonEmptyString(args.planPath) ? path.resolve(
|
|
3126
|
-
if (!isInsideDir(
|
|
3213
|
+
const planPath = isNonEmptyString(args.planPath) ? path.resolve(workspaceRoot, args.planPath.trim()) : existingProjectArtifactPath(workspaceRoot, "release-plan.json");
|
|
3214
|
+
if (!isInsideDir(workspaceRoot, planPath)) return errorResult("planPath must be inside the workspace");
|
|
3127
3215
|
if (!fs.existsSync(planPath)) return errorResult("Release plan not found", { planPath });
|
|
3128
3216
|
let plan;
|
|
3129
3217
|
try { plan = JSON.parse(fs.readFileSync(planPath, "utf8")); }
|
|
@@ -3133,11 +3221,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3133
3221
|
if (![...decisions.approve, ...decisions.reject, ...decisions.defer].length) return errorResult("review requires at least one approve, reject, or defer item");
|
|
3134
3222
|
const { reviewProductPlan } = await import("./product-operations.js");
|
|
3135
3223
|
try {
|
|
3136
|
-
plan = reviewProductPlan({ projectDir:
|
|
3224
|
+
plan = reviewProductPlan({ projectDir: workspaceRoot, planPath, ...decisions }).plan;
|
|
3137
3225
|
} catch (error) { return errorResult("Could not review release plan", { detail: error.message || String(error) }); }
|
|
3138
3226
|
} else if (operation === "generate") {
|
|
3139
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3140
|
-
if (!isInsideDir(
|
|
3227
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3228
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3141
3229
|
const { generateProductPlan } = await import("./product-operations.js");
|
|
3142
3230
|
try {
|
|
3143
3231
|
const result = await generateProductPlan({ projectDir, planPath });
|
|
@@ -3145,8 +3233,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3145
3233
|
return richResult(`🧩 Proposal drafts — ${result.generatedTasks.length} UI-Map-grounded Task(s) · ${result.generated.length} compile-checked/untrusted contract(s) · ${result.blocked.length} blocked; deterministic real-surface replay remains required`, { plan, planPath, generatedTasks: result.generatedTasks, generated: result.generated, blocked: result.blocked });
|
|
3146
3234
|
} catch (error) { return errorResult("Could not generate contract drafts", { detail: error.message || String(error) }); }
|
|
3147
3235
|
} else if (operation === "validate") {
|
|
3148
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3149
|
-
if (!isInsideDir(
|
|
3236
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3237
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3150
3238
|
let apkPath = "";
|
|
3151
3239
|
if (isNonEmptyString(args.apkPath)) {
|
|
3152
3240
|
apkPath = path.resolve(projectDir, args.apkPath.trim());
|
|
@@ -3178,8 +3266,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3178
3266
|
return richResult(`🔎 Generated contract validation passed — ${result.results.length}/${result.results.length} on ${result.platform}`, { ...result, planPath });
|
|
3179
3267
|
} catch (error) { return errorResult("Generated contract validation failed", { detail: error.message || String(error), plan, planPath }); }
|
|
3180
3268
|
} else if (operation === "promote") {
|
|
3181
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3182
|
-
if (!isInsideDir(
|
|
3269
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3270
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir)) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3183
3271
|
const { promoteProductPlan } = await import("./product-operations.js");
|
|
3184
3272
|
try {
|
|
3185
3273
|
const result = await promoteProductPlan({ projectDir, planPath, items: Array.isArray(args.items) ? args.items : [] });
|
|
@@ -3195,9 +3283,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3195
3283
|
if (unauthorized) return unauthorized;
|
|
3196
3284
|
const operation = String(args.operation || "inspect").toLowerCase();
|
|
3197
3285
|
if (!["inspect", "install", "baseline"].includes(operation)) return errorResult("operation must be inspect|install|baseline");
|
|
3198
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3199
|
-
if (!isInsideDir(
|
|
3200
|
-
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(
|
|
3286
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3287
|
+
if (!isInsideDir(workspaceRoot, projectDir) || !fs.existsSync(projectDir) || !fs.statSync(projectDir).isDirectory()) return errorResult("projectDir must be an existing directory inside the workspace");
|
|
3288
|
+
const modelPath = isNonEmptyString(args.modelPath) ? path.resolve(workspaceRoot, args.modelPath.trim()) : existingProjectArtifactPath(projectDir, "application-model.json");
|
|
3201
3289
|
if (!isInsideDir(projectDir, modelPath) || !fs.existsSync(modelPath)) return errorResult("Application model not found inside projectDir; run tapp_init first", { modelPath });
|
|
3202
3290
|
let model;
|
|
3203
3291
|
try { model = JSON.parse(fs.readFileSync(modelPath, "utf8")); }
|
|
@@ -3205,8 +3293,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3205
3293
|
const { createProductBaseline, installProductCi, prepareProductCi } = await import("./product-operations.js");
|
|
3206
3294
|
if (operation === "baseline") {
|
|
3207
3295
|
if (!isNonEmptyString(args.reportPath)) return errorResult("baseline requires reportPath from a successful portable gate");
|
|
3208
|
-
const reportPath = path.resolve(
|
|
3209
|
-
if (!isInsideDir(
|
|
3296
|
+
const reportPath = path.resolve(workspaceRoot, args.reportPath.trim());
|
|
3297
|
+
if (!isInsideDir(workspaceRoot, reportPath) || !fs.existsSync(reportPath)) return errorResult("reportPath must be an existing JSON file inside the workspace");
|
|
3210
3298
|
let report;
|
|
3211
3299
|
try { report = JSON.parse(fs.readFileSync(reportPath, "utf8")); }
|
|
3212
3300
|
catch (error) { return errorResult("Gate report is invalid JSON", { detail: error.message || String(error) }); }
|
|
@@ -3231,8 +3319,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3231
3319
|
if (unauthorized) return unauthorized;
|
|
3232
3320
|
const operation = String(args.operation || "read").toLowerCase();
|
|
3233
3321
|
const resolveRepoFile = (value, fallback = "") => {
|
|
3234
|
-
const resolved = path.resolve(
|
|
3235
|
-
return isInsideDir(
|
|
3322
|
+
const resolved = path.resolve(workspaceRoot, isNonEmptyString(value) ? value.trim() : fallback);
|
|
3323
|
+
return isInsideDir(workspaceRoot, resolved) ? resolved : null;
|
|
3236
3324
|
};
|
|
3237
3325
|
const capture = isNonEmptyString(args.captureId) ? listCaptureRuns(200).find((run) => run.id === args.captureId.trim()) : null;
|
|
3238
3326
|
if (isNonEmptyString(args.captureId) && !capture) return errorResult("Capture not found", { captureId: args.captureId });
|
|
@@ -3252,18 +3340,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3252
3340
|
const markersPath = capture ? path.join(capture.path, "ocqa-markers.txt") : resolveRepoFile(args.markersPath);
|
|
3253
3341
|
if (!markersPath) return errorResult("markersPath must be inside the repo, or provide captureId");
|
|
3254
3342
|
if (!fs.existsSync(markersPath)) return errorResult("OCQA markers not found", { markersPath });
|
|
3255
|
-
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3343
|
+
const outPath = isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3256
3344
|
if (!outPath) return errorResult("mapPath must be inside the repo");
|
|
3257
3345
|
try {
|
|
3258
3346
|
const observed = buildUiMapFromMarkers({ markersPath, platform: args.platform || "ios", target: args.target || "", runId: capture?.id || "" });
|
|
3259
3347
|
const map = fs.existsSync(outPath) && args.replace !== true ? mergeUiMaps(JSON.parse(fs.readFileSync(outPath, "utf8")), observed) : observed;
|
|
3260
3348
|
writeUiMap(outPath, map);
|
|
3261
3349
|
const controls = map.nodes.reduce((total, node) => total + node.controls.length, 0);
|
|
3262
|
-
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(
|
|
3350
|
+
return richResult(`🗺️ UI Map updated — ${map.nodes.length} states · ${map.edges.length} transitions · ${controls} semantic controls\n${path.relative(workspaceRoot, outPath)}`, { map, path: outPath });
|
|
3263
3351
|
} catch (error) { return errorResult("Could not build UI Map", { detail: error.message || String(error) }); }
|
|
3264
3352
|
}
|
|
3265
3353
|
if (operation !== "read") return errorResult("operation must be read|build|diff");
|
|
3266
|
-
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(
|
|
3354
|
+
const mapPath = capture ? path.join(capture.path, "ui-map.json") : isNonEmptyString(args.mapPath) ? resolveRepoFile(args.mapPath) : existingProjectArtifactPath(workspaceRoot, "ui-map.json");
|
|
3267
3355
|
if (!mapPath) return errorResult("mapPath must be inside the repo");
|
|
3268
3356
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found; run QA or operation=build first", { mapPath });
|
|
3269
3357
|
try {
|
|
@@ -3280,8 +3368,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3280
3368
|
if (unauthorized) return unauthorized;
|
|
3281
3369
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3282
3370
|
if (!["read", "validate", "compile"].includes(operation)) return errorResult("operation must be read|validate|compile");
|
|
3283
|
-
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(
|
|
3284
|
-
if (!taskPath || !isInsideDir(
|
|
3371
|
+
const taskPath = isNonEmptyString(args.taskPath) ? path.resolve(workspaceRoot, args.taskPath.trim()) : null;
|
|
3372
|
+
if (!taskPath || !isInsideDir(workspaceRoot, taskPath)) return errorResult("taskPath must be inside the workspace");
|
|
3285
3373
|
if (!fs.existsSync(taskPath)) return errorResult("Task file not found", { taskPath: args.taskPath });
|
|
3286
3374
|
const { applyTaskCoverage, compileTaskSteps, loadTaskFile, loadTaskRegistry, validateTaskAgainstUiMap } = await import("./task-runtime.js");
|
|
3287
3375
|
let task;
|
|
@@ -3292,8 +3380,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3292
3380
|
let groundingMap = null;
|
|
3293
3381
|
let groundingMapPath = null;
|
|
3294
3382
|
if (isNonEmptyString(args.mapPath)) {
|
|
3295
|
-
const mapPath = path.resolve(
|
|
3296
|
-
if (!isInsideDir(
|
|
3383
|
+
const mapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3384
|
+
if (!isInsideDir(workspaceRoot, mapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3297
3385
|
if (!fs.existsSync(mapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3298
3386
|
groundingMapPath = mapPath;
|
|
3299
3387
|
try {
|
|
@@ -3323,12 +3411,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3323
3411
|
const flow = { name: `Task: ${task.name}`, kind: "flow", platform: args.platform || "", vars: compiled.vars, steps: compiled.steps, taskPlan: compiled.plan };
|
|
3324
3412
|
let outPath = null;
|
|
3325
3413
|
if (isNonEmptyString(args.outPath)) {
|
|
3326
|
-
outPath = path.resolve(
|
|
3327
|
-
if (!isInsideDir(
|
|
3414
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3415
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3328
3416
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3329
3417
|
fs.writeFileSync(outPath, JSON.stringify(flow, null, 2) + "\n");
|
|
3330
3418
|
}
|
|
3331
|
-
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(
|
|
3419
|
+
return richResult(`🧩 Compiled ${task.name} into ${flow.steps.length} deterministic Flow steps${outPath ? `\n${path.relative(workspaceRoot, outPath)}` : ""}`, { flow, grounding, path: outPath });
|
|
3332
3420
|
}
|
|
3333
3421
|
|
|
3334
3422
|
if (name === "tapp_release_contract") {
|
|
@@ -3336,8 +3424,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3336
3424
|
if (unauthorized) return unauthorized;
|
|
3337
3425
|
const operation = String(args.operation || "validate").toLowerCase();
|
|
3338
3426
|
if (!["read", "validate", "compile", "run"].includes(operation)) return errorResult("operation must be read|validate|compile|run");
|
|
3339
|
-
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(
|
|
3340
|
-
if (!contractPath || !isInsideDir(
|
|
3427
|
+
const contractPath = isNonEmptyString(args.contractPath) ? path.resolve(workspaceRoot, args.contractPath.trim()) : null;
|
|
3428
|
+
if (!contractPath || !isInsideDir(workspaceRoot, contractPath)) return errorResult("contractPath must be inside the workspace");
|
|
3341
3429
|
const {
|
|
3342
3430
|
applyReleaseContractCoverage,
|
|
3343
3431
|
compileReleaseContract,
|
|
@@ -3352,8 +3440,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3352
3440
|
let groundingMap = null;
|
|
3353
3441
|
let groundingMapPath = null;
|
|
3354
3442
|
if (isNonEmptyString(args.mapPath)) {
|
|
3355
|
-
groundingMapPath = path.resolve(
|
|
3356
|
-
if (!isInsideDir(
|
|
3443
|
+
groundingMapPath = path.resolve(workspaceRoot, args.mapPath.trim());
|
|
3444
|
+
if (!isInsideDir(workspaceRoot, groundingMapPath)) return errorResult("mapPath must be inside the workspace");
|
|
3357
3445
|
if (!fs.existsSync(groundingMapPath)) return errorResult("UI Map not found", { mapPath: args.mapPath });
|
|
3358
3446
|
try {
|
|
3359
3447
|
groundingMap = JSON.parse(fs.readFileSync(groundingMapPath, "utf8"));
|
|
@@ -3374,8 +3462,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3374
3462
|
catch (error) { return errorResult("Could not compile Release Contract", { detail: error.message || String(error) }); }
|
|
3375
3463
|
let outPath = null;
|
|
3376
3464
|
if (isNonEmptyString(args.outPath)) {
|
|
3377
|
-
outPath = path.resolve(
|
|
3378
|
-
if (!isInsideDir(
|
|
3465
|
+
outPath = path.resolve(workspaceRoot, args.outPath.trim());
|
|
3466
|
+
if (!isInsideDir(workspaceRoot, outPath)) return errorResult("outPath must be inside the workspace");
|
|
3379
3467
|
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
3380
3468
|
fs.writeFileSync(outPath, JSON.stringify(execution, null, 2) + "\n");
|
|
3381
3469
|
}
|
|
@@ -3422,12 +3510,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3422
3510
|
if (unauthorized) return unauthorized;
|
|
3423
3511
|
const operation = isNonEmptyString(args.operation) ? args.operation.trim().toLowerCase() : "plan";
|
|
3424
3512
|
if (!['plan', 'adopt'].includes(operation)) return errorResult("operation must be plan|adopt");
|
|
3425
|
-
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(
|
|
3426
|
-
if (!isInsideDir(
|
|
3513
|
+
const projectDir = isNonEmptyString(args.projectDir) ? path.resolve(workspaceRoot, args.projectDir.trim()) : workspaceRoot;
|
|
3514
|
+
if (!isInsideDir(workspaceRoot, projectDir)) return errorResult("projectDir must be inside the workspace");
|
|
3427
3515
|
if (operation === "adopt") {
|
|
3428
3516
|
if (!isNonEmptyString(args.prPlanPath) || !isNonEmptyString(args.item)) return errorResult("adopt requires prPlanPath and item");
|
|
3429
3517
|
const prPlanPath = path.resolve(projectDir, args.prPlanPath.trim());
|
|
3430
|
-
if (!isInsideDir(
|
|
3518
|
+
if (!isInsideDir(workspaceRoot, prPlanPath)) return errorResult("prPlanPath must be inside the workspace");
|
|
3431
3519
|
const { adoptPrCoverageProposal } = await import("./pr-selection.js");
|
|
3432
3520
|
try {
|
|
3433
3521
|
const adopted = adoptPrCoverageProposal({
|
|
@@ -3471,8 +3559,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3471
3559
|
fs.writeFileSync(flowFile, JSON.stringify(args.flow));
|
|
3472
3560
|
parsedFlow = args.flow;
|
|
3473
3561
|
} else if (isNonEmptyString(args.flowPath)) {
|
|
3474
|
-
const p = path.resolve(
|
|
3475
|
-
if (!isInsideDir(
|
|
3562
|
+
const p = path.resolve(workspaceRoot, args.flowPath.trim());
|
|
3563
|
+
if (!isInsideDir(workspaceRoot, p)) return errorResult("flowPath must be inside the workspace");
|
|
3476
3564
|
if (!fs.existsSync(p)) return errorResult("Flow file not found", { flowPath: args.flowPath });
|
|
3477
3565
|
flowFile = p;
|
|
3478
3566
|
} else {
|
|
@@ -3554,8 +3642,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3554
3642
|
if (args.scenario && typeof args.scenario === "object") {
|
|
3555
3643
|
scenario = args.scenario;
|
|
3556
3644
|
} else if (isNonEmptyString(args.scenarioPath)) {
|
|
3557
|
-
const scenarioFile = path.resolve(
|
|
3558
|
-
if (!isInsideDir(
|
|
3645
|
+
const scenarioFile = path.resolve(workspaceRoot, args.scenarioPath.trim());
|
|
3646
|
+
if (!isInsideDir(workspaceRoot, scenarioFile)) return errorResult("scenarioPath must be inside the workspace");
|
|
3559
3647
|
if (!fs.existsSync(scenarioFile)) return errorResult("Scenario file not found", { scenarioPath: args.scenarioPath });
|
|
3560
3648
|
try {
|
|
3561
3649
|
const { loadScenarioFile } = await import("./scenario-runtime.js");
|
|
@@ -3625,7 +3713,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3625
3713
|
const ungrounded = ungroundedScreens(parsed.steps, grounding);
|
|
3626
3714
|
const flow = { name: args.name || parsed.name, app: bundleId, steps: parsed.steps };
|
|
3627
3715
|
const slug = flow.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "generated-flow";
|
|
3628
|
-
const dir = path.join(
|
|
3716
|
+
const dir = path.join(workspaceRoot, ".tapp", "proposals", "flows");
|
|
3629
3717
|
fs.mkdirSync(dir, { recursive: true });
|
|
3630
3718
|
const outPath = path.join(dir, `${slug}.yml`);
|
|
3631
3719
|
const promotedPath = path.join(".tapp", "flows", `${slug}.yml`);
|
|
@@ -3633,7 +3721,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3633
3721
|
const yaml = (yamlRes.stdout || "").trim();
|
|
3634
3722
|
if (!yaml) return errorResult("Failed to render flow YAML", { stderr: yamlRes.stderr });
|
|
3635
3723
|
fs.writeFileSync(outPath, yaml + "\n");
|
|
3636
|
-
const rel = path.relative(
|
|
3724
|
+
const rel = path.relative(workspaceRoot, outPath);
|
|
3637
3725
|
|
|
3638
3726
|
const L = [`🤖 Generated a flow **proposal** **${flow.name}** from your goal → \`${rel}\``];
|
|
3639
3727
|
L.push(`⚠️ This is an **untrusted draft**, not a committed test. Grounded in ${grounding.screens.length} observed screen(s). ${ungrounded.length ? `⚠️ references unobserved: ${ungrounded.join(", ")} — review before relying on it.` : "All referenced screens were observed."}`);
|
|
@@ -3888,7 +3976,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3888
3976
|
const unauthorized = ensureAuthorized(args);
|
|
3889
3977
|
if (unauthorized) return unauthorized;
|
|
3890
3978
|
try {
|
|
3891
|
-
const saved = await saveInteractiveSessionFlow({ projectDir:
|
|
3979
|
+
const saved = await saveInteractiveSessionFlow({ projectDir:workspaceRoot, name:args.name, addFinalAssertion:args.addFinalAssertion !== false, replace:args.replace === true });
|
|
3892
3980
|
const text = `💾 Saved flow **${saved.flow.name}** → \`${saved.path}\` (${saved.flow.steps.length} steps)\n\n\`\`\`yaml\n${saved.yaml}\n\`\`\`\n\nReplay it anytime: \`tapp_flow_run\` with \`flowPath: "${saved.path}"\`.`;
|
|
3893
3981
|
return richResult(text, { path:saved.path, flow:saved.flow });
|
|
3894
3982
|
} catch (error) {
|
|
@@ -363,6 +363,7 @@ export async function initializeProductProject({
|
|
|
363
363
|
maxContracts = 15,
|
|
364
364
|
testEmail,
|
|
365
365
|
testPassword,
|
|
366
|
+
watch = false,
|
|
366
367
|
runExploration,
|
|
367
368
|
onProgress = () => {},
|
|
368
369
|
onStatus = () => {},
|
|
@@ -394,7 +395,7 @@ export async function initializeProductProject({
|
|
|
394
395
|
exploration = await runExploration({
|
|
395
396
|
projectDir: root, platform: selectedTarget.platform, outDir, url: ownedUrl, target: sourceTarget,
|
|
396
397
|
bundleId, appId: appId || selectedTarget.runtime?.applicationId || "", apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
|
|
397
|
-
testEmail, testPassword, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
|
|
398
|
+
testEmail, testPassword, watch, onProgress: (progress) => onProgress({ ...progress, platform: selectedTarget.platform }), onStatus,
|
|
398
399
|
});
|
|
399
400
|
if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
|
|
400
401
|
}
|