@aarwitz/tapp 0.15.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/AGENTS.md +123 -0
- package/Harness/OCQAHarness/AppDelegate.swift +21 -0
- package/Harness/OCQAHarness/Info.plist +26 -0
- package/Harness/OCQAHarness.xcodeproj/project.pbxproj +199 -0
- package/Harness/OCQAHarness.xcodeproj/xcshareddata/xcschemes/OCQAHarnessUITests.xcscheme +22 -0
- package/Harness/OCQAHarnessUITests/ExplorerTests.swift +4526 -0
- package/Harness/OCQAHarnessUITests/Info.plist +22 -0
- package/Harness/generate-harness-xcodeproj.rb +254 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/bin/tapp.js +1382 -0
- package/browser/app.css +227 -0
- package/browser/app.js +675 -0
- package/browser/index.html +195 -0
- package/browser/product-contract.js +25 -0
- package/browser/view-model.js +16 -0
- package/docs/BROWSER-PRODUCT.md +72 -0
- package/docs/PRODUCT-ENGINE.md +102 -0
- package/docs/application-model.md +276 -0
- package/docs/scenarios.md +95 -0
- package/mcp-server/src/android-driver.js +287 -0
- package/mcp-server/src/android-explorer.js +197 -0
- package/mcp-server/src/android-flow.js +89 -0
- package/mcp-server/src/application-model.js +1597 -0
- package/mcp-server/src/browser-product.js +659 -0
- package/mcp-server/src/browser-workspaces.js +234 -0
- package/mcp-server/src/ci-report.js +557 -0
- package/mcp-server/src/ci-setup.js +359 -0
- package/mcp-server/src/contract-authoring.js +10 -0
- package/mcp-server/src/enrich.js +57 -0
- package/mcp-server/src/flow-runtime.js +127 -0
- package/mcp-server/src/html-report.js +124 -0
- package/mcp-server/src/index.js +3775 -0
- package/mcp-server/src/maintenance-proposal.js +178 -0
- package/mcp-server/src/managed-operation.js +61 -0
- package/mcp-server/src/pr-selection.js +841 -0
- package/mcp-server/src/product-execution.js +155 -0
- package/mcp-server/src/product-operations.js +526 -0
- package/mcp-server/src/project-config.js +101 -0
- package/mcp-server/src/release-contract.d.ts +81 -0
- package/mcp-server/src/release-contract.js +226 -0
- package/mcp-server/src/report.js +363 -0
- package/mcp-server/src/scenario-runtime.js +139 -0
- package/mcp-server/src/static-server.js +44 -0
- package/mcp-server/src/task-runtime.js +266 -0
- package/mcp-server/src/ui-map.js +661 -0
- package/mcp-server/src/web-explorer.js +493 -0
- package/mcp-server/src/web-flow.js +238 -0
- package/package.json +82 -0
- package/scripts/android-corpus-e2e.sh +30 -0
- package/scripts/ci-gate.sh +323 -0
- package/scripts/cleanup-xcode.sh +157 -0
- package/scripts/compile-contract.js +27 -0
- package/scripts/compile-flow.js +18 -0
- package/scripts/corpus-apps.txt +9 -0
- package/scripts/corpus-sweep.sh +121 -0
- package/scripts/coverage-eval.sh +92 -0
- package/scripts/coverage_eval_parse.py +95 -0
- package/scripts/deploy-and-build.sh +99 -0
- package/scripts/flow-platform.js +18 -0
- package/scripts/flow_ai_judge.py +102 -0
- package/scripts/flow_lib.py +154 -0
- package/scripts/mutation-recall-desktop.sh +186 -0
- package/scripts/mutation-recall.sh +121 -0
- package/scripts/mutation_lib.py +128 -0
- package/scripts/mutation_operators.py +144 -0
- package/scripts/platform-gate.js +186 -0
- package/scripts/pr-plan.js +68 -0
- package/scripts/quick-capture.sh +419 -0
- package/scripts/run-android-flow.js +27 -0
- package/scripts/run-flow.sh +90 -0
- package/scripts/run-web-flow.js +28 -0
- package/scripts/run-web-scenario.js +23 -0
- package/scripts/validation-matrix.sh +146 -0
- package/scripts/vision-fp-eval.sh +206 -0
- package/scripts/vision_escalation_responder.py +147 -0
- package/scripts/vision_fp_probe.py +221 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
// Tapp's customer-journey engine. Every user-facing adapter should be thin:
|
|
2
|
+
// validate its transport, call one of these operations, and render the result.
|
|
3
|
+
// This module owns repository artifact semantics and never imports a UI adapter.
|
|
4
|
+
import crypto from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import {
|
|
10
|
+
buildInitArtifacts,
|
|
11
|
+
generateApprovedContractProposals,
|
|
12
|
+
mergeGeneratedTaskProposalValidation,
|
|
13
|
+
promoteValidatedProposals,
|
|
14
|
+
recordContractProposalValidation,
|
|
15
|
+
recordGeneratedTaskProposalValidation,
|
|
16
|
+
reviewReleasePlan,
|
|
17
|
+
writeInitArtifacts,
|
|
18
|
+
} from "./application-model.js";
|
|
19
|
+
import { baselinePathForTarget, renderGithubWorkflow, selectApplicationTarget, writeCiInstallation, writeTargetBaseline } from "./ci-setup.js";
|
|
20
|
+
import { executeReleaseContract, runProductProcess } from "./product-execution.js";
|
|
21
|
+
|
|
22
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
23
|
+
const packageVersion = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version;
|
|
24
|
+
const DEFAULT_ACTION_REF = `aarwitz/tapp@v${packageVersion}`;
|
|
25
|
+
|
|
26
|
+
function realProject(projectDir) {
|
|
27
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
28
|
+
if (!fs.statSync(root).isDirectory()) throw new Error(`Repository directory not found: ${root}`);
|
|
29
|
+
return root;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function inside(root, value) {
|
|
33
|
+
const candidate = path.resolve(value);
|
|
34
|
+
return candidate === root || candidate.startsWith(root + path.sep);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readJson(file, { required = false } = {}) {
|
|
38
|
+
if (!fs.existsSync(file)) {
|
|
39
|
+
if (required) throw new Error(`Required artifact not found: ${file}`);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); }
|
|
43
|
+
catch (error) { throw new Error(`Invalid JSON in ${file}: ${error.message}`); }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function atomicJson(file, value) {
|
|
47
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
48
|
+
const temporary = `${file}.tmp-${process.pid}-${Date.now()}`;
|
|
49
|
+
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + "\n");
|
|
50
|
+
fs.renameSync(temporary, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function artifactPaths(root, outDir = ".autotap") {
|
|
54
|
+
const dir = path.resolve(root, outDir);
|
|
55
|
+
if (!inside(root, dir)) throw new Error("Artifact directory must remain inside the repository");
|
|
56
|
+
return {
|
|
57
|
+
dir,
|
|
58
|
+
model: path.join(dir, "application-model.json"),
|
|
59
|
+
plan: path.join(dir, "release-plan.json"),
|
|
60
|
+
map: path.join(dir, "ui-map.json"),
|
|
61
|
+
ci: path.join(dir, "ci.json"),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function productRunRoot(root) {
|
|
66
|
+
const home = process.env.AUTOTAP_HOME || process.env.TAPP_HOME || path.join(os.homedir(), ".tapp");
|
|
67
|
+
const identity = crypto.createHash("sha256").update(root).digest("hex").slice(0, 16);
|
|
68
|
+
return path.join(home, "product-runs", identity);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function listProductRuns(root) {
|
|
72
|
+
const runsRoot = productRunRoot(root);
|
|
73
|
+
if (!fs.existsSync(runsRoot)) return [];
|
|
74
|
+
return fs.readdirSync(runsRoot, { withFileTypes: true })
|
|
75
|
+
.filter((entry) => entry.isDirectory())
|
|
76
|
+
.map((entry) => {
|
|
77
|
+
const dir = path.join(runsRoot, entry.name);
|
|
78
|
+
const reportPath = path.join(dir, "gate-report.json");
|
|
79
|
+
const markdownPath = path.join(dir, "gate-report.md");
|
|
80
|
+
const report = readJson(reportPath);
|
|
81
|
+
return {
|
|
82
|
+
id: entry.name,
|
|
83
|
+
createdAt: fs.statSync(dir).birthtime.toISOString(),
|
|
84
|
+
status: report ? "completed" : "incomplete",
|
|
85
|
+
verdict: report?.verdict || null,
|
|
86
|
+
gate: report?.gate || null,
|
|
87
|
+
reportPath: fs.existsSync(reportPath) ? reportPath : null,
|
|
88
|
+
markdownPath: fs.existsSync(markdownPath) ? markdownPath : null,
|
|
89
|
+
report,
|
|
90
|
+
};
|
|
91
|
+
})
|
|
92
|
+
.sort((left, right) => right.id.localeCompare(left.id))
|
|
93
|
+
.slice(0, 20);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function listRepositoryFlows(root) {
|
|
97
|
+
const directory = path.join(root, ".autotap", "flows");
|
|
98
|
+
if (!fs.existsSync(directory)) return [];
|
|
99
|
+
return fs.readdirSync(directory, { withFileTypes:true })
|
|
100
|
+
.filter((entry) => entry.isFile() && /\.(?:ya?ml|json)$/i.test(entry.name))
|
|
101
|
+
.map((entry) => {
|
|
102
|
+
const file = path.join(directory, entry.name);
|
|
103
|
+
const relativePath = path.relative(root, file).replaceAll(path.sep, "/");
|
|
104
|
+
let name = entry.name.replace(/\.(?:ya?ml|json)$/i, "");
|
|
105
|
+
let platform = "ios";
|
|
106
|
+
let steps = 0;
|
|
107
|
+
try {
|
|
108
|
+
const source = fs.readFileSync(file, "utf8");
|
|
109
|
+
if (entry.name.endsWith(".json")) {
|
|
110
|
+
const parsed = JSON.parse(source);
|
|
111
|
+
name = String(parsed.name || name);
|
|
112
|
+
platform = String(parsed.platform || (/^https?:\/\//i.test(parsed.url || parsed.app || "") ? "web" : "ios")).toLowerCase();
|
|
113
|
+
steps = Array.isArray(parsed.steps) ? parsed.steps.length : 0;
|
|
114
|
+
} else {
|
|
115
|
+
const scalar = (key) => {
|
|
116
|
+
const match = source.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, "m"));
|
|
117
|
+
return match ? match[1].replace(/^["']|["']$/g, "") : "";
|
|
118
|
+
};
|
|
119
|
+
name = scalar("name") || name;
|
|
120
|
+
platform = (scalar("platform") || (/^url:\s*https?:\/\//im.test(source) ? "web" : "ios")).toLowerCase();
|
|
121
|
+
const block = source.split(/^steps:\s*$/m)[1] || "";
|
|
122
|
+
steps = (block.match(/^\s{2}-\s/gm) || []).length;
|
|
123
|
+
}
|
|
124
|
+
} catch { /* Artifact remains inspectable; deterministic replay reports invalid syntax. */ }
|
|
125
|
+
return { id:`flow_${crypto.createHash("sha256").update(relativePath).digest("hex").slice(0, 16)}`, name, path:relativePath, platform, steps, status:"committed" };
|
|
126
|
+
})
|
|
127
|
+
.sort((left, right) => left.path.localeCompare(right.path));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readProductProject({ projectDir, outDir = ".autotap" } = {}) {
|
|
131
|
+
const root = realProject(projectDir);
|
|
132
|
+
const paths = artifactPaths(root, outDir);
|
|
133
|
+
const model = readJson(paths.model);
|
|
134
|
+
const plan = readJson(paths.plan);
|
|
135
|
+
const map = readJson(paths.map);
|
|
136
|
+
const ci = readJson(paths.ci);
|
|
137
|
+
const requirements = model?.requirements || [];
|
|
138
|
+
const planItems = plan?.items || [];
|
|
139
|
+
const baselines = (model?.targets || []).map((target) => {
|
|
140
|
+
const file = baselinePathForTarget(root, target);
|
|
141
|
+
return { targetId: target.id, platform: target.platform, path: file, relativePath: path.relative(root, file).replaceAll(path.sep, "/"), exists: fs.existsSync(file) };
|
|
142
|
+
});
|
|
143
|
+
return {
|
|
144
|
+
kind: "tapp-product-project",
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
root,
|
|
147
|
+
application: model?.application || { name: path.basename(root), platforms: [], targetIds: [] },
|
|
148
|
+
targets: model?.targets || [],
|
|
149
|
+
actors: model?.actors || [],
|
|
150
|
+
capabilities: model?.capabilities || [],
|
|
151
|
+
requirements,
|
|
152
|
+
model,
|
|
153
|
+
map,
|
|
154
|
+
plan,
|
|
155
|
+
ci,
|
|
156
|
+
baselines,
|
|
157
|
+
flows: listRepositoryFlows(root),
|
|
158
|
+
evidence: listProductRuns(root),
|
|
159
|
+
state: {
|
|
160
|
+
inspected: !!model,
|
|
161
|
+
explored: map?.nodes?.length > 0 && model?.uiMap?.status === "observed",
|
|
162
|
+
reviewComplete: !!plan && !planItems.some((item) => item.decision === "pending"),
|
|
163
|
+
generated: planItems.some((item) => item.generation?.path),
|
|
164
|
+
validated: planItems.some((item) => item.generation?.trusted === true || item.generation?.status === "validated-draft"),
|
|
165
|
+
promoted: planItems.some((item) => item.decision === "accepted" || item.origin === "committed"),
|
|
166
|
+
ciPrepared: !!ci,
|
|
167
|
+
baselineReady: baselines.some((item) => item.exists),
|
|
168
|
+
blockingRequirements: requirements.filter((item) => item.severity === "blocking").length,
|
|
169
|
+
},
|
|
170
|
+
paths,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Resolve and, when needed, build one canonical Application Model target. UI
|
|
175
|
+
// adapters provide platform tool invocations; selection and runtime semantics
|
|
176
|
+
// stay here so browser, CLI, MCP, and managed runners do not invent their own
|
|
177
|
+
// target identity or configuration rules.
|
|
178
|
+
export async function prepareProductTarget({
|
|
179
|
+
projectDir,
|
|
180
|
+
outDir = ".autotap",
|
|
181
|
+
platform = "",
|
|
182
|
+
target = "",
|
|
183
|
+
appPath = "",
|
|
184
|
+
apkPath = "",
|
|
185
|
+
bundleId = "",
|
|
186
|
+
appId = "",
|
|
187
|
+
scheme = "",
|
|
188
|
+
configuration = "",
|
|
189
|
+
buildIos,
|
|
190
|
+
installIos,
|
|
191
|
+
buildAndroid,
|
|
192
|
+
onProgress = () => {},
|
|
193
|
+
} = {}) {
|
|
194
|
+
const root = realProject(projectDir);
|
|
195
|
+
const project = readProductProject({ projectDir: root, outDir });
|
|
196
|
+
if (!project.model) throw new Error("Application model not found; inspect the repository first");
|
|
197
|
+
const selectedTarget = selectApplicationTarget(project.model, { platform, target });
|
|
198
|
+
const runtime = { platform: selectedTarget.platform, target: selectedTarget.id };
|
|
199
|
+
|
|
200
|
+
if (selectedTarget.platform === "web") {
|
|
201
|
+
runtime.url = selectedTarget.runtime?.ownedUrl || "";
|
|
202
|
+
runtime.management = selectedTarget.runtime?.management || "unresolved";
|
|
203
|
+
return { operation: "prepare-target", selectedTarget, runtime };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (selectedTarget.platform === "android") {
|
|
207
|
+
runtime.appId = String(appId || selectedTarget.runtime?.applicationId || "").trim();
|
|
208
|
+
if (!runtime.appId) throw new Error(`Android target '${selectedTarget.name}' needs an application id before it can run`);
|
|
209
|
+
let resolvedApk = String(apkPath || "").trim();
|
|
210
|
+
if (resolvedApk) {
|
|
211
|
+
resolvedApk = path.resolve(resolvedApk);
|
|
212
|
+
if (!fs.existsSync(resolvedApk)) throw new Error(`Android APK not found: ${resolvedApk}`);
|
|
213
|
+
} else {
|
|
214
|
+
if (typeof buildAndroid !== "function") throw new Error(`Android target '${selectedTarget.name}' needs a build-capable runner or a prebuilt APK`);
|
|
215
|
+
onProgress({ phase: "build", text: `Building Android target ${selectedTarget.name}` });
|
|
216
|
+
const built = await buildAndroid({
|
|
217
|
+
projectDir: root,
|
|
218
|
+
gradleProjectDir: path.resolve(root, selectedTarget.build?.projectDir || "."),
|
|
219
|
+
moduleDir: path.resolve(root, selectedTarget.sourcePath || "."),
|
|
220
|
+
task: selectedTarget.build?.task || "assembleDebug",
|
|
221
|
+
target: selectedTarget,
|
|
222
|
+
});
|
|
223
|
+
if (built?.error) throw Object.assign(new Error(built.error), { details: built.details || {} });
|
|
224
|
+
resolvedApk = built?.apkPath || "";
|
|
225
|
+
if (!resolvedApk || !fs.existsSync(resolvedApk)) throw new Error(`Android build for '${selectedTarget.name}' produced no readable APK`);
|
|
226
|
+
runtime.build = built;
|
|
227
|
+
}
|
|
228
|
+
runtime.apkPath = resolvedApk;
|
|
229
|
+
return { operation: "prepare-target", selectedTarget, runtime };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
let resolvedApp = String(appPath || "").trim();
|
|
233
|
+
if (resolvedApp) {
|
|
234
|
+
resolvedApp = path.resolve(resolvedApp);
|
|
235
|
+
if (!resolvedApp.endsWith(".app") || !fs.existsSync(resolvedApp)) throw new Error(`iOS simulator app not found: ${resolvedApp}`);
|
|
236
|
+
} else {
|
|
237
|
+
if (typeof buildIos !== "function") throw new Error(`iOS target '${selectedTarget.name}' needs a macOS/Xcode runner or a prebuilt simulator .app`);
|
|
238
|
+
onProgress({ phase: "build", text: `Building iOS target ${selectedTarget.name}` });
|
|
239
|
+
const built = await buildIos({
|
|
240
|
+
container: path.resolve(root, selectedTarget.build?.container || selectedTarget.sourcePath || "."),
|
|
241
|
+
scheme: String(scheme || selectedTarget.build?.proposedScheme || ""),
|
|
242
|
+
configuration: String(configuration || selectedTarget.build?.configuration || "Debug"),
|
|
243
|
+
target: selectedTarget,
|
|
244
|
+
});
|
|
245
|
+
if (built?.error) throw Object.assign(new Error(built.error), { details: built.details || {} });
|
|
246
|
+
resolvedApp = built?.appPath || "";
|
|
247
|
+
if (!resolvedApp || !fs.existsSync(resolvedApp)) throw new Error(`iOS build for '${selectedTarget.name}' produced no readable simulator .app`);
|
|
248
|
+
runtime.build = built;
|
|
249
|
+
}
|
|
250
|
+
runtime.appPath = resolvedApp;
|
|
251
|
+
runtime.bundleId = String(bundleId || selectedTarget.runtime?.bundleId || "").trim();
|
|
252
|
+
if (typeof installIos === "function") {
|
|
253
|
+
onProgress({ phase: "runtime", text: `Installing ${path.basename(resolvedApp)} on the simulator` });
|
|
254
|
+
const installed = await installIos(resolvedApp);
|
|
255
|
+
if (installed?.error) throw Object.assign(new Error(installed.error), { details: installed.details || {} });
|
|
256
|
+
runtime.bundleId = installed?.bundleId || runtime.bundleId;
|
|
257
|
+
runtime.install = installed;
|
|
258
|
+
}
|
|
259
|
+
return { operation: "prepare-target", selectedTarget, runtime };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export async function initializeProductProject({
|
|
263
|
+
projectDir,
|
|
264
|
+
mode = "inspect",
|
|
265
|
+
outDir = ".autotap",
|
|
266
|
+
ownedUrl = "",
|
|
267
|
+
platform = "",
|
|
268
|
+
target = "",
|
|
269
|
+
bundleId = "",
|
|
270
|
+
appId = "",
|
|
271
|
+
apkPath,
|
|
272
|
+
serial,
|
|
273
|
+
scheme = "",
|
|
274
|
+
configuration = "Debug",
|
|
275
|
+
maxActions = 40,
|
|
276
|
+
timeout = 600,
|
|
277
|
+
maxContracts = 15,
|
|
278
|
+
testEmail,
|
|
279
|
+
testPassword,
|
|
280
|
+
runExploration,
|
|
281
|
+
onProgress = () => {},
|
|
282
|
+
onStatus = () => {},
|
|
283
|
+
} = {}) {
|
|
284
|
+
const root = realProject(projectDir);
|
|
285
|
+
if (!["inspect", "write", "refresh", "explore"].includes(mode)) throw new Error("mode must be inspect|write|refresh|explore");
|
|
286
|
+
if (!Number.isInteger(Number(maxContracts)) || Number(maxContracts) < 1 || Number(maxContracts) > 50) throw new Error("maxContracts must be between 1 and 50");
|
|
287
|
+
const paths = artifactPaths(root, outDir);
|
|
288
|
+
let exploration = null;
|
|
289
|
+
if (mode === "explore") {
|
|
290
|
+
if (typeof runExploration !== "function") throw new Error("The selected adapter did not provide a platform exploration capability");
|
|
291
|
+
const selectedPlatform = String(platform || (ownedUrl ? "web" : appId || apkPath ? "android" : "ios")).toLowerCase();
|
|
292
|
+
exploration = await runExploration({
|
|
293
|
+
projectDir: root, platform: selectedPlatform, outDir, url: ownedUrl, target: target || root,
|
|
294
|
+
bundleId, appId, apkPath, serial, scheme, configuration, maxActions: Number(maxActions), timeout: Number(timeout),
|
|
295
|
+
testEmail, testPassword, onProgress, onStatus,
|
|
296
|
+
});
|
|
297
|
+
if (exploration?.error) throw Object.assign(new Error(exploration.error), { details: exploration.details || {} });
|
|
298
|
+
}
|
|
299
|
+
const built = await buildInitArtifacts({
|
|
300
|
+
projectDir: root,
|
|
301
|
+
ownedUrl: ownedUrl || (exploration?.platform === "web" && !exploration.managedRuntime ? exploration.target : ""),
|
|
302
|
+
platform: String(platform || "").toLowerCase(),
|
|
303
|
+
targetValidation: exploration?.targetValidation || null,
|
|
304
|
+
outDir,
|
|
305
|
+
maxContracts: Number(maxContracts),
|
|
306
|
+
});
|
|
307
|
+
let written = null;
|
|
308
|
+
if (mode !== "inspect") {
|
|
309
|
+
const existing = fs.existsSync(paths.model) || fs.existsSync(paths.plan);
|
|
310
|
+
written = writeInitArtifacts({
|
|
311
|
+
...built,
|
|
312
|
+
root: built.root,
|
|
313
|
+
outDir,
|
|
314
|
+
refresh: mode === "refresh" || (mode === "explore" && existing),
|
|
315
|
+
invalidateValidation: mode === "explore",
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
return { operation: "initialize", mode, model: built.model, plan: written?.plan || built.plan, exploration, written, project: readProductProject({ projectDir: root, outDir }) };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function resolvePlan(root, outDir, planPath = "") {
|
|
322
|
+
const paths = artifactPaths(root, outDir);
|
|
323
|
+
const candidate = path.resolve(root, planPath || path.relative(root, paths.plan));
|
|
324
|
+
const file = fs.existsSync(candidate) ? fs.realpathSync(candidate) : candidate;
|
|
325
|
+
if (!inside(root, file)) throw new Error("Release plan must remain inside the repository");
|
|
326
|
+
return { file, plan: readJson(file, { required: true }) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function reviewProductPlan({ projectDir, outDir = ".autotap", planPath = "", approve = [], reject = [], defer = [] } = {}) {
|
|
330
|
+
const root = realProject(projectDir);
|
|
331
|
+
const resolved = resolvePlan(root, outDir, planPath);
|
|
332
|
+
const plan = reviewReleasePlan(resolved.plan, { approve, reject, defer });
|
|
333
|
+
atomicJson(resolved.file, plan);
|
|
334
|
+
return { operation: "review-plan", plan, planPath: resolved.file, project: readProductProject({ projectDir: root, outDir }) };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function generateProductPlan({ projectDir, outDir = ".autotap", planPath = "" } = {}) {
|
|
338
|
+
const root = realProject(projectDir);
|
|
339
|
+
const resolved = resolvePlan(root, outDir, planPath);
|
|
340
|
+
const result = await generateApprovedContractProposals(resolved.plan, { projectDir: root });
|
|
341
|
+
atomicJson(resolved.file, result.plan);
|
|
342
|
+
return { operation: "generate-plan", ...result, planPath: resolved.file, project: readProductProject({ projectDir: root, outDir }) };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function validateProductPlan({
|
|
346
|
+
projectDir,
|
|
347
|
+
outDir = ".autotap",
|
|
348
|
+
planPath = "",
|
|
349
|
+
items = [],
|
|
350
|
+
platform = "",
|
|
351
|
+
url = "",
|
|
352
|
+
target = "",
|
|
353
|
+
bundleId = "",
|
|
354
|
+
appId = "",
|
|
355
|
+
apkPath = "",
|
|
356
|
+
serial = "",
|
|
357
|
+
timeout = 600,
|
|
358
|
+
testEmail,
|
|
359
|
+
testPassword,
|
|
360
|
+
startWebTarget,
|
|
361
|
+
stopWebTarget,
|
|
362
|
+
onProgress = () => {},
|
|
363
|
+
} = {}) {
|
|
364
|
+
const root = realProject(projectDir);
|
|
365
|
+
const resolved = resolvePlan(root, outDir, planPath);
|
|
366
|
+
let plan = resolved.plan;
|
|
367
|
+
const requested = new Set((items || []).map(String));
|
|
368
|
+
const drafts = (plan.items || []).filter((item) => item.generation?.path && (!requested.size || requested.has(item.id) || requested.has(item.name)));
|
|
369
|
+
if (!drafts.length) throw new Error("No generated contract drafts matched validation");
|
|
370
|
+
const inferred = [...new Set(drafts.flatMap((item) => item.platforms || []))];
|
|
371
|
+
const selectedPlatform = String(platform || (url ? "web" : appId ? "android" : inferred.length === 1 ? inferred[0] : "")).toLowerCase();
|
|
372
|
+
if (!["ios", "android", "web"].includes(selectedPlatform)) throw new Error("A concrete ios|android|web platform is required");
|
|
373
|
+
const platformDrafts = drafts.filter((item) => (item.platforms || []).includes(selectedPlatform));
|
|
374
|
+
if (!platformDrafts.length) throw new Error(`No selected generated drafts apply to ${selectedPlatform}`);
|
|
375
|
+
if (selectedPlatform === "android" && !appId) throw new Error("Android draft validation requires an application id");
|
|
376
|
+
if (selectedPlatform === "ios" && !bundleId) throw new Error("iOS draft validation requires a bundle id");
|
|
377
|
+
let managed = null;
|
|
378
|
+
const results = [];
|
|
379
|
+
try {
|
|
380
|
+
if (selectedPlatform === "web" && !url) {
|
|
381
|
+
if (typeof startWebTarget !== "function") throw new Error("No managed web-target capability was provided");
|
|
382
|
+
managed = await startWebTarget({ root, requestedTarget: target, timeout, onStatus: (text) => onProgress({ phase: "runtime", text }) });
|
|
383
|
+
if (managed?.error) throw new Error(managed.error);
|
|
384
|
+
url = managed.url;
|
|
385
|
+
}
|
|
386
|
+
for (let index = 0; index < platformDrafts.length; index += 1) {
|
|
387
|
+
const item = platformDrafts[index];
|
|
388
|
+
onProgress({ phase: "validate", current: index + 1, total: platformDrafts.length, item: item.name, text: `Replaying ${item.title || item.name}` });
|
|
389
|
+
const contractPath = path.resolve(root, item.generation.path);
|
|
390
|
+
let execution;
|
|
391
|
+
if (!inside(root, contractPath) || !fs.existsSync(contractPath)) {
|
|
392
|
+
execution = { passed: false, stderr: "generated draft file missing", evidence: "" };
|
|
393
|
+
} else {
|
|
394
|
+
execution = await executeReleaseContract({ projectDir: root, contractPath, platform: selectedPlatform, url, bundleId, appId, apkPath, serial, timeout, testEmail, testPassword, onOutput: ({ text }) => onProgress({ phase: "execute", item: item.name, text: text.trim().slice(-500) }) });
|
|
395
|
+
}
|
|
396
|
+
let passed = execution.passed;
|
|
397
|
+
let detail = passed ? "deterministic replay passed" : String(execution.stderr || execution.stdout || "replay failed").trim().slice(-1000);
|
|
398
|
+
let taskUpdates = [];
|
|
399
|
+
if (passed) {
|
|
400
|
+
try { taskUpdates = recordGeneratedTaskProposalValidation({ projectDir: root, item, platform: selectedPlatform, evidence: execution.evidence, detail }); }
|
|
401
|
+
catch (error) { passed = false; detail = `Replay passed but Task validation evidence could not be persisted: ${error.message || String(error)}`; }
|
|
402
|
+
}
|
|
403
|
+
plan = recordContractProposalValidation(plan, { id: item.id, platform: selectedPlatform, passed, evidence: execution.evidence, detail });
|
|
404
|
+
if (taskUpdates.length) plan = mergeGeneratedTaskProposalValidation(plan, taskUpdates);
|
|
405
|
+
results.push({ item: item.name, passed, detail, execution });
|
|
406
|
+
}
|
|
407
|
+
} finally {
|
|
408
|
+
if (managed && typeof stopWebTarget === "function") await stopWebTarget(managed);
|
|
409
|
+
}
|
|
410
|
+
atomicJson(resolved.file, plan);
|
|
411
|
+
return { operation: "validate-plan", platform: selectedPlatform, passed: results.every((item) => item.passed), results, plan, planPath: resolved.file, project: readProductProject({ projectDir: root, outDir }) };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function promoteProductPlan({ projectDir, outDir = ".autotap", planPath = "", items = [] } = {}) {
|
|
415
|
+
const root = realProject(projectDir);
|
|
416
|
+
const resolved = resolvePlan(root, outDir, planPath);
|
|
417
|
+
const result = await promoteValidatedProposals(resolved.plan, { projectDir: root, ids: items || [] });
|
|
418
|
+
atomicJson(resolved.file, result.plan);
|
|
419
|
+
// Promotion changes the authoritative Task/contract inventory and UI Map coverage. Refresh the
|
|
420
|
+
// derived application model immediately so no adapter can show a stale pre-promotion warning.
|
|
421
|
+
const refreshed = await buildInitArtifacts({ projectDir: root, outDir });
|
|
422
|
+
const written = writeInitArtifacts({ ...refreshed, root: refreshed.root, outDir, refresh: true });
|
|
423
|
+
return { operation: "promote-plan", ...result, plan: written.plan, planPath: written.planPath, modelPath: written.modelPath, project: readProductProject({ projectDir: root, outDir }) };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
export function prepareProductCi({ projectDir, outDir = ".autotap", modelPath = "", actionRef = DEFAULT_ACTION_REF, defaultBranch = "main" } = {}) {
|
|
427
|
+
const root = realProject(projectDir);
|
|
428
|
+
const modelFile = modelPath ? path.resolve(root, modelPath) : artifactPaths(root, outDir).model;
|
|
429
|
+
if (!inside(root, modelFile)) throw new Error("Application model must remain inside the repository");
|
|
430
|
+
const model = readJson(modelFile);
|
|
431
|
+
if (!model) throw new Error("Application model not found; initialize and explore the project first");
|
|
432
|
+
const rendered = renderGithubWorkflow({ projectDir: root, model, actionRef, defaultBranch });
|
|
433
|
+
return { operation: "prepare-ci", ...rendered, project: readProductProject({ projectDir: root, outDir }) };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export function installProductCi({ projectDir, outDir = ".autotap", modelPath = "", actionRef = DEFAULT_ACTION_REF, defaultBranch = "main", workflowPath = ".github/workflows/tapp.yml", manifestPath = ".autotap/ci.json", replace = false, allowUnresolved = false } = {}) {
|
|
437
|
+
const root = realProject(projectDir);
|
|
438
|
+
const rendered = prepareProductCi({ projectDir: root, outDir, modelPath, actionRef, defaultBranch });
|
|
439
|
+
if (rendered.manifest.unresolved.length && !allowUnresolved) throw new Error(`CI installation is unresolved: ${rendered.manifest.unresolved.map((item) => `${item.platform}:${item.message}`).join("; ")}`);
|
|
440
|
+
const written = writeCiInstallation({ projectDir: root, workflow: rendered.workflow, manifest: rendered.manifest, workflowPath, manifestPath, replace });
|
|
441
|
+
return { operation: "install-ci", ...written, project: readProductProject({ projectDir: root, outDir }) };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function runProductGate({
|
|
445
|
+
projectDir,
|
|
446
|
+
outDir = ".autotap",
|
|
447
|
+
platform = "web",
|
|
448
|
+
target = "",
|
|
449
|
+
url = "",
|
|
450
|
+
appPath = "",
|
|
451
|
+
bundleId = "",
|
|
452
|
+
appId = "",
|
|
453
|
+
apkPath = "",
|
|
454
|
+
serial = "",
|
|
455
|
+
device = "",
|
|
456
|
+
flows = "",
|
|
457
|
+
scenarios = "",
|
|
458
|
+
contracts = "",
|
|
459
|
+
actions = 40,
|
|
460
|
+
timeout = 600,
|
|
461
|
+
baseline = "",
|
|
462
|
+
failOn = "gate",
|
|
463
|
+
testEmail,
|
|
464
|
+
testPassword,
|
|
465
|
+
onProgress = () => {},
|
|
466
|
+
} = {}) {
|
|
467
|
+
const root = realProject(projectDir);
|
|
468
|
+
const project = readProductProject({ projectDir: root, outDir });
|
|
469
|
+
if (!project.model) throw new Error("Application model not found; initialize and explore the project first");
|
|
470
|
+
const selected = selectApplicationTarget(project.model, { platform, target });
|
|
471
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "");
|
|
472
|
+
const runDir = path.join(productRunRoot(root), `${stamp}-${crypto.randomBytes(3).toString("hex")}`);
|
|
473
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
474
|
+
const reportPath = path.join(runDir, "gate-report.json");
|
|
475
|
+
const markdownPath = path.join(runDir, "gate-report.md");
|
|
476
|
+
const args = [path.join(packageRoot, "scripts", "ci-gate.sh"), "--platform", selected.platform, "--project-dir", root, "--target-key", selected.id, "--actions", String(actions), "--timeout", String(timeout), "--fail-on", failOn, "--json-out", reportPath, "--md-out", markdownPath];
|
|
477
|
+
if (selected.platform === "web") {
|
|
478
|
+
if (url) args.push("--url", url);
|
|
479
|
+
else args.push("--web-target", selected.id);
|
|
480
|
+
} else if (selected.platform === "android") {
|
|
481
|
+
const selectedAppId = appId || selected.runtime?.applicationId || "";
|
|
482
|
+
if (!selectedAppId) throw new Error(`Android gate for ${selected.name} requires an application id`);
|
|
483
|
+
args.push("--app-id", selectedAppId);
|
|
484
|
+
if (apkPath) {
|
|
485
|
+
const absoluteApk = path.resolve(apkPath);
|
|
486
|
+
if (!fs.existsSync(absoluteApk)) throw new Error(`Android APK not found: ${absoluteApk}`);
|
|
487
|
+
args.push("--apk", absoluteApk);
|
|
488
|
+
}
|
|
489
|
+
if (serial) args.push("--serial", serial);
|
|
490
|
+
} else {
|
|
491
|
+
if (!appPath) throw new Error(`iOS gate for ${selected.name} requires a built simulator .app`);
|
|
492
|
+
const absoluteApp = path.resolve(appPath);
|
|
493
|
+
if (!fs.existsSync(absoluteApp)) throw new Error(`iOS simulator app not found: ${absoluteApp}`);
|
|
494
|
+
args.push("--app", absoluteApp);
|
|
495
|
+
if (bundleId) args.push("--bundle-id", bundleId);
|
|
496
|
+
}
|
|
497
|
+
if (device) args.push("--device", device);
|
|
498
|
+
for (const [flag, value] of [["flows", flows], ["scenarios", scenarios], ["contracts", contracts]]) if (value) args.push(`--${flag}`, value);
|
|
499
|
+
if (baseline) {
|
|
500
|
+
const baselinePath = path.resolve(root, baseline);
|
|
501
|
+
if (!inside(root, baselinePath) || !fs.existsSync(baselinePath)) throw new Error("Baseline must be an existing file inside the repository");
|
|
502
|
+
args.push("--baseline", baselinePath);
|
|
503
|
+
} else {
|
|
504
|
+
const targetBaseline = baselinePathForTarget(root, selected);
|
|
505
|
+
if (fs.existsSync(targetBaseline)) args.push("--baseline", targetBaseline);
|
|
506
|
+
}
|
|
507
|
+
onProgress({ phase: "gate", text: `Running ${selected.platform}:${selected.name} release gate` });
|
|
508
|
+
const env = {
|
|
509
|
+
...process.env,
|
|
510
|
+
...(typeof testEmail === "string" ? { OCQA_TEST_EMAIL: testEmail } : {}),
|
|
511
|
+
...(typeof testPassword === "string" ? { OCQA_TEST_PASSWORD: testPassword } : {}),
|
|
512
|
+
};
|
|
513
|
+
const execution = await runProductProcess("bash", args, { cwd: root, env, timeoutMs: Math.max(30, Math.min(3600, Number(timeout) || 600)) * 1000 + 60_000, onOutput: ({ text }) => onProgress({ phase: "gate", text: text.trim().slice(-1000) }) });
|
|
514
|
+
const report = readJson(reportPath);
|
|
515
|
+
return { operation: "run-gate", passed: execution.code === 0, code: execution.code, stdout: execution.stdout, stderr: execution.stderr, selectedTarget: selected, report, reportPath, markdownPath, runDir, project: readProductProject({ projectDir: root, outDir }) };
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function createProductBaseline({ projectDir, outDir = ".autotap", reportPath, platform = "web", target = "", replace = false, baselinePath = "" } = {}) {
|
|
519
|
+
const root = realProject(projectDir);
|
|
520
|
+
const project = readProductProject({ projectDir: root, outDir });
|
|
521
|
+
const selected = selectApplicationTarget(project.model, { platform, target });
|
|
522
|
+
const source = path.resolve(reportPath || "");
|
|
523
|
+
const report = readJson(source, { required: true });
|
|
524
|
+
const written = writeTargetBaseline({ projectDir: root, target: selected, report, sourceReport: source, outPath: baselinePath ? path.resolve(root, baselinePath) : "", replace });
|
|
525
|
+
return { operation: "create-baseline", selectedTarget: selected, ...written, project: readProductProject({ projectDir: root, outDir }) };
|
|
526
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const PROJECT_CONFIG_RELATIVE_PATH = ".autotap/project.json";
|
|
5
|
+
|
|
6
|
+
const ACTOR_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
7
|
+
const ENV_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/;
|
|
8
|
+
const CREDENTIAL_NAME = /^[a-z][A-Za-z0-9_-]{0,63}$/;
|
|
9
|
+
const SESSIONS = new Set(["default", "isolated"]);
|
|
10
|
+
const PROVISIONING_MODES = new Set(["existing", "seeded", "api", "unknown"]);
|
|
11
|
+
|
|
12
|
+
function cleanActor(actor) {
|
|
13
|
+
return {
|
|
14
|
+
...(actor.role ? { role: actor.role } : {}),
|
|
15
|
+
session: actor.session || "default",
|
|
16
|
+
provisioning: actor.provisioning || "existing",
|
|
17
|
+
credentials: Object.fromEntries(Object.entries(actor.credentials || {}).sort(([a], [b]) => a.localeCompare(b)).map(([name, binding]) => [name, { env: binding.env }])),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateProjectConfig(config) {
|
|
22
|
+
const errors = [];
|
|
23
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) return ["Project configuration must be an object"];
|
|
24
|
+
for (const key of Object.keys(config)) if (!["kind", "schemaVersion", "actors", "lifecycle", "provenance"].includes(key)) errors.push(`unsupported project configuration field '${key}'`);
|
|
25
|
+
if (config.kind !== "tapp-project-config") errors.push("kind must be 'tapp-project-config'");
|
|
26
|
+
if (config.schemaVersion !== 1) errors.push("schemaVersion must be 1");
|
|
27
|
+
if (config.actors !== undefined && (!config.actors || typeof config.actors !== "object" || Array.isArray(config.actors))) errors.push("actors must be an object");
|
|
28
|
+
for (const [name, actor] of Object.entries(config.actors || {})) {
|
|
29
|
+
if (!ACTOR_NAME.test(name)) errors.push(`actor '${name}' must match ${ACTOR_NAME}`);
|
|
30
|
+
if (!actor || typeof actor !== "object" || Array.isArray(actor)) { errors.push(`actor '${name}' must be an object`); continue; }
|
|
31
|
+
for (const key of Object.keys(actor)) if (!["role", "session", "provisioning", "credentials"].includes(key)) errors.push(`actor '${name}' has unsupported field '${key}'; credential values are forbidden`);
|
|
32
|
+
if (actor.role !== undefined && (typeof actor.role !== "string" || !actor.role.trim())) errors.push(`actor '${name}'.role must be a non-empty string`);
|
|
33
|
+
if (!SESSIONS.has(actor.session || "default")) errors.push(`actor '${name}'.session must be default|isolated`);
|
|
34
|
+
if (!PROVISIONING_MODES.has(actor.provisioning || "existing")) errors.push(`actor '${name}'.provisioning must be existing|seeded|api|unknown`);
|
|
35
|
+
if (actor.credentials !== undefined && (!actor.credentials || typeof actor.credentials !== "object" || Array.isArray(actor.credentials))) {
|
|
36
|
+
errors.push(`actor '${name}'.credentials must be an object of environment bindings`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
for (const [credential, binding] of Object.entries(actor.credentials || {})) {
|
|
40
|
+
if (!CREDENTIAL_NAME.test(credential)) errors.push(`actor '${name}' credential '${credential}' has an invalid name`);
|
|
41
|
+
if (!binding || typeof binding !== "object" || Array.isArray(binding) || !ENV_NAME.test(String(binding.env || ""))) errors.push(`actor '${name}' credential '${credential}' must define env with an uppercase environment-variable name`);
|
|
42
|
+
for (const key of Object.keys(binding || {})) if (key !== "env") errors.push(`actor '${name}' credential '${credential}' may contain only env; credential values are forbidden`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (config.lifecycle !== undefined && (!config.lifecycle || typeof config.lifecycle !== "object" || Array.isArray(config.lifecycle))) errors.push("lifecycle must be an object");
|
|
46
|
+
for (const key of Object.keys(config.lifecycle || {})) if (!["setup", "teardown"].includes(key)) errors.push(`lifecycle has unsupported phase '${key}'`);
|
|
47
|
+
if (config.provenance !== undefined && (!config.provenance || typeof config.provenance !== "object" || Array.isArray(config.provenance))) errors.push("provenance must be an object");
|
|
48
|
+
for (const key of Object.keys(config.provenance || {})) if (!["updatedAt", "updatedBy"].includes(key)) errors.push(`provenance has unsupported field '${key}'`);
|
|
49
|
+
for (const phase of ["setup", "teardown"]) {
|
|
50
|
+
if (config.lifecycle?.[phase] !== undefined && !Array.isArray(config.lifecycle[phase])) errors.push(`lifecycle.${phase} must be an array`);
|
|
51
|
+
for (const [index, step] of (config.lifecycle?.[phase] || []).entries()) {
|
|
52
|
+
if (!step?.request || typeof step.request !== "object") errors.push(`lifecycle.${phase}[${index}] must be a request step`);
|
|
53
|
+
else if (!/^\/(?!\/)/.test(String(step.request.path || ""))) errors.push(`lifecycle.${phase}[${index}].request.path must be same-origin and start with one /`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return errors;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readProjectConfig(projectDir, { required = false } = {}) {
|
|
60
|
+
const root = fs.realpathSync(path.resolve(projectDir || process.cwd()));
|
|
61
|
+
const configPath = path.join(root, PROJECT_CONFIG_RELATIVE_PATH);
|
|
62
|
+
if (!fs.existsSync(configPath)) {
|
|
63
|
+
if (required) throw new Error(`Project configuration not found: ${configPath}`);
|
|
64
|
+
return { root, path: configPath, relativePath: PROJECT_CONFIG_RELATIVE_PATH, config: { kind: "tapp-project-config", schemaVersion: 1, actors: {} }, exists: false, errors: [] };
|
|
65
|
+
}
|
|
66
|
+
let config;
|
|
67
|
+
try { config = JSON.parse(fs.readFileSync(configPath, "utf8")); }
|
|
68
|
+
catch (error) { return { root, path: configPath, relativePath: PROJECT_CONFIG_RELATIVE_PATH, config: null, exists: true, errors: [`Invalid JSON: ${error.message}`] }; }
|
|
69
|
+
return { root, path: configPath, relativePath: PROJECT_CONFIG_RELATIVE_PATH, config, exists: true, errors: validateProjectConfig(config) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function configureActor(projectDir, { name, role = "", session = "default", provisioning = "existing", credentials = {}, replace = false } = {}) {
|
|
73
|
+
const loaded = readProjectConfig(projectDir);
|
|
74
|
+
if (loaded.errors.length) throw new Error(`Existing project configuration is invalid: ${loaded.errors.join("; ")}`);
|
|
75
|
+
if (!ACTOR_NAME.test(String(name || ""))) throw new Error("Actor name must start with a letter and contain only letters, numbers, underscore, or hyphen (max 64)");
|
|
76
|
+
if (loaded.config.actors?.[name] && !replace) throw new Error(`Actor '${name}' already exists; inspect it or pass --replace to update its non-secret bindings`);
|
|
77
|
+
const actor = cleanActor({ role: String(role || "").trim(), session, provisioning, credentials });
|
|
78
|
+
const next = {
|
|
79
|
+
...loaded.config,
|
|
80
|
+
kind: "tapp-project-config",
|
|
81
|
+
schemaVersion: 1,
|
|
82
|
+
actors: { ...(loaded.config.actors || {}), [name]: actor },
|
|
83
|
+
provenance: { ...(loaded.config.provenance || {}), updatedAt: new Date().toISOString(), updatedBy: "explicit-human-configuration" },
|
|
84
|
+
};
|
|
85
|
+
const errors = validateProjectConfig(next);
|
|
86
|
+
if (errors.length) throw new Error(errors.join("; "));
|
|
87
|
+
fs.mkdirSync(path.dirname(loaded.path), { recursive: true });
|
|
88
|
+
const temporary = `${loaded.path}.tmp-${process.pid}-${Date.now()}`;
|
|
89
|
+
fs.writeFileSync(temporary, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
|
|
90
|
+
fs.renameSync(temporary, loaded.path);
|
|
91
|
+
return { path: loaded.path, relativePath: loaded.relativePath, config: next, actor: next.actors[name] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function credentialBindingsFromValue(credentials = {}) {
|
|
95
|
+
const out = {};
|
|
96
|
+
for (const [name, value] of Object.entries(credentials || {})) {
|
|
97
|
+
const match = /^\$([A-Z_][A-Z0-9_]*)$/.exec(String(value || ""));
|
|
98
|
+
if (match) out[name] = match[1];
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|