@amerged/ohmyhost-mcp 0.1.28 → 0.1.29

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 (32) hide show
  1. package/README.md +1 -1
  2. package/THIRD_PARTY_NOTICES.md +1419 -0
  3. package/dist/{chunk-GIP4M4CK.js → chunk-BWVBJWRR.js} +67 -21
  4. package/dist/index.js +1 -1
  5. package/dist/stdio-main.js +24152 -2353
  6. package/package.json +1 -1
  7. package/src/apps/mcp/package.json +1 -1
  8. package/src/apps/mcp/src/local-client.ts +12 -2
  9. package/src/apps/mcp/src/local-server.ts +333 -4
  10. package/src/apps/product-cli/package.json +4 -2
  11. package/src/apps/product-cli/src/cli.ts +312 -2
  12. package/src/apps/product-cli/src/command.ts +261 -0
  13. package/src/apps/product-cli/src/composition.ts +74 -4
  14. package/src/apps/product-cli/src/index.ts +6 -0
  15. package/src/apps/product-cli/src/product-api.ts +5 -0
  16. package/src/apps/product-cli/src/project-link-store.ts +92 -1
  17. package/src/apps/product-cli/src/runtime-contract.ts +34 -1
  18. package/src/apps/product-cli/src/source-managed-client.ts +189 -0
  19. package/src/apps/product-cli/src/source-publication-store.ts +563 -0
  20. package/src/apps/product-cli/src/source-publisher.ts +195 -0
  21. package/src/apps/product-cli/src/source-workspace.ts +552 -0
  22. package/src/packages/agent-skills/src/generated-skill-resources.ts +18 -8
  23. package/src/packages/contracts/package.json +10 -0
  24. package/src/packages/contracts/src/managed-sources.ts +582 -0
  25. package/src/packages/contracts/src/mcp-source-codec.ts +150 -0
  26. package/src/packages/contracts/src/operation-failure.ts +22 -6
  27. package/src/packages/contracts/src/problem-policy.ts +61 -14
  28. package/src/packages/sdk-ts/package.json +1 -1
  29. package/src/packages/sdk-ts/src/generated/index.ts +92 -0
  30. package/src/packages/sdk-ts/src/generated/sdk.gen.ts +541 -0
  31. package/src/packages/sdk-ts/src/generated/types.gen.ts +911 -4
  32. package/src/packages/workos-auth-contracts/src/tokens.ts +61 -4
@@ -7,7 +7,63 @@ import type { ManagedMailCommand } from "@ohmyhost/sdk-ts";
7
7
  import { USER_API_KEY_ID } from "@ohmyhost/contracts/user-api-keys";
8
8
  import { WORKOS_USER_API_KEY_PATTERN } from "@ohmyhost/workos-auth-contracts/user-api-keys";
9
9
  import { isProfileName, isProfileUserId } from "./profile-store.js";
10
+ import {
11
+ isManagedSourcePath,
12
+ parseManagedSourceRequest,
13
+ type ManagedSourceUploadRequest,
14
+ type ManagedSourceRestoreRequest,
15
+ type ManagedSourceInitializeRequest,
16
+ } from "@ohmyhost/contracts/managed-sources";
17
+ export type SourceCliCommand =
18
+ | { readonly kind: "source-inspect"; readonly directory: string }
19
+ | {
20
+ readonly kind: "source-read";
21
+ readonly action: "status" | "files" | "file" | "versions" | "diff" | "upload";
22
+ readonly projectId: string;
23
+ readonly commitSha?: string;
24
+ readonly path?: string;
25
+ readonly limit?: number;
26
+ readonly beforeCommitSha?: string;
27
+ readonly fromCommitSha?: string;
28
+ readonly toCommitSha?: string;
29
+ readonly operationId?: string;
30
+ readonly credentialStore: "native";
31
+ }
32
+ | {
33
+ readonly kind: "source-publish";
34
+ readonly projectId: string;
35
+ readonly directory: string;
36
+ readonly request: ManagedSourceUploadRequest;
37
+ readonly explicitExpectedCommit: boolean;
38
+ readonly idempotencyKey: string;
39
+ readonly wait: boolean;
40
+ readonly credentialStore: "native";
41
+ }
42
+ | {
43
+ readonly kind: "source-complete";
44
+ readonly projectId: string;
45
+ readonly directory: string;
46
+ readonly operationId?: string;
47
+ readonly credentialStore: "native";
48
+ }
49
+ | {
50
+ readonly kind: "source-initialize";
51
+ readonly projectId: string;
52
+ readonly request: ManagedSourceInitializeRequest;
53
+ readonly idempotencyKey: string;
54
+ readonly wait: boolean;
55
+ readonly credentialStore: "native";
56
+ }
57
+ | {
58
+ readonly kind: "source-restore";
59
+ readonly projectId: string;
60
+ readonly request: ManagedSourceRestoreRequest;
61
+ readonly idempotencyKey: string;
62
+ readonly wait: boolean;
63
+ readonly credentialStore: "native";
64
+ };
10
65
  export type ProductCliCommand =
66
+ | SourceCliCommand
11
67
  | {
12
68
  readonly kind: "project-data-plan";
13
69
  readonly projectId: string;
@@ -571,7 +627,190 @@ export class InvalidCommandError extends Error {
571
627
  }
572
628
  }
573
629
 
630
+ function parseSourceCommand(values: string[], wait: boolean): SourceCliCommand {
631
+ const action = values.shift();
632
+ if (action === "publish" && values[0] === "complete") {
633
+ values.shift();
634
+ if (wait) throw new InvalidCommandError();
635
+ const options = parseOptionalOptions(values, ["--project", "--directory", "--operation"]);
636
+ const projectId = options["--project"],
637
+ operationId = options["--operation"];
638
+ if (
639
+ projectId === undefined ||
640
+ !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(projectId) ||
641
+ (operationId !== undefined && !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(operationId))
642
+ )
643
+ throw new InvalidCommandError();
644
+ return {
645
+ kind: "source-complete",
646
+ projectId,
647
+ directory: options["--directory"] ?? ".",
648
+ operationId,
649
+ credentialStore: "native",
650
+ };
651
+ }
652
+ if (action === "inspect") {
653
+ if (wait) throw new InvalidCommandError();
654
+ const options = parseOptionalOptions(values, ["--directory"]);
655
+ return { kind: "source-inspect", directory: options["--directory"] ?? "." };
656
+ }
657
+ const allowed =
658
+ action === "publish"
659
+ ? [
660
+ "--project",
661
+ "--directory",
662
+ "--mode",
663
+ "--expected-source-generation",
664
+ "--expected-source-connection",
665
+ "--expected-commit",
666
+ "--message",
667
+ "--idempotency-key",
668
+ ]
669
+ : action === "restore"
670
+ ? [
671
+ "--project",
672
+ "--expected-source-generation",
673
+ "--expected-commit",
674
+ "--restore-commit",
675
+ "--message",
676
+ "--idempotency-key",
677
+ ]
678
+ : action === "initialize"
679
+ ? ["--project", "--template", "--expected-source-generation", "--idempotency-key"]
680
+ : [
681
+ "--project",
682
+ "--commit",
683
+ "--path",
684
+ "--limit",
685
+ "--before",
686
+ "--from",
687
+ "--to",
688
+ "--operation",
689
+ ];
690
+ const options = parseOptionalOptions(values, allowed);
691
+ const projectId = options["--project"];
692
+ if (projectId === undefined || !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(projectId))
693
+ throw new InvalidCommandError();
694
+ const credentialStore = "native" as const;
695
+ if (action === "publish" || action === "restore" || action === "initialize") {
696
+ const idempotencyKey = options["--idempotency-key"];
697
+ const rawGeneration = options["--expected-source-generation"];
698
+ if (
699
+ idempotencyKey === undefined ||
700
+ rawGeneration === undefined ||
701
+ !/^(?:0|[1-9][0-9]*)$/u.test(rawGeneration)
702
+ )
703
+ throw new InvalidCommandError();
704
+ const expected_source_generation = Number(rawGeneration);
705
+ try {
706
+ if (action === "initialize")
707
+ return {
708
+ kind: "source-initialize",
709
+ projectId,
710
+ request: parseManagedSourceRequest("initialize", {
711
+ expected_source_generation,
712
+ template: options["--template"] ?? "vite-react",
713
+ }),
714
+ idempotencyKey,
715
+ wait,
716
+ credentialStore,
717
+ };
718
+ const common = {
719
+ expected_source_generation,
720
+ expected_commit_sha: options["--expected-commit"] ?? null,
721
+ message: options["--message"],
722
+ };
723
+ if (action === "restore")
724
+ return {
725
+ kind: "source-restore",
726
+ projectId,
727
+ request: parseManagedSourceRequest("restore", {
728
+ ...common,
729
+ restore_commit_sha: options["--restore-commit"],
730
+ }),
731
+ idempotencyKey,
732
+ wait,
733
+ credentialStore,
734
+ };
735
+ return {
736
+ kind: "source-publish",
737
+ projectId,
738
+ directory: options["--directory"] ?? ".",
739
+ request: parseManagedSourceRequest("upload", {
740
+ ...common,
741
+ mode: options["--mode"],
742
+ expected_source_connection_id: options["--expected-source-connection"] ?? null,
743
+ }),
744
+ explicitExpectedCommit: options["--expected-commit"] !== undefined,
745
+ idempotencyKey,
746
+ wait,
747
+ credentialStore,
748
+ };
749
+ } catch {
750
+ throw new InvalidCommandError();
751
+ }
752
+ }
753
+ if (wait || !["status", "files", "file", "versions", "diff", "upload"].includes(action ?? ""))
754
+ throw new InvalidCommandError();
755
+ const commitSha = options["--commit"],
756
+ path = options["--path"],
757
+ beforeCommitSha = options["--before"],
758
+ fromCommitSha = options["--from"],
759
+ toCommitSha = options["--to"],
760
+ operationId = options["--operation"];
761
+ const permitted =
762
+ action === "status"
763
+ ? ["--project"]
764
+ : action === "versions"
765
+ ? ["--project", "--limit", "--before"]
766
+ : action === "diff"
767
+ ? ["--project", "--from", "--to"]
768
+ : action === "upload"
769
+ ? ["--project", "--operation"]
770
+ : action === "file"
771
+ ? ["--project", "--commit", "--path"]
772
+ : ["--project", "--commit"];
773
+ if (
774
+ Object.keys(options).some((key) => !permitted.includes(key)) ||
775
+ [commitSha, beforeCommitSha, fromCommitSha, toCommitSha].some(
776
+ (value) => value !== undefined && !/^[a-f0-9]{40}$/u.test(value),
777
+ ) ||
778
+ (action === "file" && !isManagedSourcePath(path)) ||
779
+ (action === "diff" && (fromCommitSha === undefined || toCommitSha === undefined)) ||
780
+ (action === "upload" &&
781
+ (operationId === undefined || !/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u.test(operationId)))
782
+ )
783
+ throw new InvalidCommandError();
784
+ const limit = options["--limit"] === undefined ? undefined : Number(options["--limit"]);
785
+ if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 1 || limit > 100))
786
+ throw new InvalidCommandError();
787
+ return {
788
+ kind: "source-read",
789
+ action: action as Extract<SourceCliCommand, { kind: "source-read" }>["action"],
790
+ projectId,
791
+ commitSha,
792
+ path,
793
+ beforeCommitSha,
794
+ fromCommitSha,
795
+ toCommitSha,
796
+ operationId,
797
+ limit,
798
+ credentialStore,
799
+ };
800
+ }
801
+
574
802
  export type RecognizedCommandLabel =
803
+ | "source inspect"
804
+ | "source status"
805
+ | "source files"
806
+ | "source file"
807
+ | "source versions"
808
+ | "source diff"
809
+ | "source upload"
810
+ | "source publish"
811
+ | "source publish complete"
812
+ | "source initialize"
813
+ | "source restore"
575
814
  | "project data plan"
576
815
  | "project data change"
577
816
  | "database compute set"
@@ -768,6 +1007,24 @@ export const recognizedCommandLabel = (argv: readonly string[]): RecognizedComma
768
1007
  if (value === "database" && values[0] === "psql") return "database psql";
769
1008
  if (value === "link" || value === "plan" || value === "deploy" || value === "logs")
770
1009
  return value;
1010
+ if (value === "source" && values[0] === "publish" && values[1] === "complete")
1011
+ return "source publish complete";
1012
+ if (
1013
+ value === "source" &&
1014
+ [
1015
+ "inspect",
1016
+ "status",
1017
+ "files",
1018
+ "file",
1019
+ "versions",
1020
+ "diff",
1021
+ "upload",
1022
+ "publish",
1023
+ "initialize",
1024
+ "restore",
1025
+ ].includes(values[0] ?? "")
1026
+ )
1027
+ return `source ${values[0]}` as RecognizedCommandLabel;
771
1028
  if (value === "source" && values[0] === "auto-deploy") {
772
1029
  if (values[1] === "set") return "source auto-deploy set";
773
1030
  if (values[1] === "status") return "source auto-deploy status";
@@ -842,6 +1099,10 @@ export const parseProductCliCommand = (argv: readonly string[]): ProductCliComma
842
1099
  const follow = removeFlag(values, "--follow");
843
1100
  const stdin = removeFlag(values, "--stdin");
844
1101
  const first = values.shift();
1102
+ if (first === "source" && values[0] !== "auto-deploy") {
1103
+ if (dryRun || revoke || yes || follow || stdin) throw new InvalidCommandError();
1104
+ return parseSourceCommand(values, wait);
1105
+ }
845
1106
  if (
846
1107
  first === "whoami" &&
847
1108
  !dryRun &&
@@ -1,9 +1,15 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
+ import { inspectSourceWorkspace, SourceWorkspaceError } from "./source-workspace.js";
4
+ import {
5
+ inspectLocalSourcePublications,
6
+ LocalSourcePublicationError,
7
+ } from "./source-publication-store.js";
3
8
  import {
4
9
  createClient,
5
10
  getGithubOrganizationConnection,
6
11
  getProject,
12
+ getProjectSource,
7
13
  revokeCurrentSession,
8
14
  } from "@ohmyhost/sdk-ts";
9
15
  import { WORKOS_USER_API_KEY_PATTERN } from "@ohmyhost/workos-auth-contracts/user-api-keys";
@@ -558,6 +564,14 @@ export const runProductionCli = async (
558
564
  return written(invalidCommandFailure(argv, environment));
559
565
  }
560
566
  const workingDirectory = overrides.workingDirectory ?? process.cwd();
567
+ const directoryOption = args.indexOf("--directory");
568
+ const selectedSourceDirectory =
569
+ args[0] === "source" &&
570
+ args[1] === "publish" &&
571
+ directoryOption !== -1 &&
572
+ args[directoryOption + 1] !== undefined
573
+ ? resolve(workingDirectory, args[directoryOption + 1] as string)
574
+ : workingDirectory;
561
575
  let command: ProductCliCommand;
562
576
  let linked: Extract<LinkedProject, { outcome: "linked" }> | null;
563
577
  let links: DirectoryLinks = { links: [], legacy: null };
@@ -570,7 +584,7 @@ export const runProductionCli = async (
570
584
  return { outcome: "none" };
571
585
  }
572
586
  links = await readProjectLinks(
573
- join(workingDirectory, platform.linkDirectory),
587
+ join(selectedSourceDirectory, platform.linkDirectory),
574
588
  platform.apiOrigin,
575
589
  );
576
590
  let named = profileName;
@@ -649,6 +663,22 @@ export const runProductionCli = async (
649
663
  );
650
664
  return runOfflineInit(command, io, diagnostics);
651
665
  }
666
+ if (command.kind === "source-inspect") {
667
+ try {
668
+ const workspace = await inspectSourceWorkspace(resolve(workingDirectory, command.directory));
669
+ return written({
670
+ exitCode: 0,
671
+ stdout: `${JSON.stringify({ version: 1, command: "source inspect", status: "inspected", workspace, pending_publications: await inspectLocalSourcePublications(workspace.directory) })}\n`,
672
+ stderr: "",
673
+ });
674
+ } catch (error) {
675
+ return written(
676
+ error instanceof SourceWorkspaceError || error instanceof LocalSourcePublicationError
677
+ ? errorDocument("source inspect", 2, error.code, false, error.message)
678
+ : classifyCliFailure("source inspect", error, signal),
679
+ );
680
+ }
681
+ }
652
682
  if (command.kind === "help" || command.kind === "version") {
653
683
  return written(
654
684
  cliMetadataResult(command.kind, command.kind === "help" ? command.topic : undefined),
@@ -726,7 +756,7 @@ export const runProductionCli = async (
726
756
  );
727
757
  const registry =
728
758
  overrides.profiles ?? new NativeKeyringProfileRegistry(undefined, platform.credentialService);
729
- const linkDirectory = join(workingDirectory, platform.linkDirectory);
759
+ const linkDirectory = join(selectedSourceDirectory, platform.linkDirectory);
730
760
  const projectId = commandProjectId(command);
731
761
  // A command that named its project read no link yet, but the link still says whose project it is.
732
762
  if (projectId !== undefined && linked === null)
@@ -843,6 +873,16 @@ export const runProductionCli = async (
843
873
  return written(result);
844
874
  }
845
875
  const selected = profile;
876
+ if (command.kind === "whoami" || command.kind === "login")
877
+ links = await readProjectLinks(linkDirectory, platform.apiOrigin);
878
+ const sourceOrganizationId =
879
+ selected?.organizationId ?? (command.kind === "login" ? command.organizationId : undefined);
880
+ const boundSource =
881
+ sourceOrganizationId === null || sourceOrganizationId === undefined
882
+ ? links.links.length === 1
883
+ ? links.links[0]
884
+ : undefined
885
+ : links.links.find((link) => link.organization_id === sourceOrganizationId);
846
886
  const productApiOrigin = platform.apiOrigin;
847
887
  const lifecycle = new GeneratedSdkSessionRevoker({
848
888
  environment,
@@ -850,6 +890,15 @@ export const runProductionCli = async (
850
890
  ...(selected === undefined ? {} : { request: { name: selected.name } }),
851
891
  });
852
892
  const cli = new ProductCli({
893
+ sourceDirectory: workingDirectory,
894
+ sourceMetadataDirectory: platform.linkDirectory,
895
+ sourceWorkspace: () => inspectSourceWorkspace(workingDirectory),
896
+ ...(boundSource === undefined
897
+ ? {}
898
+ : {
899
+ linkedSourceProvider: boundSource.version === 3 ? boundSource.source.provider : "github",
900
+ linkedProjectId: boundSource.project_id,
901
+ }),
853
902
  ...(apiToken === undefined ? {} : { apiToken }),
854
903
  commandPrefix: platform.commandPrefix,
855
904
  profiles: registry,
@@ -895,6 +944,19 @@ export const runProductionCli = async (
895
944
  signal,
896
945
  },
897
946
  ),
947
+ projectSourceStatus: (accessToken, projectId) =>
948
+ getProjectSource(
949
+ { project_id: projectId, include_binding: true },
950
+ {
951
+ client: createClient({
952
+ baseUrl: productApiOrigin,
953
+ auth: accessToken,
954
+ throwOnError: true,
955
+ fetch: boundedFetch(),
956
+ }),
957
+ signal,
958
+ },
959
+ ),
898
960
  projectLinkStore: new OwnerOnlyProjectLinkStore(linkDirectory, workingDirectory),
899
961
  productApiOrigin,
900
962
  tokenRefresher: refresher,
@@ -1351,7 +1413,15 @@ export const createBoundedFetch =
1351
1413
  if (isAbortOrTimeout(error)) throw error;
1352
1414
  throw new NetworkTransportError();
1353
1415
  }
1354
- return streaming ? response : bufferBoundedResponse(response, responseLimit);
1416
+ const requestUrl = new URL(input instanceof Request ? input.url : String(input));
1417
+ const sourceRead =
1418
+ /^\/v1\/projects\/[0-7][0-9A-HJKMNP-TV-Z]{25}\/source\/(?:files|diff|versions)$/u.test(
1419
+ requestUrl.pathname,
1420
+ );
1421
+ const limit = sourceRead
1422
+ ? Math.max(responseLimit, Math.ceil((2 * 1024 * 1024) / 3) * 4 + 64 * 1024)
1423
+ : responseLimit;
1424
+ return streaming ? response : bufferBoundedResponse(response, limit);
1355
1425
  };
1356
1426
 
1357
1427
  const bufferBoundedResponse = async (response: Response, limit: number): Promise<Response> => {
@@ -8,6 +8,10 @@ export * from "./profile-store.js";
8
8
  export * from "./project-link-store.js";
9
9
  export * from "./repository-init.js";
10
10
  export * from "./workspace.js";
11
+ export * from "./source-workspace.js";
12
+ export * from "./source-publisher.js";
13
+ export * from "./source-managed-client.js";
14
+ export * from "./source-publication-store.js";
11
15
  export type { PublicAccount } from "./runtime-contract.js";
12
16
  export {
13
17
  parseAccountProfile,
@@ -24,6 +28,8 @@ export {
24
28
  parseOperationEvent,
25
29
  parseSafeProblem,
26
30
  parseProjectDataChangePlan,
31
+ parseProjectSource,
32
+ parseProjectSourceBinding,
27
33
  } from "./runtime-contract.js";
28
34
 
29
35
  export * from "./user-token-file.js";
@@ -143,6 +143,8 @@ import {
143
143
  parseOperation,
144
144
  type PublicAccount,
145
145
  } from "./runtime-contract.js";
146
+ import { GeneratedSdkManagedSourceApi } from "./source-managed-client.js";
147
+ import type { ManagedSourceApi } from "./source-publisher.js";
146
148
 
147
149
  export interface ProjectCreateInput {
148
150
  readonly organizationId: string;
@@ -154,6 +156,7 @@ export interface ProjectCreateInput {
154
156
  }
155
157
 
156
158
  export interface ProductApi {
159
+ readonly managedSource?: ManagedSourceApi;
157
160
  planProjectDataChange(input: {
158
161
  projectId: string;
159
162
  change: ProjectDataChangeKind;
@@ -436,6 +439,7 @@ export interface ProductApi {
436
439
 
437
440
  export class GeneratedSdkProductApi implements ProductApi {
438
441
  readonly #client: OhMyHostClient;
442
+ public readonly managedSource: ManagedSourceApi;
439
443
 
440
444
  public constructor(
441
445
  baseUrl: string,
@@ -443,6 +447,7 @@ export class GeneratedSdkProductApi implements ProductApi {
443
447
  fetch: typeof globalThis.fetch,
444
448
  ) {
445
449
  this.#client = createClient({ auth: accessToken, baseUrl, fetch, throwOnError: true });
450
+ this.managedSource = new GeneratedSdkManagedSourceApi(() => this.#client);
446
451
  }
447
452
 
448
453
  public async changeDatabaseCompute(input: {
@@ -15,7 +15,7 @@ import { join } from "node:path";
15
15
  * Which project this working copy deploys to for one organization. The same checkout may be linked
16
16
  * once per organization, so a second account never overwrites or inherits the first one's link.
17
17
  */
18
- export interface ProjectLinkMetadata {
18
+ export interface LegacyScopedProjectLink {
19
19
  readonly version: 2;
20
20
  readonly api_origin: string;
21
21
  readonly organization_id: string;
@@ -24,6 +24,33 @@ export interface ProjectLinkMetadata {
24
24
  readonly repository_full_name: string;
25
25
  }
26
26
 
27
+ /** Provider binding follows the project; managed sources also cache this directory's confirmed base. */
28
+ export interface CurrentProjectLink {
29
+ readonly version: 3;
30
+ readonly api_origin: string;
31
+ readonly organization_id: string;
32
+ readonly project_id: string;
33
+ readonly source:
34
+ | {
35
+ readonly provider: "managed";
36
+ readonly source_connection_id: string;
37
+ readonly source_generation: number;
38
+ readonly namespace: string;
39
+ readonly repository_name: string;
40
+ readonly branch: "main";
41
+ readonly last_confirmed_commit_sha: string | null;
42
+ }
43
+ | {
44
+ readonly provider: "github";
45
+ readonly source_connection_id: string;
46
+ readonly source_generation: number;
47
+ readonly installation_id: string;
48
+ readonly repository_full_name: string;
49
+ };
50
+ }
51
+
52
+ export type ProjectLinkMetadata = LegacyScopedProjectLink | CurrentProjectLink;
53
+
27
54
  /** A link written before links named their organization; it is verified before any use. */
28
55
  export interface LegacyProjectLink {
29
56
  readonly project_id: string;
@@ -60,6 +87,10 @@ export async function readProjectLinks(
60
87
  const organizationId = /^([0-7][0-9A-HJKMNP-TV-Z]{25})\.json$/u.exec(name)?.[1];
61
88
  if (organizationId === undefined) continue;
62
89
  const link = await readJson(join(directory, LINKS, name));
90
+ if (isCurrentLink(link, apiOrigin, organizationId)) {
91
+ links.push(link);
92
+ continue;
93
+ }
63
94
  if (
64
95
  isRecord(link, [
65
96
  "version",
@@ -100,6 +131,66 @@ export async function readProjectLinks(
100
131
  };
101
132
  }
102
133
 
134
+ function isCurrentLink(
135
+ value: Record<string, unknown> | null,
136
+ apiOrigin: string,
137
+ organizationId: string,
138
+ ): value is unknown & CurrentProjectLink & Record<string, unknown> {
139
+ if (
140
+ !isRecord(value, ["version", "api_origin", "organization_id", "project_id", "source"]) ||
141
+ value["version"] !== 3 ||
142
+ value["api_origin"] !== apiOrigin ||
143
+ value["organization_id"] !== organizationId ||
144
+ typeof value["project_id"] !== "string" ||
145
+ !ULID.test(value["project_id"])
146
+ )
147
+ return false;
148
+ const candidate = value["source"];
149
+ if (typeof candidate !== "object" || candidate === null || !("provider" in candidate))
150
+ return false;
151
+ const source = candidate as Record<string, unknown>;
152
+ const keys =
153
+ source["provider"] === "managed"
154
+ ? [
155
+ "provider",
156
+ "source_connection_id",
157
+ "source_generation",
158
+ "namespace",
159
+ "repository_name",
160
+ "branch",
161
+ "last_confirmed_commit_sha",
162
+ ]
163
+ : [
164
+ "provider",
165
+ "source_connection_id",
166
+ "source_generation",
167
+ "installation_id",
168
+ "repository_full_name",
169
+ ];
170
+ if (
171
+ !isRecord(source, keys) ||
172
+ typeof source["source_connection_id"] !== "string" ||
173
+ !ULID.test(source["source_connection_id"]) ||
174
+ !Number.isSafeInteger(source["source_generation"]) ||
175
+ Number(source["source_generation"]) < 1
176
+ )
177
+ return false;
178
+ return source["provider"] === "managed"
179
+ ? source["branch"] === "main" &&
180
+ (source["last_confirmed_commit_sha"] === null ||
181
+ (typeof source["last_confirmed_commit_sha"] === "string" &&
182
+ /^[a-f0-9]{40}$/u.test(source["last_confirmed_commit_sha"]))) &&
183
+ typeof source["namespace"] === "string" &&
184
+ typeof source["repository_name"] === "string" &&
185
+ /^[a-z0-9][a-z0-9_-]{0,127}$/u.test(source["namespace"]) &&
186
+ /^[a-z0-9][a-z0-9_-]{0,127}$/u.test(source["repository_name"])
187
+ : source["provider"] === "github" &&
188
+ typeof source["installation_id"] === "string" &&
189
+ /^[1-9][0-9]{0,19}$/u.test(source["installation_id"]) &&
190
+ typeof source["repository_full_name"] === "string" &&
191
+ /^[^/]+\/[^/]+$/u.test(source["repository_full_name"]);
192
+ }
193
+
103
194
  export type LinkedProject =
104
195
  | {
105
196
  readonly outcome: "linked";
@@ -1,4 +1,8 @@
1
1
  import { parseFeedbackSubmissionDetail } from "@ohmyhost/contracts/feedback";
2
+ import {
3
+ assertGithubSourceResponse,
4
+ assertManagedSourceResponse,
5
+ } from "@ohmyhost/contracts/managed-sources";
2
6
  import {
3
7
  parseProjectDataChangePlan as readProjectDataChangePlan,
4
8
  type ProjectDataChangePlan,
@@ -822,7 +826,20 @@ export const parseProjectStatus = (value: unknown): Readonly<Record<string, unkn
822
826
  return input;
823
827
  };
824
828
 
825
- const parseProjectSource = (value: unknown): void => {
829
+ export const parseProjectSource = (value: unknown): void => {
830
+ if (
831
+ typeof value === "object" &&
832
+ value !== null &&
833
+ "provider" in value &&
834
+ value.provider === "managed"
835
+ ) {
836
+ try {
837
+ assertManagedSourceResponse("source", value);
838
+ } catch {
839
+ throw new ResponseContractError();
840
+ }
841
+ return;
842
+ }
826
843
  const input = exactRecord(value, [
827
844
  "provider",
828
845
  "installation_id",
@@ -850,6 +867,22 @@ const parseProjectSource = (value: unknown): void => {
850
867
  }
851
868
  };
852
869
 
870
+ /** Binding-aware reads opt in explicitly; a legacy GitHub projection cannot supply a fence. */
871
+ export const parseProjectSourceBinding = (value: unknown): void => {
872
+ try {
873
+ if (
874
+ typeof value === "object" &&
875
+ value !== null &&
876
+ "provider" in value &&
877
+ value.provider === "managed"
878
+ )
879
+ assertManagedSourceResponse("source", value);
880
+ else assertGithubSourceResponse("binding", value);
881
+ } catch {
882
+ throw new ResponseContractError();
883
+ }
884
+ };
885
+
853
886
  const parseProjectDeployment = (value: unknown): void => {
854
887
  const input = exactRecord(value, [
855
888
  "id",