run402-mcp 3.7.4 → 3.7.6

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.
Files changed (54) hide show
  1. package/README.md +8 -7
  2. package/dist/index.js +6 -4
  3. package/dist/index.js.map +1 -1
  4. package/dist/tools/app-up.d.ts +40 -0
  5. package/dist/tools/app-up.d.ts.map +1 -0
  6. package/dist/tools/app-up.js +48 -0
  7. package/dist/tools/app-up.js.map +1 -0
  8. package/dist/tools/create-mailbox.d.ts.map +1 -1
  9. package/dist/tools/create-mailbox.js +4 -1
  10. package/dist/tools/create-mailbox.js.map +1 -1
  11. package/dist/tools/get-mailbox.d.ts.map +1 -1
  12. package/dist/tools/get-mailbox.js +7 -0
  13. package/dist/tools/get-mailbox.js.map +1 -1
  14. package/dist/tools/list-mailboxes.d.ts.map +1 -1
  15. package/dist/tools/list-mailboxes.js +7 -0
  16. package/dist/tools/list-mailboxes.js.map +1 -1
  17. package/dist/tools/remove-sender-domain.js +1 -1
  18. package/dist/tools/remove-sender-domain.js.map +1 -1
  19. package/dist/tools/sender-domain-status.js +1 -1
  20. package/dist/tools/sender-domain-status.js.map +1 -1
  21. package/package.json +3 -2
  22. package/schemas/run402-app.v1.schema.json +304 -0
  23. package/sdk/README.md +4 -5
  24. package/sdk/dist/actions.d.ts +14 -1
  25. package/sdk/dist/actions.d.ts.map +1 -1
  26. package/sdk/dist/actions.js.map +1 -1
  27. package/sdk/dist/app-up.d.ts +271 -0
  28. package/sdk/dist/app-up.d.ts.map +1 -0
  29. package/sdk/dist/app-up.js +500 -0
  30. package/sdk/dist/app-up.js.map +1 -0
  31. package/sdk/dist/index.d.ts +1 -0
  32. package/sdk/dist/index.d.ts.map +1 -1
  33. package/sdk/dist/index.js +1 -0
  34. package/sdk/dist/index.js.map +1 -1
  35. package/sdk/dist/namespaces/email.d.ts +14 -2
  36. package/sdk/dist/namespaces/email.d.ts.map +1 -1
  37. package/sdk/dist/namespaces/email.js +4 -0
  38. package/sdk/dist/namespaces/email.js.map +1 -1
  39. package/sdk/dist/namespaces/projects.types.d.ts +1 -1
  40. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  41. package/sdk/dist/namespaces/sender-domain.d.ts +1 -1
  42. package/sdk/dist/namespaces/sender-domain.d.ts.map +1 -1
  43. package/sdk/dist/namespaces/sender-domain.js +1 -1
  44. package/sdk/dist/namespaces/sender-domain.js.map +1 -1
  45. package/sdk/dist/node/actions-node.d.ts.map +1 -1
  46. package/sdk/dist/node/actions-node.js +417 -5
  47. package/sdk/dist/node/actions-node.js.map +1 -1
  48. package/sdk/dist/node/index.d.ts +1 -0
  49. package/sdk/dist/node/index.d.ts.map +1 -1
  50. package/sdk/dist/node/index.js +12 -3
  51. package/sdk/dist/node/index.js.map +1 -1
  52. package/sdk/dist/node/target-profile.js +15 -3
  53. package/sdk/dist/node/target-profile.js.map +1 -1
  54. package/README.zh-CN.md +0 -169
@@ -1,11 +1,16 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
3
- import { dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
2
+ import { execFile } from "node:child_process";
3
+ import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
6
+ import { promisify } from "node:util";
4
7
  import { Run402Action } from "../actions.js";
8
+ import { RUN402_APP_MANIFEST_FILENAME, compileRun402AppInstallGraph, createRun402AppUpResult, } from "../app-up.js";
5
9
  import { LocalError } from "../errors.js";
6
10
  import { loadDeployManifest } from "./deploy-manifest.js";
7
11
  const DEFAULT_BOOTSTRAP_TIER = "prototype";
8
12
  const MANIFEST_CANDIDATES = [
13
+ RUN402_APP_MANIFEST_FILENAME,
9
14
  "run402.deploy.json",
10
15
  "app.json",
11
16
  ];
@@ -22,6 +27,7 @@ const TIER_RANK = {
22
27
  hobby: 2,
23
28
  team: 3,
24
29
  };
30
+ const execFileAsync = promisify(execFile);
25
31
  /**
26
32
  * Node implementation of the action runner. The public CLI should treat this
27
33
  * as the orchestration kernel and stay a flag parser / renderer.
@@ -126,9 +132,40 @@ export class NodeActions {
126
132
  return run.result(result);
127
133
  }
128
134
  async #runUp(input, run) {
129
- const workspaceDir = resolvePath(this.opts.cwd ?? process.cwd(), input.dir ?? ".");
130
- const manifest = await this.#discoverAndValidateManifest(input, workspaceDir, run);
135
+ if (input.source && input.dir) {
136
+ throw run.error("Pass either an app source positional/repo URL or --dir, not both.", "RUN402_SOURCE_CONFLICT", { source: input.source, dir: input.dir });
137
+ }
138
+ const startedAt = new Date().toISOString();
139
+ const source = await this.#resolveUpSource(input, run);
140
+ const workspaceDir = source.workspaceDir;
141
+ const manifest = await this.#discoverAndValidateManifest(input, workspaceDir, source.metadata, run);
142
+ if (manifest.manifestKind === "app") {
143
+ const appResult = this.#planAppUpResult(input, manifest, run, {
144
+ startedAt,
145
+ status: "planned",
146
+ dryRun: run.dryRun || run.executionMode === "check" || run.executionMode === "printSpec",
147
+ projectId: input.projectId ?? (run.dryRun ? "prj_planned" : null),
148
+ });
149
+ if (run.executionMode === "check" || run.executionMode === "printSpec" || run.dryRun) {
150
+ return run.result({
151
+ project_id: input.projectId ?? "prj_planned",
152
+ manifest_path: manifest.manifestPath,
153
+ app_graph: manifest.appGraph,
154
+ app_result: appResult,
155
+ });
156
+ }
157
+ const blocked = this.#blockedAppUpResult(input, manifest, run, startedAt);
158
+ return run.result({
159
+ project_id: input.projectId ?? "prj_planned",
160
+ manifest_path: manifest.manifestPath,
161
+ app_graph: manifest.appGraph,
162
+ app_result: blocked,
163
+ });
164
+ }
131
165
  if (run.executionMode === "check" || run.executionMode === "printSpec") {
166
+ if (!manifest.releaseSpec) {
167
+ throw run.error("Internal error: release manifest did not produce a ReleaseSpec.", "RUN402_ACTION_INTERNAL");
168
+ }
132
169
  return run.result({
133
170
  project_id: manifest.releaseSpec.project,
134
171
  manifest_path: manifest.manifestPath,
@@ -242,7 +279,7 @@ export class NodeActions {
242
279
  deploy,
243
280
  });
244
281
  }
245
- async #discoverAndValidateManifest(input, workspaceDir, run) {
282
+ async #discoverAndValidateManifest(input, workspaceDir, source, run) {
246
283
  const step = run.addStep({
247
284
  action: "deploy.discover",
248
285
  description: "Discover and validate deploy manifest",
@@ -269,6 +306,38 @@ export class NodeActions {
269
306
  }
270
307
  throw run.error("No deploy manifest found. Add run402.deploy.json or app.json, or pass --manifest for executable configs.", "UP_MANIFEST_REQUIRED", { dir: workspaceDir, candidates: MANIFEST_CANDIDATES, executable_candidates: EXECUTABLE_MANIFEST_CANDIDATES });
271
308
  }
309
+ if (basename(manifestPath) === RUN402_APP_MANIFEST_FILENAME) {
310
+ const loadedAppSpec = await loadRun402AppManifest(manifestPath);
311
+ const appSpec = input.buildMode
312
+ ? {
313
+ ...loadedAppSpec,
314
+ build: {
315
+ ...(loadedAppSpec.build ?? {}),
316
+ mode: input.buildMode,
317
+ },
318
+ }
319
+ : loadedAppSpec;
320
+ const appGraph = await compileRun402AppInstallGraph(appSpec, {
321
+ source,
322
+ ...(input.name ? { name: input.name } : {}),
323
+ ...(input.idempotencyKey ?? run.rootIdempotencyKey
324
+ ? { root_idempotency_key: input.idempotencyKey ?? run.rootIdempotencyKey }
325
+ : {}),
326
+ });
327
+ run.setState(step, "succeeded", {
328
+ manifest_kind: "app",
329
+ manifest_path: manifestPath,
330
+ app_id: appSpec.app.id,
331
+ graph_digest: appGraph.graph_digest,
332
+ });
333
+ return {
334
+ manifestKind: "app",
335
+ manifestPath,
336
+ releaseSpec: null,
337
+ appGraph,
338
+ appSpec,
339
+ };
340
+ }
272
341
  const loaded = await loadDeployManifest(manifestPath, {
273
342
  ...(input.projectId
274
343
  ? { project: input.projectId }
@@ -283,6 +352,7 @@ export class NodeActions {
283
352
  idempotency_key: loaded.idempotencyKey ?? null,
284
353
  });
285
354
  return {
355
+ manifestKind: "release",
286
356
  manifestPath,
287
357
  releaseSpec: loaded.spec,
288
358
  idempotencyKey: loaded.idempotencyKey,
@@ -291,6 +361,162 @@ export class NodeActions {
291
361
  : loaded.spec.project,
292
362
  };
293
363
  }
364
+ async #resolveUpSource(input, run) {
365
+ const baseDir = resolvePath(this.opts.cwd ?? process.cwd());
366
+ const rawSource = input.source ?? input.dir ?? ".";
367
+ const step = run.addStep({
368
+ action: "app.source.resolve",
369
+ description: "Resolve app source",
370
+ mutation: false,
371
+ auto: true,
372
+ details: { source: rawSource },
373
+ });
374
+ run.setState(step, "running");
375
+ if (isRepositoryUrl(rawSource)) {
376
+ const root = await mkdtemp(join(tmpdir(), "run402-app-source-"));
377
+ const checkout = join(root, "repo");
378
+ try {
379
+ await execFileAsync("git", ["clone", "--depth", "1", rawSource, checkout], {
380
+ maxBuffer: 1024 * 1024 * 8,
381
+ });
382
+ const commit = await gitCommit(checkout);
383
+ run.setState(step, "succeeded", {
384
+ kind: "repo",
385
+ repo_url: rawSource,
386
+ commit: commit ?? null,
387
+ checkout,
388
+ });
389
+ return {
390
+ workspaceDir: checkout,
391
+ metadata: {
392
+ kind: "repo",
393
+ repo_url: rawSource,
394
+ ...(commit ? { commit } : {}),
395
+ },
396
+ cleanupDir: root,
397
+ };
398
+ }
399
+ catch (err) {
400
+ await rm(root, { recursive: true, force: true });
401
+ throw run.error(`Failed to resolve repository source ${rawSource}.`, "RUN402_SOURCE_RESOLVE_FAILED", { source: rawSource, error: err instanceof Error ? err.message : String(err) });
402
+ }
403
+ }
404
+ const workspaceDir = resolveMaybe(baseDir, rawSource);
405
+ const stat = await lstat(workspaceDir).catch(() => null);
406
+ if (!stat || !stat.isDirectory()) {
407
+ throw run.error("App source must be an existing directory or repository URL.", "RUN402_SOURCE_INVALID", { source: rawSource, resolved_path: workspaceDir });
408
+ }
409
+ const commit = await gitCommit(workspaceDir);
410
+ run.setState(step, "succeeded", {
411
+ kind: "local",
412
+ path: workspaceDir,
413
+ commit: commit ?? null,
414
+ });
415
+ return {
416
+ workspaceDir,
417
+ metadata: {
418
+ kind: "local",
419
+ path: workspaceDir,
420
+ ...(commit ? { commit } : {}),
421
+ },
422
+ };
423
+ }
424
+ #planAppUpResult(input, manifest, run, opts) {
425
+ if (!manifest.appGraph) {
426
+ throw run.error("Internal error: app manifest did not produce an install graph.", "RUN402_ACTION_INTERNAL");
427
+ }
428
+ return createRun402AppUpResult({
429
+ graph: manifest.appGraph,
430
+ manifest_path: manifest.manifestPath,
431
+ status: opts.status,
432
+ started_at: opts.startedAt,
433
+ dry_run: opts.dryRun,
434
+ project_id: opts.projectId,
435
+ project_name: input.name ?? manifest.appSpec?.project.name ?? null,
436
+ public_origin: appPublicOrigin(input, manifest.appSpec),
437
+ approval_policy: {
438
+ yes: run.approval === "yes",
439
+ allow_prune: input.allowPrune === true,
440
+ max_spend_usd: input.maxSpendUsd ?? null,
441
+ build_mode: input.buildMode ?? manifest.appSpec?.build?.mode ?? null,
442
+ shell_build_approved: input.allowShellBuild === true,
443
+ },
444
+ diagnostics: opts.diagnostics,
445
+ next_actions: opts.nextActions,
446
+ blocked_node_id: opts.blockedNodeId,
447
+ });
448
+ }
449
+ #blockedAppUpResult(input, manifest, run, startedAt) {
450
+ const block = this.#firstAppUpBlock(input, manifest, run);
451
+ return this.#planAppUpResult(input, manifest, run, {
452
+ startedAt,
453
+ status: "blocked",
454
+ dryRun: false,
455
+ projectId: input.projectId ?? null,
456
+ diagnostics: block.diagnostics,
457
+ nextActions: block.nextActions,
458
+ blockedNodeId: block.nodeId,
459
+ });
460
+ }
461
+ #firstAppUpBlock(input, manifest, run) {
462
+ const nameBlock = missingRequiredName(input, manifest);
463
+ if (nameBlock)
464
+ return nameBlock;
465
+ const missingSecrets = missingRequiredSecrets(manifest.appSpec);
466
+ if (missingSecrets.length > 0) {
467
+ return {
468
+ nodeId: "secrets.ensure",
469
+ diagnostics: missingSecrets.map((secret) => ({
470
+ code: "MISSING_SECRET",
471
+ severity: "error",
472
+ node_id: "secrets.ensure",
473
+ field_path: `secrets.${secret.name}`,
474
+ message: [
475
+ `Required user secret ${secret.name} is missing from approved env source ${secret.sourceEnv}.`,
476
+ secret.description ? `Usage: ${secret.description}` : "",
477
+ ].filter(Boolean).join(" "),
478
+ details: {
479
+ source_env: secret.sourceEnv,
480
+ ...(secret.description ? { usage: secret.description } : {}),
481
+ },
482
+ })),
483
+ nextActions: missingSecrets.map((secret) => ({
484
+ type: "set_user_secret",
485
+ code: "MISSING_SECRET",
486
+ node_id: "secrets.ensure",
487
+ field_path: `secrets.${secret.name}`,
488
+ message: [
489
+ `Provide ${secret.sourceEnv} in the environment before retrying.`,
490
+ secret.description ? `Usage: ${secret.description}` : "",
491
+ ].filter(Boolean).join(" "),
492
+ command: `${secret.sourceEnv}="<value>" run402 up --name <name> --yes`,
493
+ argv: ["run402", "secrets", "set", "<project_id>", secret.name, "--env", secret.sourceEnv],
494
+ })),
495
+ };
496
+ }
497
+ const spendBlock = spendApprovalBlock(input, run);
498
+ if (spendBlock)
499
+ return spendBlock;
500
+ const buildBlock = appBuildBlock(input, manifest);
501
+ if (buildBlock)
502
+ return buildBlock;
503
+ return {
504
+ nodeId: "project.ensure",
505
+ diagnostics: [{
506
+ code: "APP_INSTALL_STATE_UNAVAILABLE",
507
+ severity: "error",
508
+ node_id: "project.ensure",
509
+ message: "Server-side app installation state is required before app-aware up can mutate resources.",
510
+ }],
511
+ nextActions: [{
512
+ type: "wait_for_run402_support",
513
+ code: "APP_INSTALL_STATE_UNAVAILABLE",
514
+ node_id: "project.ensure",
515
+ message: "Use --check or --dry-run to inspect the graph until app installation endpoints are available.",
516
+ argv: ["run402", "up", "--check"],
517
+ }],
518
+ };
519
+ }
294
520
  async #ensureAllowance(run, opts = { fund: false }) {
295
521
  const status = await this.sdk.allowance.status();
296
522
  if (status.configured) {
@@ -727,6 +953,174 @@ function withActionDetails(err, run) {
727
953
  });
728
954
  return err;
729
955
  }
956
+ function isRepositoryUrl(value) {
957
+ return /^https?:\/\/.+/.test(value) || /^git@[^:]+:.+/.test(value) || /^ssh:\/\/.+/.test(value) || /^file:\/\/.+/.test(value);
958
+ }
959
+ async function gitCommit(dir) {
960
+ try {
961
+ const { stdout } = await execFileAsync("git", ["-C", dir, "rev-parse", "HEAD"], {
962
+ maxBuffer: 1024 * 1024,
963
+ });
964
+ const commit = String(stdout).trim();
965
+ return /^[0-9a-f]{40}$/i.test(commit) ? commit : undefined;
966
+ }
967
+ catch {
968
+ return undefined;
969
+ }
970
+ }
971
+ function appPublicOrigin(input, spec) {
972
+ const template = spec?.project.origin?.subdomain;
973
+ if (!template)
974
+ return null;
975
+ const name = input.name ?? spec?.project.name ?? null;
976
+ const subdomain = template.replace(/\$\{input\.name\}/g, name ?? "");
977
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(subdomain))
978
+ return null;
979
+ return `https://${subdomain}.run402.com`;
980
+ }
981
+ function spendApprovalBlock(input, run) {
982
+ const expectedPrototypeSpendUsd = 0.10;
983
+ if (input.maxSpendUsd !== undefined && input.maxSpendUsd < expectedPrototypeSpendUsd) {
984
+ return {
985
+ nodeId: "account.ensure",
986
+ diagnostics: [{
987
+ code: "SPEND_APPROVAL_REQUIRED",
988
+ severity: "error",
989
+ node_id: "account.ensure",
990
+ message: `The app install may need up to $${expectedPrototypeSpendUsd.toFixed(2)} for tier readiness, above --max-spend-usd ${input.maxSpendUsd}.`,
991
+ }],
992
+ nextActions: [{
993
+ type: "approve_spend",
994
+ code: "SPEND_APPROVAL_REQUIRED",
995
+ node_id: "account.ensure",
996
+ message: "Raise --max-spend-usd or preconfigure an active tier, then retry with approval.",
997
+ spend: {
998
+ amount_usd_micros: 100_000,
999
+ currency: "USD",
1000
+ description: "prototype tier readiness",
1001
+ },
1002
+ }],
1003
+ };
1004
+ }
1005
+ if (run.approval !== "yes") {
1006
+ return {
1007
+ nodeId: "account.ensure",
1008
+ diagnostics: [{
1009
+ code: "SPEND_APPROVAL_REQUIRED",
1010
+ severity: "error",
1011
+ node_id: "account.ensure",
1012
+ message: "App-aware up needs explicit approval before it can perform spend-impacting readiness steps.",
1013
+ }],
1014
+ nextActions: [{
1015
+ type: "approve_spend",
1016
+ code: "SPEND_APPROVAL_REQUIRED",
1017
+ node_id: "account.ensure",
1018
+ message: "Retry with --yes and an appropriate --max-spend-usd cap, or run --dry-run to inspect the plan.",
1019
+ argv: ["run402", "up", "--yes", "--max-spend-usd", String(expectedPrototypeSpendUsd)],
1020
+ spend: {
1021
+ amount_usd_micros: 100_000,
1022
+ currency: "USD",
1023
+ description: "prototype tier readiness",
1024
+ },
1025
+ }],
1026
+ };
1027
+ }
1028
+ return null;
1029
+ }
1030
+ function missingRequiredName(input, manifest) {
1031
+ const spec = manifest.appSpec;
1032
+ if (!spec || input.name || input.projectId || spec.project.id)
1033
+ return null;
1034
+ const needsName = spec.project.name?.includes("${input.name}") === true ||
1035
+ spec.project.origin?.subdomain?.includes("${input.name}") === true;
1036
+ if (!needsName)
1037
+ return null;
1038
+ return {
1039
+ nodeId: "project.ensure",
1040
+ diagnostics: [{
1041
+ code: "PROJECT_REQUIRED",
1042
+ severity: "error",
1043
+ node_id: "project.ensure",
1044
+ field_path: "project.name",
1045
+ message: "This app needs an instance name so Run402 can create the project, public subdomain, and project-scoped mail host.",
1046
+ details: {
1047
+ manifest_path: manifest.manifestPath,
1048
+ template: "${input.name}",
1049
+ },
1050
+ }],
1051
+ nextActions: [{
1052
+ type: "set_project_name",
1053
+ code: "PROJECT_REQUIRED",
1054
+ node_id: "project.ensure",
1055
+ field_path: "project.name",
1056
+ message: "Retry with --name, for example: run402 up --name kysigned3 --yes.",
1057
+ command: "run402 up --name kysigned3 --yes",
1058
+ argv: ["run402", "up", "--name", "kysigned3", "--yes"],
1059
+ }],
1060
+ };
1061
+ }
1062
+ function missingRequiredSecrets(spec) {
1063
+ const missing = [];
1064
+ for (const [name, secret] of Object.entries(spec?.secrets ?? {})) {
1065
+ if (secret.required !== true)
1066
+ continue;
1067
+ const sourceEnv = secret.source_env ?? name;
1068
+ if (process.env[sourceEnv])
1069
+ continue;
1070
+ missing.push({
1071
+ name,
1072
+ sourceEnv,
1073
+ ...(secret.description ? { description: secret.description } : {}),
1074
+ });
1075
+ }
1076
+ return missing;
1077
+ }
1078
+ function appBuildBlock(input, manifest) {
1079
+ const build = manifest.appSpec?.build;
1080
+ if (!build)
1081
+ return null;
1082
+ const mode = input.buildMode ?? build.mode ?? "local";
1083
+ const nodeId = `build.${mode}`;
1084
+ const shellCommand = build.commands?.find((command) => command.shell);
1085
+ if (shellCommand && input.allowShellBuild !== true) {
1086
+ return {
1087
+ nodeId,
1088
+ diagnostics: [{
1089
+ code: "BUILD_APPROVAL_REQUIRED",
1090
+ severity: "error",
1091
+ node_id: nodeId,
1092
+ field_path: `build.commands.${shellCommand.id}.shell`,
1093
+ message: "Shell-string build commands require explicit shell build approval.",
1094
+ }],
1095
+ nextActions: [{
1096
+ type: "approve_shell_build",
1097
+ code: "BUILD_APPROVAL_REQUIRED",
1098
+ node_id: nodeId,
1099
+ field_path: `build.commands.${shellCommand.id}.shell`,
1100
+ message: "Review the shell command, then retry with --allow-shell-build or convert it to argv.",
1101
+ }],
1102
+ };
1103
+ }
1104
+ if (mode === "remote" || mode === "sandbox") {
1105
+ return {
1106
+ nodeId,
1107
+ diagnostics: [{
1108
+ code: "REMOTE_BUILD_UNSUPPORTED",
1109
+ severity: "error",
1110
+ node_id: nodeId,
1111
+ message: `${mode} build mode is planned but not available in this SDK build yet.`,
1112
+ }],
1113
+ nextActions: [{
1114
+ type: "choose_build_mode",
1115
+ code: "REMOTE_BUILD_UNSUPPORTED",
1116
+ node_id: nodeId,
1117
+ message: "Retry with --build-mode local after preparing the local toolchain, or wait for Run402 remote build support.",
1118
+ argv: ["run402", "up", "--build-mode", "local", "--yes"],
1119
+ }],
1120
+ };
1121
+ }
1122
+ return null;
1123
+ }
730
1124
  async function findManifest(workspaceDir) {
731
1125
  for (const candidate of MANIFEST_CANDIDATES) {
732
1126
  const path = join(workspaceDir, candidate);
@@ -753,6 +1147,24 @@ async function findExecutableManifest(workspaceDir) {
753
1147
  }
754
1148
  return null;
755
1149
  }
1150
+ async function loadRun402AppManifest(path) {
1151
+ let raw;
1152
+ try {
1153
+ raw = await readFile(path, "utf-8");
1154
+ }
1155
+ catch (err) {
1156
+ throw new LocalError(`Failed to read Run402 app manifest '${path}': ${err.message}`, "loading Run402 app manifest", err);
1157
+ }
1158
+ try {
1159
+ return JSON.parse(raw);
1160
+ }
1161
+ catch (err) {
1162
+ throw new LocalError(`Run402 app manifest is not valid JSON: ${err.message}`, "parsing Run402 app manifest", {
1163
+ code: "APP_SPEC_INVALID",
1164
+ details: { path },
1165
+ });
1166
+ }
1167
+ }
756
1168
  function reviewedPlanRequirement(mode) {
757
1169
  return typeof mode === "object" && mode.kind === "applyReviewed"
758
1170
  ? { planId: mode.planId, ...(mode.planFingerprint ? { planFingerprint: mode.planFingerprint } : {}) }