run402-mcp 3.7.6 → 3.7.7

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.
@@ -3,11 +3,12 @@ import { execFile } from "node:child_process";
3
3
  import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile, } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
6
+ import { setTimeout as delay } from "node:timers/promises";
6
7
  import { promisify } from "node:util";
7
8
  import { Run402Action } from "../actions.js";
8
9
  import { RUN402_APP_MANIFEST_FILENAME, compileRun402AppInstallGraph, createRun402AppUpResult, } from "../app-up.js";
9
10
  import { LocalError } from "../errors.js";
10
- import { loadDeployManifest } from "./deploy-manifest.js";
11
+ import { loadDeployManifest, normalizeDeployManifest } from "./deploy-manifest.js";
11
12
  const DEFAULT_BOOTSTRAP_TIER = "prototype";
12
13
  const MANIFEST_CANDIDATES = [
13
14
  RUN402_APP_MANIFEST_FILENAME,
@@ -154,13 +155,7 @@ export class NodeActions {
154
155
  app_result: appResult,
155
156
  });
156
157
  }
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
- });
158
+ return this.#applyAppManifest(input, manifest, workspaceDir, run, startedAt);
164
159
  }
165
160
  if (run.executionMode === "check" || run.executionMode === "printSpec") {
166
161
  if (!manifest.releaseSpec) {
@@ -336,6 +331,7 @@ export class NodeActions {
336
331
  releaseSpec: null,
337
332
  appGraph,
338
333
  appSpec,
334
+ source,
339
335
  };
340
336
  }
341
337
  const loaded = await loadDeployManifest(manifestPath, {
@@ -448,6 +444,9 @@ export class NodeActions {
448
444
  }
449
445
  #blockedAppUpResult(input, manifest, run, startedAt) {
450
446
  const block = this.#firstAppUpBlock(input, manifest, run);
447
+ if (!block) {
448
+ throw run.error("Internal error: app manifest was marked blocked without a blocking condition.", "RUN402_ACTION_INTERNAL");
449
+ }
451
450
  return this.#planAppUpResult(input, manifest, run, {
452
451
  startedAt,
453
452
  status: "blocked",
@@ -500,22 +499,483 @@ export class NodeActions {
500
499
  const buildBlock = appBuildBlock(input, manifest);
501
500
  if (buildBlock)
502
501
  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
- }],
502
+ return null;
503
+ }
504
+ async #applyAppManifest(input, manifest, workspaceDir, run, startedAt) {
505
+ if (!manifest.appSpec || !manifest.appGraph) {
506
+ throw run.error("Internal error: app manifest did not produce an install graph.", "RUN402_ACTION_INTERNAL");
507
+ }
508
+ const block = this.#firstAppUpBlock(input, manifest, run);
509
+ if (block) {
510
+ const blocked = this.#planAppUpResult(input, manifest, run, {
511
+ startedAt,
512
+ status: "blocked",
513
+ dryRun: false,
514
+ projectId: input.projectId ?? null,
515
+ diagnostics: block.diagnostics,
516
+ nextActions: block.nextActions,
517
+ blockedNodeId: block.nodeId,
518
+ });
519
+ return run.result({
520
+ project_id: input.projectId ?? "prj_planned",
521
+ manifest_path: manifest.manifestPath,
522
+ app_graph: manifest.appGraph,
523
+ app_result: blocked,
524
+ });
525
+ }
526
+ if (this.#targetKind() === "core") {
527
+ const blocked = this.#planAppUpResult(input, manifest, run, {
528
+ startedAt,
529
+ status: "blocked",
530
+ dryRun: false,
531
+ projectId: input.projectId ?? null,
532
+ diagnostics: [{
533
+ code: "UNSUPPORTED_RESOURCE_KIND",
534
+ severity: "error",
535
+ node_id: "project.ensure",
536
+ message: "App-aware up currently targets Run402 Cloud because it provisions Cloud projects, mailboxes, secrets, and managed subdomains.",
537
+ }],
538
+ nextActions: [{
539
+ type: "choose_cloud_target",
540
+ code: "UNSUPPORTED_RESOURCE_KIND",
541
+ node_id: "project.ensure",
542
+ message: "Select the Run402 Cloud target, then retry.",
543
+ argv: ["run402", "init", "--api-base", "https://api.run402.com"],
544
+ }],
545
+ blockedNodeId: "project.ensure",
546
+ });
547
+ return run.result({
548
+ project_id: input.projectId ?? "prj_planned",
549
+ manifest_path: manifest.manifestPath,
550
+ app_graph: manifest.appGraph,
551
+ app_result: blocked,
552
+ });
553
+ }
554
+ await this.#ensureCloudTier(run, input.tier ?? DEFAULT_BOOTSTRAP_TIER);
555
+ const resolved = await this.#resolveProject(input, manifest, workspaceDir, run);
556
+ await this.#recordAppInstallState({
557
+ projectId: resolved.projectId,
558
+ manifest,
559
+ status: "applying",
560
+ run,
561
+ });
562
+ if (resolved.shouldWriteLink && run.executionMode !== "plan") {
563
+ await this.#writeWorkspaceProjectLink(resolved.linkPath, {
564
+ schema_version: "run402.workspace-project.v1",
565
+ project_id: resolved.projectId,
566
+ ...(input.name ? { name: input.name } : {}),
567
+ target: { kind: this.#targetKind() },
568
+ created_at: resolved.link?.created_at ?? new Date().toISOString(),
569
+ updated_at: new Date().toISOString(),
570
+ }, resolved.link, run);
571
+ }
572
+ try {
573
+ const projectKeys = await this.sdk.projects.keys(resolved.projectId);
574
+ const publicOrigin = appPublicOrigin(input, manifest.appSpec) ?? projectKeys.site_url ?? null;
575
+ const resources = await this.#ensureAppMailboxes(resolved.projectId, manifest.appSpec, run);
576
+ const env = {
577
+ ...this.#generatedAppEnv(resolved.projectId, projectKeys, publicOrigin, resources),
578
+ ...this.#userSecretEnv(manifest.appSpec),
579
+ };
580
+ resources.env = env;
581
+ await this.#ensureAppSecrets(resolved.projectId, manifest.appSpec, env, run);
582
+ await this.#runAppBuild(workspaceDir, manifest.appSpec, env, run);
583
+ const releaseInput = materializeTemplates(manifest.appSpec.release, {
584
+ ...env,
585
+ "input.name": input.name ?? manifest.appSpec.project.name ?? "",
586
+ });
587
+ const normalized = await normalizeDeployManifest(releaseInput, {
588
+ project: resolved.projectId,
589
+ baseDir: dirname(manifest.manifestPath),
590
+ });
591
+ if (!hasDeployableContent(normalized.spec)) {
592
+ throw run.error("App release contains no deployable sections.", "MANIFEST_EMPTY", { manifest_path: manifest.manifestPath });
593
+ }
594
+ const deployStep = run.addStep({
595
+ action: "deploy.apply",
596
+ description: "Apply app release",
597
+ mutation: true,
598
+ auto: false,
599
+ details: {
600
+ project_id: resolved.projectId,
601
+ manifest_path: manifest.manifestPath,
602
+ },
603
+ });
604
+ run.setState(deployStep, "running");
605
+ const scoped = await this.sdk.project(resolved.projectId);
606
+ const deploy = await scoped.apply(normalized.spec, {
607
+ idempotencyKey: input.idempotencyKey ?? normalized.idempotencyKey,
608
+ allowWarnings: input.allowWarnings,
609
+ allowWarningCodes: input.allowWarningCodes,
610
+ target: "cloud",
611
+ onEvent: (event) => {
612
+ run.options.onEvent?.({
613
+ type: "action.step",
614
+ action: Run402Action.Up,
615
+ step: {
616
+ ...deployStep,
617
+ details: { ...deployStep.details, deploy_event: event.type },
618
+ },
619
+ });
620
+ },
621
+ });
622
+ run.setState(deployStep, "succeeded", {
623
+ release_id: deploy.release_id,
624
+ operation_id: deploy.operation_id,
625
+ });
626
+ const webhooks = await this.#ensureAppWebhooks(resolved.projectId, manifest.appSpec, resources, run);
627
+ resources.webhooks = webhooks;
628
+ const verification = await this.#verifyAppHttp(manifest.appSpec, publicOrigin, run);
629
+ markAppGraphNodes(manifest.appGraph, verification.ok ? "succeeded" : "failed");
630
+ const appResult = createRun402AppUpResult({
631
+ graph: manifest.appGraph,
632
+ manifest_path: manifest.manifestPath,
633
+ status: verification.ok ? "succeeded" : "deployed_unverified",
634
+ started_at: startedAt,
635
+ dry_run: false,
636
+ project_id: resolved.projectId,
637
+ project_name: input.name ?? manifest.appSpec.project.name ?? null,
638
+ public_origin: publicOrigin,
639
+ operation_id: deploy.operation_id ?? null,
640
+ diagnostics: verification.diagnostics,
641
+ approval_policy: {
642
+ yes: run.approval === "yes",
643
+ allow_prune: input.allowPrune === true,
644
+ max_spend_usd: input.maxSpendUsd ?? null,
645
+ build_mode: input.buildMode ?? manifest.appSpec.build?.mode ?? null,
646
+ shell_build_approved: input.allowShellBuild === true,
647
+ },
648
+ });
649
+ applyResourceStateToAppResult(appResult, resources);
650
+ appResult.release.operation_id = deploy.operation_id ?? null;
651
+ appResult.release.release_id = deploy.release_id ?? null;
652
+ appResult.release.spec = normalized.manifest;
653
+ for (const check of appResult.verification.http) {
654
+ const actual = verification.results.get(check.id);
655
+ if (actual !== undefined) {
656
+ check.actual_status = actual;
657
+ check.status = actual === check.expected_status ? "succeeded" : "failed";
658
+ }
659
+ }
660
+ await this.#recordAppInstallState({
661
+ projectId: resolved.projectId,
662
+ manifest,
663
+ status: verification.ok ? "active" : "failed",
664
+ run,
665
+ resources,
666
+ lastOperationId: deploy.operation_id ?? null,
667
+ error: verification.ok ? null : { code: "VERIFY_FAILED", diagnostics: verification.diagnostics },
668
+ });
669
+ return run.result({
670
+ project_id: resolved.projectId,
671
+ manifest_path: manifest.manifestPath,
672
+ ...(resolved.shouldWriteLink ? { workspace_link_path: resolved.linkPath } : {}),
673
+ app_graph: manifest.appGraph,
674
+ deploy,
675
+ app_result: appResult,
676
+ });
677
+ }
678
+ catch (err) {
679
+ await this.#recordAppInstallState({
680
+ projectId: resolved.projectId,
681
+ manifest,
682
+ status: "failed",
683
+ run,
684
+ error: actionErrorDetails(err),
685
+ });
686
+ throw err;
687
+ }
688
+ }
689
+ async #recordAppInstallState(input) {
690
+ if (!input.manifest.appSpec || !input.manifest.appGraph)
691
+ return;
692
+ const step = input.run.addStep({
693
+ action: "app.install",
694
+ description: input.status === "applying"
695
+ ? "Record app install state"
696
+ : "Record app install result",
697
+ mutation: true,
698
+ auto: true,
699
+ details: {
700
+ project_id: input.projectId,
701
+ app_key: input.manifest.appSpec.app.id,
702
+ status: input.status,
703
+ },
704
+ });
705
+ input.run.setState(step, "running");
706
+ try {
707
+ await this.sdk.apps.upsertInstallState({
708
+ project_id: input.projectId,
709
+ app_key: input.manifest.appSpec.app.id,
710
+ status: input.status,
711
+ manifest_digest: `sha256:${sha256Hex(stableJson(input.manifest.appSpec))}`,
712
+ graph_digest: input.manifest.appGraph.graph_digest,
713
+ source: input.manifest.source,
714
+ manifest: input.manifest.appSpec,
715
+ resources: input.resources
716
+ ? {
717
+ mailboxes: input.resources.mailboxes,
718
+ webhooks: input.resources.webhooks,
719
+ }
720
+ : {},
721
+ bindings: input.resources
722
+ ? { env: Object.keys(input.resources.env).sort() }
723
+ : {},
724
+ last_operation_id: input.lastOperationId ?? null,
725
+ error: input.error ?? null,
726
+ });
727
+ input.run.setState(step, "succeeded");
728
+ }
729
+ catch (err) {
730
+ input.run.setState(step, "skipped", {
731
+ reason: "app_install_state_unavailable",
732
+ error: err instanceof Error ? err.message : String(err),
733
+ });
734
+ }
735
+ }
736
+ async #ensureAppMailboxes(projectId, spec, run) {
737
+ const state = { mailboxes: {}, webhooks: {}, env: {} };
738
+ const entries = Object.entries(spec.resources?.mailboxes ?? {}).sort(([a], [b]) => a.localeCompare(b));
739
+ if (entries.length === 0)
740
+ return state;
741
+ const step = run.addStep({
742
+ action: "app.mailbox.ensure",
743
+ description: "Ensure app mailboxes",
744
+ mutation: true,
745
+ auto: true,
746
+ details: { project_id: projectId, count: entries.length },
747
+ });
748
+ await run.approve(step, ["app.mailbox.ensure"], "Create or reuse app mailboxes.");
749
+ run.setState(step, "running");
750
+ let envelope = await this.sdk.email.listMailboxes(projectId);
751
+ for (const [logicalName, mailboxSpec] of entries) {
752
+ const slug = appMailboxSlug(logicalName, mailboxSpec);
753
+ let mailbox = envelope.mailboxes.find((candidate) => candidate.slug === slug);
754
+ if (!mailbox) {
755
+ mailbox = await this.sdk.email.createMailbox(projectId, slug);
756
+ envelope = await this.sdk.email.listMailboxes(projectId);
757
+ }
758
+ state.mailboxes[logicalName] = {
759
+ id: mailbox.mailbox_id,
760
+ slug: mailbox.slug,
761
+ address: mailbox.address,
762
+ ...(mailbox.managed_address ? { managed_address: mailbox.managed_address } : {}),
763
+ };
764
+ }
765
+ const defaults = {};
766
+ for (const [logicalName, mailboxSpec] of entries) {
767
+ const mailbox = state.mailboxes[logicalName];
768
+ if (!mailbox)
769
+ continue;
770
+ if (mailboxSpec.roles?.includes("default_outbound"))
771
+ defaults.default_outbound_mailbox_id = mailbox.id;
772
+ if (mailboxSpec.roles?.includes("auth_sender"))
773
+ defaults.auth_sender_mailbox_id = mailbox.id;
774
+ }
775
+ if ((defaults.default_outbound_mailbox_id && envelope.mailbox_settings?.default_outbound_mailbox_id !== defaults.default_outbound_mailbox_id) ||
776
+ (defaults.auth_sender_mailbox_id && envelope.mailbox_settings?.auth_sender_mailbox_id !== defaults.auth_sender_mailbox_id)) {
777
+ await this.sdk.email.setMailboxDefaults(projectId, defaults);
778
+ }
779
+ run.setState(step, "succeeded", {
780
+ mailboxes: Object.fromEntries(Object.entries(state.mailboxes).map(([name, mailbox]) => [
781
+ name,
782
+ { mailbox_id: mailbox.id, slug: mailbox.slug, address: mailbox.address },
783
+ ])),
784
+ });
785
+ return state;
786
+ }
787
+ #generatedAppEnv(projectId, projectKeys, publicOrigin, resources) {
788
+ const env = {
789
+ RUN402_PROJECT_ID: projectId,
790
+ RUN402_ANON_KEY: projectKeys.anon_key,
791
+ RUN402_SERVICE_KEY: projectKeys.service_key,
792
+ RUN402_API_BASE: this.sdk.apiBase,
793
+ RUN402_API_BASE_URL: this.sdk.apiBase,
518
794
  };
795
+ if (publicOrigin)
796
+ env.RUN402_PUBLIC_ORIGIN = publicOrigin;
797
+ for (const [logicalName, mailbox] of Object.entries(resources.mailboxes)) {
798
+ const suffix = logicalName.toUpperCase();
799
+ env[`RUN402_MAILBOX_${suffix}_ID`] = mailbox.id;
800
+ env[`RUN402_MAILBOX_${suffix}_ADDRESS`] = mailbox.address;
801
+ if (mailbox.managed_address)
802
+ env[`RUN402_MAILBOX_${suffix}_MANAGED_ADDRESS`] = mailbox.managed_address;
803
+ }
804
+ return env;
805
+ }
806
+ #userSecretEnv(spec) {
807
+ const env = {};
808
+ for (const [name, secret] of Object.entries(spec.secrets ?? {})) {
809
+ const sourceEnv = secret.source_env ?? name;
810
+ const value = process.env[sourceEnv];
811
+ if (value !== undefined)
812
+ env[name] = value;
813
+ }
814
+ return env;
815
+ }
816
+ async #ensureAppSecrets(projectId, spec, env, run) {
817
+ const required = new Set();
818
+ for (const [name, secret] of Object.entries(spec.secrets ?? {})) {
819
+ if (secret.required === true || process.env[secret.source_env ?? name] !== undefined)
820
+ required.add(name);
821
+ }
822
+ for (const name of releaseRequiredSecretNames(spec.release))
823
+ required.add(name);
824
+ const names = [...required].sort();
825
+ if (names.length === 0)
826
+ return;
827
+ const step = run.addStep({
828
+ action: "app.secret.ensure",
829
+ description: "Ensure app runtime secrets",
830
+ mutation: true,
831
+ auto: true,
832
+ details: { project_id: projectId, keys: names },
833
+ });
834
+ await run.approve(step, ["app.secret.ensure"], "Set generated and user-provided app runtime secrets.");
835
+ run.setState(step, "running");
836
+ for (const name of names) {
837
+ const value = env[name];
838
+ if (value === undefined) {
839
+ throw run.error(`App secret ${name} is required by the release but no generated or user-provided value is available.`, "MISSING_SECRET", { key: name });
840
+ }
841
+ await this.sdk.secrets.set(projectId, name, { value });
842
+ }
843
+ run.setState(step, "succeeded", { keys: names });
844
+ }
845
+ async #runAppBuild(workspaceDir, spec, env, run) {
846
+ const commands = spec.build?.commands ?? [];
847
+ if (commands.length === 0)
848
+ return;
849
+ const mode = spec.build?.mode ?? "local";
850
+ if (mode !== "local") {
851
+ throw run.error(`${mode} build mode is not executable by this SDK build.`, "REMOTE_BUILD_UNSUPPORTED", { mode });
852
+ }
853
+ const step = run.addStep({
854
+ action: "app.build",
855
+ description: "Run app build commands",
856
+ mutation: true,
857
+ auto: false,
858
+ details: { mode, commands: commands.map((command) => command.id) },
859
+ });
860
+ run.setState(step, "running");
861
+ for (const command of commands) {
862
+ const cwd = resolveMaybe(workspaceDir, command.cwd ?? ".");
863
+ if (command.argv && command.argv.length > 0) {
864
+ const [file, ...args] = command.argv;
865
+ await execFileAsync(file, args, {
866
+ cwd,
867
+ env: { ...process.env, ...env },
868
+ maxBuffer: 1024 * 1024 * 16,
869
+ });
870
+ }
871
+ else if (command.shell) {
872
+ await execFileAsync("/bin/sh", ["-c", command.shell], {
873
+ cwd,
874
+ env: { ...process.env, ...env },
875
+ maxBuffer: 1024 * 1024 * 16,
876
+ });
877
+ }
878
+ else {
879
+ throw run.error(`Build command ${command.id} must define argv or shell.`, "APP_SPEC_INVALID", { command_id: command.id });
880
+ }
881
+ }
882
+ run.setState(step, "succeeded");
883
+ }
884
+ async #ensureAppWebhooks(projectId, spec, resources, run) {
885
+ const entries = Object.entries(spec.resources?.webhooks ?? {}).sort(([a], [b]) => a.localeCompare(b));
886
+ const webhooks = {};
887
+ if (entries.length === 0)
888
+ return webhooks;
889
+ const step = run.addStep({
890
+ action: "app.webhook.ensure",
891
+ description: "Ensure app mailbox webhooks",
892
+ mutation: true,
893
+ auto: true,
894
+ details: { project_id: projectId, count: entries.length },
895
+ });
896
+ await run.approve(step, ["app.webhook.ensure"], "Create or reuse app mailbox webhooks.");
897
+ run.setState(step, "running");
898
+ for (const [logicalName, webhook] of entries) {
899
+ const mailbox = resources.mailboxes[webhook.mailbox];
900
+ if (!mailbox) {
901
+ throw run.error(`Webhook ${logicalName} references unknown mailbox ${webhook.mailbox}.`, "APP_SPEC_INVALID", { webhook: logicalName, mailbox: webhook.mailbox });
902
+ }
903
+ const url = materializeTemplates(webhook.url, resources.env);
904
+ const events = [...webhook.events].sort();
905
+ const current = await this.sdk.email.webhooks.list(projectId, { mailbox: mailbox.id });
906
+ const existing = current.webhooks.find((candidate) => candidate.url === url && sameStringSet(candidate.events ?? [], events));
907
+ const summary = existing ?? await this.sdk.email.webhooks.register(projectId, {
908
+ mailbox: mailbox.id,
909
+ url,
910
+ events,
911
+ });
912
+ webhooks[logicalName] = {
913
+ id: summary.webhook_id,
914
+ mailbox: webhook.mailbox,
915
+ url,
916
+ events,
917
+ created: existing === undefined,
918
+ };
919
+ }
920
+ run.setState(step, "succeeded", { webhooks });
921
+ return webhooks;
922
+ }
923
+ async #verifyAppHttp(spec, publicOrigin, run) {
924
+ const checks = spec.verify?.http ?? [];
925
+ const results = new Map();
926
+ const diagnostics = [];
927
+ if (checks.length === 0)
928
+ return { ok: true, results, diagnostics };
929
+ const step = run.addStep({
930
+ action: "app.verify",
931
+ description: "Verify app HTTP checks",
932
+ mutation: false,
933
+ auto: true,
934
+ details: { count: checks.length },
935
+ });
936
+ run.setState(step, "running");
937
+ for (const check of checks) {
938
+ const url = check.url ?? (publicOrigin && check.path ? new URL(check.path, publicOrigin).toString() : null);
939
+ if (!url) {
940
+ results.set(check.id, null);
941
+ diagnostics.push({
942
+ code: "VERIFY_FAILED",
943
+ severity: "error",
944
+ node_id: `verify.http.${check.id}`,
945
+ message: `HTTP verification ${check.id} needs either url or project public origin + path.`,
946
+ });
947
+ continue;
948
+ }
949
+ const retries = Math.max(1, check.retries ?? 1);
950
+ let status = null;
951
+ for (let attempt = 0; attempt < retries; attempt++) {
952
+ try {
953
+ const res = await fetch(url, { method: "GET" });
954
+ status = res.status;
955
+ if (status === check.expect.status)
956
+ break;
957
+ }
958
+ catch {
959
+ status = null;
960
+ }
961
+ if (attempt + 1 < retries)
962
+ await delay(1_000);
963
+ }
964
+ results.set(check.id, status);
965
+ if (status !== check.expect.status) {
966
+ diagnostics.push({
967
+ code: "VERIFY_FAILED",
968
+ severity: "error",
969
+ node_id: `verify.http.${check.id}`,
970
+ message: `HTTP verification ${check.id} expected ${check.expect.status}, got ${status ?? "network_error"}.`,
971
+ details: { url, expected_status: check.expect.status, actual_status: status },
972
+ });
973
+ }
974
+ }
975
+ run.setState(step, diagnostics.length === 0 ? "succeeded" : "failed", {
976
+ checks: Object.fromEntries(results),
977
+ });
978
+ return { ok: diagnostics.length === 0, results, diagnostics };
519
979
  }
520
980
  async #ensureAllowance(run, opts = { fund: false }) {
521
981
  const status = await this.sdk.allowance.status();
@@ -671,8 +1131,9 @@ export class NodeActions {
671
1131
  },
672
1132
  });
673
1133
  run.setState(step, "running");
674
- if (input.name && link?.name && input.name !== link.name) {
675
- throw run.error("--name is only used when creating/linking a project and conflicts with the workspace link name.", "RUN402_NAME_PROJECT_CONFLICT", { name: input.name, linked_name: link.name, link_path: linkPath });
1134
+ const linkConflict = workspaceLinkConflict(link, input, this.#targetKind(), linkPath);
1135
+ if (linkConflict && !input.projectId) {
1136
+ throw run.error(linkConflict.message, "RUN402_WORKSPACE_LINK_CONFLICT", linkConflict.details);
676
1137
  }
677
1138
  let projectId = null;
678
1139
  let source = null;
@@ -978,6 +1439,71 @@ function appPublicOrigin(input, spec) {
978
1439
  return null;
979
1440
  return `https://${subdomain}.run402.com`;
980
1441
  }
1442
+ function materializeTemplates(value, values) {
1443
+ if (typeof value === "string") {
1444
+ return value.replace(/\$\{([^}]+)\}/g, (_match, key) => values[key] ?? "");
1445
+ }
1446
+ if (Array.isArray(value))
1447
+ return value.map((item) => materializeTemplates(item, values));
1448
+ if (value && typeof value === "object") {
1449
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [
1450
+ key,
1451
+ materializeTemplates(nested, values),
1452
+ ]));
1453
+ }
1454
+ return value;
1455
+ }
1456
+ function appMailboxSlug(logicalName, spec) {
1457
+ return spec.slug ?? logicalName.replace(/_/g, "-");
1458
+ }
1459
+ function releaseRequiredSecretNames(release) {
1460
+ const secrets = release.secrets;
1461
+ if (!secrets || typeof secrets !== "object" || Array.isArray(secrets))
1462
+ return [];
1463
+ const required = secrets.require;
1464
+ return Array.isArray(required)
1465
+ ? required.filter((value) => typeof value === "string")
1466
+ : [];
1467
+ }
1468
+ function sameStringSet(a, b) {
1469
+ if (a.length !== b.length)
1470
+ return false;
1471
+ const left = [...a].sort();
1472
+ const right = [...b].sort();
1473
+ return left.every((value, index) => value === right[index]);
1474
+ }
1475
+ function markAppGraphNodes(graph, status) {
1476
+ for (const node of graph.nodes) {
1477
+ node.status = status;
1478
+ }
1479
+ }
1480
+ function applyResourceStateToAppResult(appResult, resources) {
1481
+ for (const [name, mailbox] of Object.entries(resources.mailboxes)) {
1482
+ if (!appResult.resources.mailboxes[name])
1483
+ continue;
1484
+ appResult.resources.mailboxes[name].id = mailbox.id;
1485
+ appResult.resources.mailboxes[name].address = mailbox.address;
1486
+ }
1487
+ for (const [name, webhook] of Object.entries(resources.webhooks)) {
1488
+ appResult.resources.webhooks[name] = {
1489
+ id: webhook.id,
1490
+ mailbox: webhook.mailbox,
1491
+ url: webhook.url,
1492
+ events: webhook.events,
1493
+ enabled: true,
1494
+ };
1495
+ }
1496
+ appResult.resources.bindings = appResult.resources.bindings.map((binding) => {
1497
+ const value = resources.env[binding.env];
1498
+ return value === undefined
1499
+ ? binding
1500
+ : { ...binding, value, redacted: /KEY|SECRET|TOKEN/.test(binding.env) };
1501
+ });
1502
+ appResult.resources.user_secrets = Object.fromEntries(Object.entries(appResult.resources.user_secrets).map(([name, secret]) => [
1503
+ name,
1504
+ { ...secret, satisfied: resources.env[name] !== undefined },
1505
+ ]));
1506
+ }
981
1507
  function spendApprovalBlock(input, run) {
982
1508
  const expectedPrototypeSpendUsd = 0.10;
983
1509
  if (input.maxSpendUsd !== undefined && input.maxSpendUsd < expectedPrototypeSpendUsd) {
@@ -1276,6 +1802,49 @@ function workspaceProjectLinksEqual(a, b) {
1276
1802
  (a.target?.kind ?? null) === (b.target?.kind ?? null) &&
1277
1803
  (a.target?.api_base ?? null) === (b.target?.api_base ?? null));
1278
1804
  }
1805
+ function workspaceLinkConflict(link, input, targetKind, linkPath) {
1806
+ if (!link)
1807
+ return null;
1808
+ const linkedTarget = link.target?.kind;
1809
+ if (linkedTarget && targetKind !== "unknown" && linkedTarget !== targetKind) {
1810
+ return {
1811
+ message: "Workspace project link was created for a different Run402 target.",
1812
+ details: {
1813
+ reason: "target_mismatch",
1814
+ linked_project_id: link.project_id,
1815
+ linked_target: linkedTarget,
1816
+ target: targetKind,
1817
+ link_path: linkPath,
1818
+ },
1819
+ };
1820
+ }
1821
+ if (!input.name)
1822
+ return null;
1823
+ if (!link.name) {
1824
+ return {
1825
+ message: "--name was supplied, but this workspace is already linked to a project without a recorded name.",
1826
+ details: {
1827
+ reason: "name_missing",
1828
+ linked_project_id: link.project_id,
1829
+ name: input.name,
1830
+ link_path: linkPath,
1831
+ },
1832
+ };
1833
+ }
1834
+ if (input.name !== link.name) {
1835
+ return {
1836
+ message: "--name conflicts with the existing workspace project link.",
1837
+ details: {
1838
+ reason: "name_mismatch",
1839
+ linked_project_id: link.project_id,
1840
+ linked_name: link.name,
1841
+ name: input.name,
1842
+ link_path: linkPath,
1843
+ },
1844
+ };
1845
+ }
1846
+ return null;
1847
+ }
1279
1848
  function deriveStableRootKey(input) {
1280
1849
  if (input.type === Run402Action.ProjectsProvision && input.name) {
1281
1850
  return `projects.provision:${input.name}`;
@@ -1312,6 +1881,29 @@ function hasContent(value) {
1312
1881
  return value.length > 0;
1313
1882
  return true;
1314
1883
  }
1884
+ function stableJson(value) {
1885
+ if (value === undefined)
1886
+ return "null";
1887
+ if (value === null || typeof value !== "object")
1888
+ return JSON.stringify(value) ?? "null";
1889
+ if (Array.isArray(value))
1890
+ return `[${value.map(stableJson).join(",")}]`;
1891
+ const entries = Object.entries(value)
1892
+ .filter(([, v]) => v !== undefined)
1893
+ .sort(([a], [b]) => a.localeCompare(b));
1894
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableJson(v)}`).join(",")}}`;
1895
+ }
1896
+ function sha256Hex(value) {
1897
+ return createHash("sha256").update(value, "utf8").digest("hex");
1898
+ }
1899
+ function actionErrorDetails(err) {
1900
+ const maybe = err;
1901
+ return {
1902
+ message: err instanceof Error ? err.message : String(err),
1903
+ ...(typeof maybe.code === "string" ? { code: maybe.code } : {}),
1904
+ ...(maybe.details && typeof maybe.details === "object" ? { details: maybe.details } : {}),
1905
+ };
1906
+ }
1315
1907
  function assertNever(value) {
1316
1908
  throw new LocalError(`Unknown Run402 action ${value.type}`, "running Run402 action", { code: "RUN402_ACTION_UNKNOWN" });
1317
1909
  }