@amerged/ohmyhost-mcp 0.1.19 → 0.1.20
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/README.md +1 -1
- package/dist/{chunk-PXGLL6SR.js → chunk-NESQZ3BS.js} +6 -6
- package/dist/index.js +1 -1
- package/dist/stdio-main.js +1316 -165
- package/package.json +1 -1
- package/src/apps/mcp/local-client.ts +86 -17
- package/src/apps/mcp/local-server.ts +280 -86
- package/src/apps/mcp/stdio-main.ts +11 -2
- package/src/apps/product-cli/cli.ts +337 -92
- package/src/apps/product-cli/command.ts +111 -6
- package/src/apps/product-cli/composition.ts +748 -120
- package/src/apps/product-cli/credential-store.ts +5 -3
- package/src/apps/product-cli/index.ts +2 -0
- package/src/apps/product-cli/product-api.ts +4 -4
- package/src/apps/product-cli/profile-store.ts +609 -0
- package/src/apps/product-cli/project-link-store.ts +169 -20
- package/src/apps/product-cli/runtime-contract.ts +15 -4
- package/src/apps/product-cli/workspace.ts +75 -13
- package/src/packages/agent-skills/generated-skill-resources.ts +5 -5
|
@@ -1,37 +1,166 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
appendFile,
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
unlink,
|
|
10
|
+
writeFile,
|
|
11
|
+
} from "node:fs/promises";
|
|
2
12
|
import { join } from "node:path";
|
|
3
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Which project this working copy deploys to for one organization. The same checkout may be linked
|
|
16
|
+
* once per organization, so a second account never overwrites or inherits the first one's link.
|
|
17
|
+
*/
|
|
4
18
|
export interface ProjectLinkMetadata {
|
|
5
|
-
readonly version:
|
|
19
|
+
readonly version: 2;
|
|
6
20
|
readonly api_origin: string;
|
|
21
|
+
readonly organization_id: string;
|
|
7
22
|
readonly project_id: string;
|
|
8
23
|
readonly installation_id: string;
|
|
9
24
|
readonly repository_full_name: string;
|
|
10
|
-
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A link written before links named their organization; it is verified before any use. */
|
|
28
|
+
export interface LegacyProjectLink {
|
|
29
|
+
readonly project_id: string;
|
|
30
|
+
readonly installation_id: string;
|
|
31
|
+
readonly repository_full_name: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DirectoryLinks {
|
|
35
|
+
readonly links: readonly ProjectLinkMetadata[];
|
|
36
|
+
readonly legacy: LegacyProjectLink | null;
|
|
11
37
|
}
|
|
12
38
|
|
|
13
39
|
export interface ProjectLinkStore {
|
|
14
40
|
save(metadata: ProjectLinkMetadata): Promise<void>;
|
|
15
41
|
}
|
|
16
42
|
|
|
17
|
-
|
|
18
|
-
|
|
43
|
+
const ULID = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/u;
|
|
44
|
+
const LINKS = "links";
|
|
45
|
+
const LEGACY_LINK = "link.json";
|
|
46
|
+
|
|
47
|
+
/** Every usable link of this directory for this API; unreadable files are not links. */
|
|
48
|
+
export async function readProjectLinks(
|
|
19
49
|
directory: string,
|
|
20
50
|
apiOrigin: string,
|
|
21
|
-
): Promise<
|
|
51
|
+
): Promise<DirectoryLinks> {
|
|
52
|
+
let names: string[] = [];
|
|
22
53
|
try {
|
|
23
|
-
|
|
24
|
-
if (typeof link !== "object" || link === null) return null;
|
|
25
|
-
const { version, api_origin, project_id } = link as Record<string, unknown>;
|
|
26
|
-
return version === 1 &&
|
|
27
|
-
api_origin === apiOrigin &&
|
|
28
|
-
typeof project_id === "string" &&
|
|
29
|
-
/^[0-9A-HJKMNP-TV-Z]{26}$/u.test(project_id)
|
|
30
|
-
? project_id
|
|
31
|
-
: null;
|
|
54
|
+
names = await readdir(join(directory, LINKS));
|
|
32
55
|
} catch {
|
|
33
|
-
|
|
56
|
+
names = [];
|
|
57
|
+
}
|
|
58
|
+
const links: ProjectLinkMetadata[] = [];
|
|
59
|
+
for (const name of names.sort()) {
|
|
60
|
+
const organizationId = /^([0-7][0-9A-HJKMNP-TV-Z]{25})\.json$/u.exec(name)?.[1];
|
|
61
|
+
if (organizationId === undefined) continue;
|
|
62
|
+
const link = await readJson(join(directory, LINKS, name));
|
|
63
|
+
if (
|
|
64
|
+
isRecord(link, [
|
|
65
|
+
"version",
|
|
66
|
+
"api_origin",
|
|
67
|
+
"organization_id",
|
|
68
|
+
"project_id",
|
|
69
|
+
"installation_id",
|
|
70
|
+
"repository_full_name",
|
|
71
|
+
]) &&
|
|
72
|
+
link["version"] === 2 &&
|
|
73
|
+
link["api_origin"] === apiOrigin &&
|
|
74
|
+
link["organization_id"] === organizationId &&
|
|
75
|
+
typeof link["project_id"] === "string" &&
|
|
76
|
+
ULID.test(link["project_id"]) &&
|
|
77
|
+
typeof link["installation_id"] === "string" &&
|
|
78
|
+
typeof link["repository_full_name"] === "string"
|
|
79
|
+
)
|
|
80
|
+
links.push(link as unknown as ProjectLinkMetadata);
|
|
34
81
|
}
|
|
82
|
+
const legacy = await readJson(join(directory, LEGACY_LINK));
|
|
83
|
+
return {
|
|
84
|
+
links,
|
|
85
|
+
legacy:
|
|
86
|
+
typeof legacy === "object" &&
|
|
87
|
+
legacy !== null &&
|
|
88
|
+
legacy["version"] === 1 &&
|
|
89
|
+
legacy["api_origin"] === apiOrigin &&
|
|
90
|
+
typeof legacy["project_id"] === "string" &&
|
|
91
|
+
ULID.test(legacy["project_id"]) &&
|
|
92
|
+
typeof legacy["installation_id"] === "string" &&
|
|
93
|
+
typeof legacy["repository_full_name"] === "string"
|
|
94
|
+
? {
|
|
95
|
+
project_id: legacy["project_id"],
|
|
96
|
+
installation_id: legacy["installation_id"],
|
|
97
|
+
repository_full_name: legacy["repository_full_name"],
|
|
98
|
+
}
|
|
99
|
+
: null,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type LinkedProject =
|
|
104
|
+
| {
|
|
105
|
+
readonly outcome: "linked";
|
|
106
|
+
readonly projectId: string;
|
|
107
|
+
/** Null for a legacy link, whose organization is still to be verified. */
|
|
108
|
+
readonly organizationId: string | null;
|
|
109
|
+
}
|
|
110
|
+
| { readonly outcome: "none" }
|
|
111
|
+
| { readonly outcome: "ambiguous"; readonly organizationIds: readonly string[] };
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The project a command in this directory means when it names none. With a known organization only
|
|
115
|
+
* that organization's link counts; otherwise exactly one link must exist.
|
|
116
|
+
*/
|
|
117
|
+
export function chooseLinkedProject(
|
|
118
|
+
directory: DirectoryLinks,
|
|
119
|
+
organizationId: string | undefined,
|
|
120
|
+
): LinkedProject {
|
|
121
|
+
if (organizationId !== undefined) {
|
|
122
|
+
const link = directory.links.find((candidate) => candidate.organization_id === organizationId);
|
|
123
|
+
if (link !== undefined)
|
|
124
|
+
return { outcome: "linked", projectId: link.project_id, organizationId };
|
|
125
|
+
return directory.legacy === null
|
|
126
|
+
? { outcome: "none" }
|
|
127
|
+
: { outcome: "linked", projectId: directory.legacy.project_id, organizationId: null };
|
|
128
|
+
}
|
|
129
|
+
const candidates = [
|
|
130
|
+
...directory.links.map((link) => ({
|
|
131
|
+
projectId: link.project_id,
|
|
132
|
+
organizationId: link.organization_id as string | null,
|
|
133
|
+
})),
|
|
134
|
+
...(directory.legacy === null
|
|
135
|
+
? []
|
|
136
|
+
: [{ projectId: directory.legacy.project_id, organizationId: null }]),
|
|
137
|
+
];
|
|
138
|
+
const [only] = candidates;
|
|
139
|
+
if (candidates.length === 1 && only !== undefined) return { outcome: "linked", ...only };
|
|
140
|
+
if (candidates.length === 0) return { outcome: "none" };
|
|
141
|
+
return {
|
|
142
|
+
outcome: "ambiguous",
|
|
143
|
+
organizationIds: directory.links.map((link) => link.organization_id),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The organization a directory link records for this project, if one does. */
|
|
148
|
+
export function linkedOrganization(
|
|
149
|
+
directory: DirectoryLinks,
|
|
150
|
+
projectId: string,
|
|
151
|
+
): string | undefined {
|
|
152
|
+
const organizations = new Set(
|
|
153
|
+
directory.links
|
|
154
|
+
.filter((link) => link.project_id === projectId)
|
|
155
|
+
.map((link) => link.organization_id),
|
|
156
|
+
);
|
|
157
|
+
const [organizationId] = organizations;
|
|
158
|
+
return organizations.size === 1 ? organizationId : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Drops the pre-organization link once its replacement is written; it is never guessed at. */
|
|
162
|
+
export async function removeLegacyProjectLink(directory: string): Promise<void> {
|
|
163
|
+
await unlink(join(directory, LEGACY_LINK));
|
|
35
164
|
}
|
|
36
165
|
|
|
37
166
|
export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
|
|
@@ -42,10 +171,14 @@ export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
|
|
|
42
171
|
) {}
|
|
43
172
|
|
|
44
173
|
public async save(metadata: ProjectLinkMetadata): Promise<void> {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
174
|
+
if (!ULID.test(metadata.organization_id)) throw new TypeError("Invalid linked organization");
|
|
175
|
+
const links = join(this.directory, LINKS);
|
|
176
|
+
for (const directory of [this.directory, links]) {
|
|
177
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
178
|
+
await chmod(directory, 0o700);
|
|
179
|
+
}
|
|
180
|
+
const target = join(links, `${metadata.organization_id}.json`);
|
|
181
|
+
const temporary = join(links, `.link-${process.pid}.tmp`);
|
|
49
182
|
await writeFile(temporary, `${JSON.stringify(metadata)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
50
183
|
await chmod(temporary, 0o600);
|
|
51
184
|
await rename(temporary, target);
|
|
@@ -54,6 +187,22 @@ export class OwnerOnlyProjectLinkStore implements ProjectLinkStore {
|
|
|
54
187
|
}
|
|
55
188
|
}
|
|
56
189
|
|
|
190
|
+
async function readJson(path: string): Promise<Record<string, unknown> | null> {
|
|
191
|
+
try {
|
|
192
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
193
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
194
|
+
? (value as Record<string, unknown>)
|
|
195
|
+
: null;
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const isRecord = (value: unknown, keys: readonly string[]): value is Record<string, unknown> =>
|
|
202
|
+
typeof value === "object" &&
|
|
203
|
+
value !== null &&
|
|
204
|
+
Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
|
205
|
+
|
|
57
206
|
async function ignoreLinkDirectory(root: string): Promise<void> {
|
|
58
207
|
try {
|
|
59
208
|
await stat(join(root, ".git"));
|
|
@@ -34,7 +34,7 @@ export const PROBLEM_POLICY = {
|
|
|
34
34
|
status: 409,
|
|
35
35
|
retryable: false,
|
|
36
36
|
action:
|
|
37
|
-
"Run 'ohmyhost github connect --organization ULID --idempotency-key KEY --json' for
|
|
37
|
+
"This workspace has no GitHub connection yet. Run 'ohmyhost github connect --organization ULID --idempotency-key KEY --json' for it, complete its browser link, then repeat the original source link. If the project belongs to another account, use that account's login instead; error.acting_as names the login this ran as.",
|
|
38
38
|
},
|
|
39
39
|
github_connection_revoked: {
|
|
40
40
|
status: 409,
|
|
@@ -120,7 +120,12 @@ export const PROBLEM_POLICY = {
|
|
|
120
120
|
action:
|
|
121
121
|
"Run 'ohmyhost organization list --json' and select one with 'ohmyhost organization use --organization ULID --json', or create the first workspace.",
|
|
122
122
|
},
|
|
123
|
-
resource_not_found: {
|
|
123
|
+
resource_not_found: {
|
|
124
|
+
status: 404,
|
|
125
|
+
retryable: false,
|
|
126
|
+
action:
|
|
127
|
+
"Check the resource identifier and the account: a project is visible only to its own organization, and error.acting_as names the login this ran as. Use the login of the account that owns it.",
|
|
128
|
+
},
|
|
124
129
|
idempotency_key_reused: {
|
|
125
130
|
status: 409,
|
|
126
131
|
retryable: false,
|
|
@@ -889,7 +894,13 @@ export const parseCurrentIdentity = (value: unknown): PublicCurrentIdentity => {
|
|
|
889
894
|
return input as unknown as PublicCurrentIdentity;
|
|
890
895
|
};
|
|
891
896
|
|
|
892
|
-
|
|
897
|
+
/** The signed-in user and the workspaces it belongs to, as `GET /v1/me/profile` reports them. */
|
|
898
|
+
export interface PublicAccount {
|
|
899
|
+
readonly userId: string;
|
|
900
|
+
readonly organizations: readonly PublicWorkspace[];
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
export const parseAccountProfile = (value: unknown): PublicAccount => {
|
|
893
904
|
const input = exactRecord(value, [
|
|
894
905
|
"user_id",
|
|
895
906
|
"name",
|
|
@@ -927,7 +938,7 @@ export const parseAccountProfile = (value: unknown): readonly PublicWorkspace[]
|
|
|
927
938
|
});
|
|
928
939
|
if (new Set(workspaces.map((workspace) => workspace.id)).size !== organizationIds.length)
|
|
929
940
|
throw new ResponseContractError();
|
|
930
|
-
return workspaces;
|
|
941
|
+
return { userId: input["user_id"] as string, organizations: workspaces };
|
|
931
942
|
};
|
|
932
943
|
|
|
933
944
|
export interface PublicProject {
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import type { TokenRefresher } from "./cli.js";
|
|
2
2
|
import type { CredentialStore, StoredCredential } from "./credential-store.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { LocalProfile, ProfileRegistry } from "./profile-store.js";
|
|
4
|
+
import { parseCurrentIdentity, type PublicWorkspace } from "./runtime-contract.js";
|
|
4
5
|
|
|
5
|
-
/** The
|
|
6
|
+
/** The saved login a workspace selection works on; absent for a user API token. */
|
|
6
7
|
export interface WorkspaceSession {
|
|
7
8
|
readonly store: CredentialStore;
|
|
8
9
|
readonly refresher: TokenRefresher;
|
|
10
|
+
readonly profiles: ProfileRegistry;
|
|
11
|
+
readonly profile: LocalProfile;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
/** A user API token carries a fixed scope and can never select another workspace. */
|
|
@@ -16,6 +19,28 @@ export class InteractiveSessionRequiredError extends Error {
|
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
22
|
+
/**
|
|
23
|
+
* A saved login keeps the organization it was signed in to. Another organization is its own login,
|
|
24
|
+
* so switching one login between organizations can never retarget another agent's work.
|
|
25
|
+
*/
|
|
26
|
+
export class ProfileOrganizationFixedError extends Error {
|
|
27
|
+
public constructor(
|
|
28
|
+
readonly profile: LocalProfile,
|
|
29
|
+
readonly organization: { readonly id: string },
|
|
30
|
+
) {
|
|
31
|
+
super("The saved login is bound to another organization");
|
|
32
|
+
this.name = "ProfileOrganizationFixedError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The provider answered with an identity other than the one that was requested. */
|
|
37
|
+
export class AccountVerificationError extends Error {
|
|
38
|
+
public constructor() {
|
|
39
|
+
super("The signed-in account does not match the requested one");
|
|
40
|
+
this.name = "AccountVerificationError";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
19
44
|
export type WorkspaceSelection =
|
|
20
45
|
| { readonly outcome: "selected"; readonly organization: PublicWorkspace }
|
|
21
46
|
| { readonly outcome: "no_workspace" }
|
|
@@ -43,15 +68,45 @@ export const chooseWorkspace = (
|
|
|
43
68
|
return { outcome: "choice_required", organizations };
|
|
44
69
|
};
|
|
45
70
|
|
|
71
|
+
/** The user a verified identity answer names; only interactive users and their keys have one. */
|
|
72
|
+
export const verifiedUserId = (identity: unknown): string => {
|
|
73
|
+
const actor = parseCurrentIdentity(identity).actor_id;
|
|
74
|
+
if (!actor.startsWith("user:") || actor.length === 5) throw new AccountVerificationError();
|
|
75
|
+
return actor.slice(5);
|
|
76
|
+
};
|
|
77
|
+
|
|
46
78
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
79
|
+
* Check that a token belongs to the expected user and organization before anything is stored.
|
|
80
|
+
* A null organization means the token must not carry one.
|
|
81
|
+
*/
|
|
82
|
+
export const assertVerifiedLogin = (
|
|
83
|
+
identity: unknown,
|
|
84
|
+
userId: string,
|
|
85
|
+
organizationId: string | null,
|
|
86
|
+
): void => {
|
|
87
|
+
const organizations = parseCurrentIdentity(identity).organization_ids;
|
|
88
|
+
if (
|
|
89
|
+
verifiedUserId(identity) !== userId ||
|
|
90
|
+
(organizationId === null
|
|
91
|
+
? organizations.length !== 0
|
|
92
|
+
: organizations.length !== 1 || organizations[0] !== organizationId)
|
|
93
|
+
)
|
|
94
|
+
throw new AccountVerificationError();
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Bind a saved login that has no organization yet to one of its user's organizations. The provider
|
|
99
|
+
* scopes the session to that organization and the result is verified before it replaces the login.
|
|
49
100
|
*/
|
|
50
101
|
export const selectWorkspace = async (
|
|
51
102
|
session: WorkspaceSession,
|
|
52
103
|
organization: PublicWorkspace,
|
|
53
104
|
signal: AbortSignal,
|
|
54
|
-
|
|
105
|
+
identity: (accessToken: string) => Promise<unknown>,
|
|
106
|
+
): Promise<LocalProfile> => {
|
|
107
|
+
if (session.profile.organizationId === organization.id) return session.profile;
|
|
108
|
+
if (session.profile.organizationId !== null)
|
|
109
|
+
throw new ProfileOrganizationFixedError(session.profile, organization);
|
|
55
110
|
const stored = await session.store.read();
|
|
56
111
|
if (stored === undefined) throw new InteractiveSessionRequiredError();
|
|
57
112
|
const refreshed = await session.refresher.refresh(
|
|
@@ -59,17 +114,24 @@ export const selectWorkspace = async (
|
|
|
59
114
|
signal,
|
|
60
115
|
organization.workos_id,
|
|
61
116
|
);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
117
|
+
assertVerifiedLogin(
|
|
118
|
+
await identity(refreshed.accessToken),
|
|
119
|
+
session.profile.userId,
|
|
120
|
+
organization.id,
|
|
121
|
+
);
|
|
122
|
+
const bound: LocalProfile = {
|
|
123
|
+
...session.profile,
|
|
65
124
|
organizationId: organization.id,
|
|
66
|
-
|
|
125
|
+
organizationName: organization.name,
|
|
126
|
+
};
|
|
127
|
+
await session.profiles.save(
|
|
128
|
+
bound,
|
|
129
|
+
{ ...refreshed, workosOrganizationId: organization.workos_id, organizationId: organization.id },
|
|
130
|
+
session.profile,
|
|
131
|
+
);
|
|
132
|
+
return bound;
|
|
67
133
|
};
|
|
68
134
|
|
|
69
|
-
/** The workspace the stored credential is scoped to, or null while none was selected. */
|
|
70
|
-
export const selectedWorkspaceId = async (session: WorkspaceSession): Promise<string | null> =>
|
|
71
|
-
(await session.store.read())?.organizationId ?? null;
|
|
72
|
-
|
|
73
135
|
/**
|
|
74
136
|
* The workspace fields of a credential, present only once one has been selected. A refresh returns
|
|
75
137
|
* a bare credential, so carrying these forward is what keeps the selection alive.
|