@amerged/ohmyhost-mcp 0.1.10
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/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +11 -0
- package/THIRD_PARTY_NOTICES.md +474 -0
- package/dist/chunk-7MV2EVXV.js +30779 -0
- package/dist/index.js +9 -0
- package/dist/stdio-main.js +5607 -0
- package/package.json +32 -0
- package/src/apps/mcp/index.ts +30 -0
- package/src/apps/mcp/local-client.ts +891 -0
- package/src/apps/mcp/local-server.ts +1512 -0
- package/src/apps/mcp/stdio-main.ts +26 -0
- package/src/apps/product-cli/bin.ts +24 -0
- package/src/apps/product-cli/cli.ts +2271 -0
- package/src/apps/product-cli/command.ts +1811 -0
- package/src/apps/product-cli/composition.ts +823 -0
- package/src/apps/product-cli/credential-store.ts +149 -0
- package/src/apps/product-cli/device-token-expiry.ts +89 -0
- package/src/apps/product-cli/file-credential-store.ts +166 -0
- package/src/apps/product-cli/index.ts +23 -0
- package/src/apps/product-cli/product-api.ts +1082 -0
- package/src/apps/product-cli/project-link-store.ts +30 -0
- package/src/apps/product-cli/psql-session.ts +29 -0
- package/src/apps/product-cli/repository-init.ts +1622 -0
- package/src/apps/product-cli/runtime-contract.ts +1747 -0
- package/src/apps/product-cli/user-token-file.ts +214 -0
- package/src/apps/product-cli/workspace.ts +92 -0
- package/src/packages/agent-skills/generated-skill-resources.ts +147 -0
- package/src/packages/agent-skills/index.ts +37 -0
- package/src/packages/contracts/application-root.ts +80 -0
- package/src/packages/contracts/billing.ts +172 -0
- package/src/packages/contracts/credit-pricing.ts +214 -0
- package/src/packages/contracts/database-access.ts +257 -0
- package/src/packages/contracts/database-compute.ts +72 -0
- package/src/packages/contracts/database-migration-admission.ts +235 -0
- package/src/packages/contracts/feedback.ts +96 -0
- package/src/packages/contracts/framework-admission.ts +280 -0
- package/src/packages/contracts/framework-family.ts +18 -0
- package/src/packages/contracts/functions.ts +76 -0
- package/src/packages/contracts/index.ts +245 -0
- package/src/packages/contracts/operation-deployment-result.ts +165 -0
- package/src/packages/contracts/operation-failure.ts +150 -0
- package/src/packages/contracts/platform-origins.ts +5 -0
- package/src/packages/contracts/project-context.ts +59 -0
- package/src/packages/contracts/project-exports.ts +102 -0
- package/src/packages/contracts/user-api-keys.ts +98 -0
- package/src/packages/sdk-ts/generated/client/client.gen.ts +277 -0
- package/src/packages/sdk-ts/generated/client/index.ts +27 -0
- package/src/packages/sdk-ts/generated/client/types.gen.ts +217 -0
- package/src/packages/sdk-ts/generated/client/utils.gen.ts +316 -0
- package/src/packages/sdk-ts/generated/client.gen.ts +24 -0
- package/src/packages/sdk-ts/generated/core/auth.gen.ts +48 -0
- package/src/packages/sdk-ts/generated/core/bodySerializer.gen.ts +82 -0
- package/src/packages/sdk-ts/generated/core/params.gen.ts +178 -0
- package/src/packages/sdk-ts/generated/core/pathSerializer.gen.ts +171 -0
- package/src/packages/sdk-ts/generated/core/queryKeySerializer.gen.ts +117 -0
- package/src/packages/sdk-ts/generated/core/serverSentEvents.gen.ts +242 -0
- package/src/packages/sdk-ts/generated/core/types.gen.ts +114 -0
- package/src/packages/sdk-ts/generated/core/utils.gen.ts +140 -0
- package/src/packages/sdk-ts/generated/index.ts +578 -0
- package/src/packages/sdk-ts/generated/sdk.gen.ts +3440 -0
- package/src/packages/sdk-ts/generated/types.gen.ts +5790 -0
- package/src/packages/sdk-ts/index.ts +7 -0
- package/src/packages/workos-auth-contracts/device-flow.ts +271 -0
- package/src/packages/workos-auth-contracts/index.ts +8 -0
- package/src/packages/workos-auth-contracts/management-rpc.ts +379 -0
- package/src/packages/workos-auth-contracts/organizations.ts +113 -0
- package/src/packages/workos-auth-contracts/parsing.ts +75 -0
- package/src/packages/workos-auth-contracts/session-status.ts +53 -0
- package/src/packages/workos-auth-contracts/tokens.ts +216 -0
- package/src/packages/workos-auth-contracts/user-api-keys.ts +85 -0
- package/src/packages/workos-auth-contracts/webhooks.ts +127 -0
|
@@ -0,0 +1,2271 @@
|
|
|
1
|
+
import { open } from "node:fs/promises";
|
|
2
|
+
import {
|
|
3
|
+
parseProjectDatabaseWrite,
|
|
4
|
+
assertProjectDatabaseWriteReceipt,
|
|
5
|
+
assertProjectDatabaseAccess,
|
|
6
|
+
assertProjectDatabaseAccessCredential,
|
|
7
|
+
assertProjectDatabaseAccessPage,
|
|
8
|
+
} from "@ohmyhost/contracts/database-access";
|
|
9
|
+
import { assertOrganizationAccount } from "@ohmyhost/contracts/credit-pricing";
|
|
10
|
+
import { assertDatabaseCompute } from "@ohmyhost/contracts/database-compute";
|
|
11
|
+
import cliPackage from "../package.json" with { type: "json" };
|
|
12
|
+
import {
|
|
13
|
+
assertProjectContext,
|
|
14
|
+
assertProjectNotesReceipt,
|
|
15
|
+
} from "@ohmyhost/contracts/project-context";
|
|
16
|
+
import { assertProjectExport } from "@ohmyhost/contracts/project-exports";
|
|
17
|
+
import { prepareUserTokenFile, UserTokenFileError } from "./user-token-file.js";
|
|
18
|
+
import { parseUserApiKeyPage } from "@ohmyhost/contracts/user-api-keys";
|
|
19
|
+
import { assertOrganizationCreditUsage } from "@ohmyhost/contracts/credit-pricing";
|
|
20
|
+
import {
|
|
21
|
+
assertBillingCheckout,
|
|
22
|
+
assertBillingPortal,
|
|
23
|
+
assertRecharge,
|
|
24
|
+
} from "@ohmyhost/contracts/billing";
|
|
25
|
+
import type {
|
|
26
|
+
DeviceAuthorization,
|
|
27
|
+
DevicePollResult,
|
|
28
|
+
WorkosDeviceFlowClient,
|
|
29
|
+
} from "@ohmyhost/workos-auth-contracts/device-flow";
|
|
30
|
+
import { WorkosContractError } from "@ohmyhost/workos-auth-contracts/parsing";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
InvalidCommandError,
|
|
34
|
+
parseProductCliCommand,
|
|
35
|
+
recognizedCommandLabel,
|
|
36
|
+
type RecognizedCommandLabel,
|
|
37
|
+
type ProductCliCommand,
|
|
38
|
+
} from "./command.js";
|
|
39
|
+
import {
|
|
40
|
+
CredentialStoreUnavailableError,
|
|
41
|
+
type CredentialStore,
|
|
42
|
+
type CurrentCredential,
|
|
43
|
+
type StoredCredential,
|
|
44
|
+
} from "./credential-store.js";
|
|
45
|
+
import type { ProductApi } from "./product-api.js";
|
|
46
|
+
import type { ProjectLinkStore } from "./project-link-store.js";
|
|
47
|
+
import { DeviceTokenExpiryError, resolveDeviceTokenExpiresAt } from "./device-token-expiry.js";
|
|
48
|
+
import {
|
|
49
|
+
chooseWorkspace,
|
|
50
|
+
InteractiveSessionRequiredError,
|
|
51
|
+
publicWorkspace,
|
|
52
|
+
selectWorkspace,
|
|
53
|
+
selectedWorkspaceId,
|
|
54
|
+
workspaceOf,
|
|
55
|
+
type WorkspaceSelection,
|
|
56
|
+
type WorkspaceSession,
|
|
57
|
+
} from "./workspace.js";
|
|
58
|
+
import {
|
|
59
|
+
parseCloudflareDnsAuthorization,
|
|
60
|
+
parseGithubConnectionAuthorization,
|
|
61
|
+
parseGithubOrganizationConnectionStatus,
|
|
62
|
+
parseCloudflareDnsAuthorizationStatus,
|
|
63
|
+
parseDeploymentPlan,
|
|
64
|
+
parseCurrentIdentity,
|
|
65
|
+
parseOrganization,
|
|
66
|
+
type PublicWorkspace,
|
|
67
|
+
parseOrganizationCredits,
|
|
68
|
+
parseProjectCreditBudget,
|
|
69
|
+
parseDevAccessTicket,
|
|
70
|
+
parseGuardedActionPlan,
|
|
71
|
+
parseEnvironmentSecret,
|
|
72
|
+
parseEnvironmentSecretDeletion,
|
|
73
|
+
parseEnvironmentSecretPage,
|
|
74
|
+
parseFunctionRunPage,
|
|
75
|
+
type PublicEnvironmentSecret,
|
|
76
|
+
parseOperation,
|
|
77
|
+
parseOperationEvent,
|
|
78
|
+
parseProjectMailDomain,
|
|
79
|
+
parseProjectPage,
|
|
80
|
+
parseProjectStatus,
|
|
81
|
+
parseProjectDatabaseQuery,
|
|
82
|
+
parsePaidDomain,
|
|
83
|
+
parsePaidDomainPlan,
|
|
84
|
+
parseProviderReconciliationAttempt,
|
|
85
|
+
parseSafeProblem,
|
|
86
|
+
ResponseContractError,
|
|
87
|
+
} from "./runtime-contract.js";
|
|
88
|
+
|
|
89
|
+
export interface CliResult {
|
|
90
|
+
readonly exitCode: number;
|
|
91
|
+
readonly stdout: string;
|
|
92
|
+
readonly stderr: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DeviceAuthorizationPresenter {
|
|
96
|
+
present(input: {
|
|
97
|
+
readonly verificationUri: string;
|
|
98
|
+
/** The provider's prefilled link; the contract binds it to the same host and user code. */
|
|
99
|
+
readonly verificationUriComplete?: string;
|
|
100
|
+
readonly userCode: string;
|
|
101
|
+
readonly expiresInSeconds: number;
|
|
102
|
+
}): Promise<void>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface TokenRevoker {
|
|
106
|
+
revoke(refreshToken: string, signal: AbortSignal): Promise<void>;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface TokenRefresher {
|
|
110
|
+
refresh(
|
|
111
|
+
refreshToken: string,
|
|
112
|
+
signal: AbortSignal,
|
|
113
|
+
workosOrganizationId?: string,
|
|
114
|
+
): Promise<CurrentCredential>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface DiagnosticSink {
|
|
118
|
+
record(event: {
|
|
119
|
+
readonly correlationId: string;
|
|
120
|
+
readonly category: DiagnosticCategory;
|
|
121
|
+
readonly subsystem: DiagnosticSubsystem;
|
|
122
|
+
readonly errorKind: DiagnosticErrorKind;
|
|
123
|
+
}): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type DiagnosticCategory =
|
|
127
|
+
| "cancelled"
|
|
128
|
+
| "configuration"
|
|
129
|
+
| "credential_store"
|
|
130
|
+
| "device_authorization"
|
|
131
|
+
| "internal"
|
|
132
|
+
| "network"
|
|
133
|
+
| "provider_blocked"
|
|
134
|
+
| "remote_problem"
|
|
135
|
+
| "response_contract"
|
|
136
|
+
| "timeout";
|
|
137
|
+
|
|
138
|
+
export type DiagnosticSubsystem =
|
|
139
|
+
| "authentication"
|
|
140
|
+
| "credential_store"
|
|
141
|
+
| "lifecycle"
|
|
142
|
+
| "parser"
|
|
143
|
+
| "runtime"
|
|
144
|
+
| "sdk";
|
|
145
|
+
|
|
146
|
+
export type DiagnosticErrorKind =
|
|
147
|
+
| "blocked"
|
|
148
|
+
| "cancelled"
|
|
149
|
+
| "invalid_response"
|
|
150
|
+
| "network_transport"
|
|
151
|
+
| "remote_problem"
|
|
152
|
+
| "timeout"
|
|
153
|
+
| "unexpected"
|
|
154
|
+
| "unavailable";
|
|
155
|
+
|
|
156
|
+
export class NetworkTransportError extends Error {
|
|
157
|
+
public constructor() {
|
|
158
|
+
super("The network transport failed.");
|
|
159
|
+
this.name = "NetworkTransportError";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class LifecycleTransportError extends Error {
|
|
164
|
+
public constructor() {
|
|
165
|
+
super("The public-client lifecycle transport failed.");
|
|
166
|
+
this.name = "LifecycleTransportError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class TokenRefreshRejectedError extends Error {
|
|
171
|
+
public constructor() {
|
|
172
|
+
super("The session must be authenticated again.");
|
|
173
|
+
this.name = "TokenRefreshRejectedError";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ProviderCapabilityBlockedError extends Error {
|
|
178
|
+
public constructor() {
|
|
179
|
+
super("The provider capability is not available in this release.");
|
|
180
|
+
this.name = "ProviderCapabilityBlockedError";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export class SecretInputError extends Error {
|
|
185
|
+
public constructor() {
|
|
186
|
+
super("The secret input is invalid.");
|
|
187
|
+
this.name = "SecretInputError";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class PublicResponseTooLargeError extends Error {
|
|
192
|
+
public constructor() {
|
|
193
|
+
super("The remote response exceeded the size limit.");
|
|
194
|
+
this.name = "PublicResponseTooLargeError";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface ProductCliDependencies {
|
|
199
|
+
readonly apiToken?: string;
|
|
200
|
+
readonly commandPrefix: string;
|
|
201
|
+
readonly credentialStore: (kind: "native") => CredentialStore;
|
|
202
|
+
readonly deviceFlow: WorkosDeviceFlowClient;
|
|
203
|
+
readonly deviceAuthorizationPresenter: DeviceAuthorizationPresenter;
|
|
204
|
+
readonly productApi: (accessToken: () => Promise<string>) => ProductApi;
|
|
205
|
+
/**
|
|
206
|
+
* Reads the signed-in customer's workspaces through a separate read-only dependency,
|
|
207
|
+
* keeping product mutation capabilities out of login.
|
|
208
|
+
*/
|
|
209
|
+
readonly accountProfile: (
|
|
210
|
+
accessToken: () => Promise<string>,
|
|
211
|
+
) => Promise<readonly PublicWorkspace[]>;
|
|
212
|
+
/** Optional onboarding guidance; production supplies only this one read-only SDK query. */
|
|
213
|
+
readonly githubConnectionStatus?: (
|
|
214
|
+
accessToken: () => Promise<string>,
|
|
215
|
+
organizationId: string,
|
|
216
|
+
) => ReturnType<ProductApi["getGithubConnection"]>;
|
|
217
|
+
readonly projectLinkStore: ProjectLinkStore;
|
|
218
|
+
readonly productApiOrigin: string;
|
|
219
|
+
readonly tokenRevoker: TokenRevoker;
|
|
220
|
+
readonly tokenRefresher: TokenRefresher;
|
|
221
|
+
readonly now: () => Date;
|
|
222
|
+
readonly waitUntil: (instant: Date, signal: AbortSignal) => Promise<void>;
|
|
223
|
+
readonly lifecycleTimeoutMs: number;
|
|
224
|
+
readonly createCorrelationId: () => string;
|
|
225
|
+
readonly diagnosticSink: DiagnosticSink;
|
|
226
|
+
readonly operationEventSink?: { write(value: string): void };
|
|
227
|
+
readonly readSecretValue: (signal: AbortSignal) => Promise<string>;
|
|
228
|
+
readonly spawnPsql?: (input: {
|
|
229
|
+
readonly host: string;
|
|
230
|
+
readonly user: string;
|
|
231
|
+
readonly password: string;
|
|
232
|
+
readonly database: string;
|
|
233
|
+
readonly mode: "read" | "write";
|
|
234
|
+
readonly signal: AbortSignal;
|
|
235
|
+
}) => Promise<number>;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The local psql client is missing or not executable; no credential stays behind. */
|
|
239
|
+
export class PsqlUnavailableError extends Error {
|
|
240
|
+
public constructor() {
|
|
241
|
+
super("The local psql client is unavailable");
|
|
242
|
+
this.name = "PsqlUnavailableError";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface CliErrorDocument {
|
|
247
|
+
readonly version: 1;
|
|
248
|
+
readonly command: string;
|
|
249
|
+
readonly status: "error";
|
|
250
|
+
readonly operation_id?: string;
|
|
251
|
+
readonly error: {
|
|
252
|
+
readonly code: string;
|
|
253
|
+
readonly retryable: boolean;
|
|
254
|
+
readonly suggested_action: string;
|
|
255
|
+
readonly message?: string;
|
|
256
|
+
readonly retry_after_seconds?: number;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
type CommandLabel = RecognizedCommandLabel;
|
|
261
|
+
type CustomerCommandLabel = Exclude<CommandLabel, "unknown" | "help" | "version">;
|
|
262
|
+
|
|
263
|
+
const CLI_VERSION = cliPackage.version;
|
|
264
|
+
const HELP_USAGE = Object.freeze({
|
|
265
|
+
init: "ohmyhost init [--directory PATH] [--root PATH] [--project SLUG] [--region us|eu] [--dry-run] --json (pass the project's hosting region so storage.jurisdiction matches it; us when omitted)",
|
|
266
|
+
login: "ohmyhost login [--organization ULID] --json",
|
|
267
|
+
logout: "ohmyhost logout [--revoke] --json",
|
|
268
|
+
whoami: "ohmyhost whoami --json",
|
|
269
|
+
"github connect":
|
|
270
|
+
"ohmyhost github connect --organization ULID --idempotency-key KEY --json (connect once, then link covered repositories without another browser consent)",
|
|
271
|
+
"github status": "ohmyhost github status --organization ULID --json",
|
|
272
|
+
"export create":
|
|
273
|
+
"ohmyhost export create --project ULID --idempotency-key KEY --stdin --json (password on stdin only; one accepted SQL ZIP per project per 24 hours)",
|
|
274
|
+
"export get":
|
|
275
|
+
"ohmyhost export get EXPORT_ULID --project ULID --json (poll the original job; signed ZIP download lasts 24 hours)",
|
|
276
|
+
"credits account": "ohmyhost credits account --organization ULID --json",
|
|
277
|
+
"credits balance": "ohmyhost credits balance --organization ULID --json",
|
|
278
|
+
"billing recharge get": "ohmyhost billing recharge get --organization ULID --json",
|
|
279
|
+
"billing recharge set":
|
|
280
|
+
"ohmyhost billing recharge set --organization ULID --enabled true|false --monthly-limit-minor CENTS --revision N --idempotency-key KEY [--consent off_session_v1] --json (explicit Owner consent required before enabling)",
|
|
281
|
+
"billing checkout":
|
|
282
|
+
"ohmyhost billing checkout --organization ULID --offer topup|paid [--packs 1] --idempotency-key KEY --json (returns a human payment URL; never auto-pays)",
|
|
283
|
+
"billing status": "ohmyhost billing status --organization ULID --checkout ULID --json",
|
|
284
|
+
"billing portal":
|
|
285
|
+
"ohmyhost billing portal --organization ULID --json (short-lived human URL; request fresh after expiry)",
|
|
286
|
+
"credits usage":
|
|
287
|
+
"ohmyhost credits usage --organization ULID --month YYYY-MM [--cursor ULID] --json",
|
|
288
|
+
"budget get": "ohmyhost budget get --project ULID --json",
|
|
289
|
+
"budget set":
|
|
290
|
+
"ohmyhost budget set --project ULID --credits NUMBER|none [--mode continue|stop] --idempotency-key KEY --json",
|
|
291
|
+
"organization create":
|
|
292
|
+
"ohmyhost organization create --name NAME --idempotency-key KEY [--source SOURCE] --json (SOURCE is optional attribution from a link's r value; the new workspace is selected immediately)",
|
|
293
|
+
"organization list":
|
|
294
|
+
"ohmyhost organization list --json (the workspaces you belong to and the selected one)",
|
|
295
|
+
"organization use": "ohmyhost organization use --organization ULID --json",
|
|
296
|
+
"operation get": "ohmyhost operation get OPERATION_ULID --json",
|
|
297
|
+
"operation reconcile":
|
|
298
|
+
"ohmyhost operation reconcile OPERATION_ULID --idempotency-key KEY --yes --json",
|
|
299
|
+
"token create":
|
|
300
|
+
"ohmyhost token create --organization ULID --name NAME --idempotency-key KEY --out .env.local --json",
|
|
301
|
+
"token list": "ohmyhost token list --organization ULID [--after KEY_ID] --json",
|
|
302
|
+
"token revoke": "ohmyhost token revoke --organization ULID --key KEY_ID --yes --json",
|
|
303
|
+
"feedback submit":
|
|
304
|
+
"ohmyhost feedback submit --organization ULID --kind bug|issue|feature_request --title TITLE --description REDACTED_REPORT [--project ULID] [--environment ULID] [--operation ULID] [--error-code CODE] [--client-version VERSION] --idempotency-key KEY --json",
|
|
305
|
+
"project create":
|
|
306
|
+
"ohmyhost project create --organization ULID --name NAME [--data-mode shared|isolated] [--region us|eu] --idempotency-key KEY --json (the region is chosen once: us is the default, eu places the database, files and builds in the EU; it cannot be changed later)",
|
|
307
|
+
"project list": "ohmyhost project list [--cursor ULID] [--limit LIMIT] --json",
|
|
308
|
+
"project context": "ohmyhost project context --project ULID --json",
|
|
309
|
+
"project notes set":
|
|
310
|
+
"ohmyhost project notes set --project ULID --version NUMBER --markdown TEXT --idempotency-key KEY --json (no credentials or signed URLs)",
|
|
311
|
+
"project status": "ohmyhost project status --project ULID --json",
|
|
312
|
+
"project dev-access create": "ohmyhost project dev-access create --project ULID --json",
|
|
313
|
+
"database compute set":
|
|
314
|
+
"ohmyhost database compute set --project ULID --environment dev|prod --profile standard|performance --idempotency-key KEY --yes [--wait] --json\n" +
|
|
315
|
+
" standard: Free 0.25 CU/1 GB/60s idle, Paid 0.5 CU/2 GB/60s idle. performance: Paid 1 CU/4 GB/300s idle, 2.5x database compute credits per equal active minute; longer idle time also consumes credits.",
|
|
316
|
+
"database compute get":
|
|
317
|
+
"ohmyhost database compute get --project ULID [--environment dev|prod] --json",
|
|
318
|
+
"database write":
|
|
319
|
+
"ohmyhost database write --project ULID --environment dev|prod --statement-file PATH --idempotency-key KEY [--parameters-json JSON] --yes --json",
|
|
320
|
+
"database query":
|
|
321
|
+
"ohmyhost database query --project ULID --environment dev|prod --statement SQL [--parameters-json JSON] --json",
|
|
322
|
+
"database access create":
|
|
323
|
+
"ohmyhost database access create --project ULID --environment dev|prod [--mode read|write] [--ttl 5m|1h|24h|SECONDS] [--label TEXT] --yes --json\n" +
|
|
324
|
+
" The connection URI is shown once. Use it immediately, never store it in files, notes or source, and revoke it when finished.",
|
|
325
|
+
"database access list":
|
|
326
|
+
"ohmyhost database access list --project ULID [--environment dev|prod] --json",
|
|
327
|
+
"database access revoke":
|
|
328
|
+
"ohmyhost database access revoke --project ULID --access ULID --yes --json",
|
|
329
|
+
"database psql":
|
|
330
|
+
"ohmyhost database psql --project ULID --environment dev|prod [--mode read|write] [--ttl 5m|1h|24h|SECONDS] [--json] (starts local psql with a temporary credential and revokes it on exit)",
|
|
331
|
+
link: "ohmyhost link --project ULID --repository-owner OWNER --repository-name REPOSITORY --idempotency-key KEY --json (uses the workspace GitHub connection and waits for the source-link operation)",
|
|
332
|
+
"source auto-deploy set":
|
|
333
|
+
"ohmyhost source auto-deploy set --project ULID --branch BRANCH --enabled true|false --idempotency-key KEY --json",
|
|
334
|
+
"source auto-deploy status": "ohmyhost source auto-deploy status --project ULID --json",
|
|
335
|
+
"domain cloudflare authorize":
|
|
336
|
+
"ohmyhost domain cloudflare authorize --project ULID --zone ZONE --idempotency-key KEY --json",
|
|
337
|
+
"domain cloudflare status": "ohmyhost domain cloudflare status --project ULID --json",
|
|
338
|
+
"domain cloudflare apply":
|
|
339
|
+
"ohmyhost domain cloudflare apply --project ULID --idempotency-key KEY --yes --wait --json",
|
|
340
|
+
"domain paid plan": "ohmyhost domain paid plan --project ULID --hostname HOST --json",
|
|
341
|
+
"domain paid apply":
|
|
342
|
+
"ohmyhost domain paid apply --project ULID --hostname HOST --idempotency-key KEY --yes --json",
|
|
343
|
+
"domain paid status": "ohmyhost domain paid status --project ULID --json",
|
|
344
|
+
"domain paid delete":
|
|
345
|
+
"ohmyhost domain paid delete --project ULID --hostname HOST --idempotency-key KEY --yes --json",
|
|
346
|
+
plan: "ohmyhost plan --project ULID --commit SHA --json",
|
|
347
|
+
deploy:
|
|
348
|
+
"ohmyhost deploy --project ULID --plan-id ULID --idempotency-key KEY --yes [--wait] --json",
|
|
349
|
+
logs: "ohmyhost logs OPERATION_ULID --follow --json",
|
|
350
|
+
"deployment logs": "ohmyhost deployment logs --project ULID --deployment ULID --follow --json",
|
|
351
|
+
"rollback plan": "ohmyhost rollback plan --project ULID --deployment DEPLOYMENT_ULID --json",
|
|
352
|
+
rollback:
|
|
353
|
+
"ohmyhost rollback --project ULID --deployment DEPLOYMENT_ULID --if-match ETAG --confirmation-token TOKEN --idempotency-key KEY --yes --json",
|
|
354
|
+
"deployment promote plan":
|
|
355
|
+
"ohmyhost deployment promote plan --project ULID --deployment DEV_DEPLOYMENT_ULID --json",
|
|
356
|
+
"deployment promote":
|
|
357
|
+
"ohmyhost deployment promote --project ULID --deployment DEV_DEPLOYMENT_ULID --if-match ETAG --confirmation-token TOKEN --idempotency-key KEY --yes [--wait] --json",
|
|
358
|
+
"delete plan": "ohmyhost delete plan --project ULID --json",
|
|
359
|
+
delete:
|
|
360
|
+
"ohmyhost delete --project ULID --if-match ETAG --confirmation-token TOKEN --idempotency-key KEY --yes --json",
|
|
361
|
+
"secret list": "ohmyhost secret list --project ULID --environment ENVIRONMENT_ULID --json",
|
|
362
|
+
"function runs":
|
|
363
|
+
"ohmyhost function runs --project ULID --environment ENVIRONMENT_ULID [--limit 1-100] --json",
|
|
364
|
+
"secret set":
|
|
365
|
+
"printf '%s' \"$SECRET_VALUE\" | ohmyhost secret set NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY --stdin [--wait] --json",
|
|
366
|
+
"secret delete":
|
|
367
|
+
"ohmyhost secret delete NAME --project ULID --environment ENVIRONMENT_ULID --idempotency-key KEY [--wait] --json",
|
|
368
|
+
"mail domain set":
|
|
369
|
+
"ohmyhost mail domain set --project ULID --domain DOMAIN --idempotency-key KEY --json",
|
|
370
|
+
"mail domain status": "ohmyhost mail domain status --project ULID --json",
|
|
371
|
+
} satisfies Readonly<Record<CustomerCommandLabel, string>>);
|
|
372
|
+
|
|
373
|
+
const HELP_COMMANDS = Object.freeze(
|
|
374
|
+
Object.entries(HELP_USAGE).map(([command, usage]) => Object.freeze({ command, usage })),
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
export const cliMetadataResult = (kind: "help" | "version", topic?: string): CliResult =>
|
|
378
|
+
kind === "help"
|
|
379
|
+
? success("help", {
|
|
380
|
+
status: "succeeded",
|
|
381
|
+
cli_version: CLI_VERSION,
|
|
382
|
+
usage: "ohmyhost <command> [options] --json",
|
|
383
|
+
environment: "OHMYHOST_ENVIRONMENT=development|production (default: production)",
|
|
384
|
+
commands: HELP_COMMANDS.filter((entry) => !topic || entry.command === topic),
|
|
385
|
+
})
|
|
386
|
+
: success("version", { status: "succeeded", cli_version: CLI_VERSION });
|
|
387
|
+
|
|
388
|
+
class ProductCliFailure extends Error {
|
|
389
|
+
public constructor(
|
|
390
|
+
public readonly exitCode: number,
|
|
391
|
+
public readonly code: string,
|
|
392
|
+
public readonly retryable: boolean,
|
|
393
|
+
public readonly suggestedAction: string,
|
|
394
|
+
public readonly operation?: ReturnType<typeof parseOperation>,
|
|
395
|
+
) {
|
|
396
|
+
super(code);
|
|
397
|
+
this.name = "ProductCliFailure";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export class ProductCli {
|
|
402
|
+
public constructor(private readonly dependencies: ProductCliDependencies) {}
|
|
403
|
+
|
|
404
|
+
public async run(
|
|
405
|
+
argv: readonly string[],
|
|
406
|
+
signal = new AbortController().signal,
|
|
407
|
+
): Promise<CliResult> {
|
|
408
|
+
let command: ProductCliCommand;
|
|
409
|
+
try {
|
|
410
|
+
command = parseProductCliCommand(argv);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error instanceof InvalidCommandError) {
|
|
413
|
+
return failure(
|
|
414
|
+
recognizedCommandLabel(argv),
|
|
415
|
+
2,
|
|
416
|
+
"invalid_command",
|
|
417
|
+
false,
|
|
418
|
+
"Check 'ohmyhost --help'.",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
this.recordDiagnostic({
|
|
422
|
+
category: "internal",
|
|
423
|
+
subsystem: "parser",
|
|
424
|
+
errorKind: "unexpected",
|
|
425
|
+
});
|
|
426
|
+
return failure("unknown", 1, "internal_error", false, "Retry the command.");
|
|
427
|
+
}
|
|
428
|
+
return this.runCommand(command, signal);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
public async runCommand(
|
|
432
|
+
command: ProductCliCommand,
|
|
433
|
+
signal = new AbortController().signal,
|
|
434
|
+
): Promise<CliResult> {
|
|
435
|
+
const name = parsedCommandName(command);
|
|
436
|
+
try {
|
|
437
|
+
throwIfAborted(signal);
|
|
438
|
+
if (command.kind === "help" || command.kind === "version")
|
|
439
|
+
return cliMetadataResult(command.kind, command.kind === "help" ? command.topic : undefined);
|
|
440
|
+
if (command.kind === "init") throw new ProviderCapabilityBlockedError();
|
|
441
|
+
if (
|
|
442
|
+
this.dependencies.apiToken !== undefined &&
|
|
443
|
+
(command.kind === "organization-create" ||
|
|
444
|
+
command.kind === "organization-list" ||
|
|
445
|
+
command.kind === "organization-use")
|
|
446
|
+
) {
|
|
447
|
+
// A user API token is bound to one workspace and cannot read the account profile.
|
|
448
|
+
throw new InteractiveSessionRequiredError();
|
|
449
|
+
}
|
|
450
|
+
if (command.kind === "login")
|
|
451
|
+
return await this.login(command.credentialStore, command.organizationId, signal);
|
|
452
|
+
if (command.kind === "logout") {
|
|
453
|
+
return await this.logout(command.credentialStore, command.revoke, signal);
|
|
454
|
+
}
|
|
455
|
+
if (command.kind === "github-connect" || command.kind === "github-status") {
|
|
456
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
457
|
+
if (command.kind === "github-status") {
|
|
458
|
+
const github = parseGithubOrganizationConnectionStatus(
|
|
459
|
+
await api.getGithubConnection(command.organizationId),
|
|
460
|
+
);
|
|
461
|
+
if (
|
|
462
|
+
github.connection !== null &&
|
|
463
|
+
github.connection.organization_id !== command.organizationId
|
|
464
|
+
)
|
|
465
|
+
throw new ResponseContractError();
|
|
466
|
+
return success(name, {
|
|
467
|
+
status: github.status,
|
|
468
|
+
github,
|
|
469
|
+
...(github.status === "connected"
|
|
470
|
+
? {}
|
|
471
|
+
: { next_action: this.githubConnectAction(command.organizationId) }),
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
const authorization = parseGithubConnectionAuthorization(await api.connectGithub(command));
|
|
475
|
+
if (
|
|
476
|
+
authorization.connection !== null &&
|
|
477
|
+
authorization.connection.organization_id !== command.organizationId
|
|
478
|
+
)
|
|
479
|
+
throw new ResponseContractError();
|
|
480
|
+
return success(name, {
|
|
481
|
+
status: authorization.status,
|
|
482
|
+
authorization,
|
|
483
|
+
...(authorization.status === "connected"
|
|
484
|
+
? {}
|
|
485
|
+
: {
|
|
486
|
+
next_action:
|
|
487
|
+
authorization.status === "pending"
|
|
488
|
+
? "Open authorization_url in your GitHub profile, then repeat this exact command and key to observe the connection."
|
|
489
|
+
: authorization.status === "authorizing"
|
|
490
|
+
? "GitHub authorization is processing. Repeat this exact command and key; do not replay the callback."
|
|
491
|
+
: "Resolve last_failure, then run github connect for this same workspace with a new idempotency key.",
|
|
492
|
+
}),
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
if (command.kind === "whoami") return await this.getCurrentIdentity(command, signal);
|
|
496
|
+
if (
|
|
497
|
+
command.kind === "token-create" ||
|
|
498
|
+
command.kind === "token-list" ||
|
|
499
|
+
command.kind === "token-revoke"
|
|
500
|
+
) {
|
|
501
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
502
|
+
if (command.kind === "token-list")
|
|
503
|
+
return success(name, {
|
|
504
|
+
status: "listed",
|
|
505
|
+
tokens: parseUserApiKeyPage(await api.listUserApiKeys(command)),
|
|
506
|
+
});
|
|
507
|
+
if (command.kind === "token-revoke") {
|
|
508
|
+
await api.revokeUserApiKey(command);
|
|
509
|
+
return success(name, { status: "revoked", key_id: command.keyId });
|
|
510
|
+
}
|
|
511
|
+
const file = await prepareUserTokenFile(command.outputPath);
|
|
512
|
+
try {
|
|
513
|
+
return success(name, {
|
|
514
|
+
status: "stored",
|
|
515
|
+
...(await file.save(await api.createUserApiKey(command))),
|
|
516
|
+
});
|
|
517
|
+
} finally {
|
|
518
|
+
await file.close();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (command.kind === "billing-recharge-get" || command.kind === "billing-recharge-set") {
|
|
522
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
523
|
+
const recharge =
|
|
524
|
+
command.kind === "billing-recharge-get"
|
|
525
|
+
? await api.getBillingRecharge(command.organizationId)
|
|
526
|
+
: await api.configureBillingRecharge(command);
|
|
527
|
+
try {
|
|
528
|
+
assertRecharge(recharge);
|
|
529
|
+
} catch {
|
|
530
|
+
throw new ResponseContractError();
|
|
531
|
+
}
|
|
532
|
+
return success(name, { status: "succeeded", recharge });
|
|
533
|
+
}
|
|
534
|
+
if (
|
|
535
|
+
command.kind === "billing-checkout" ||
|
|
536
|
+
command.kind === "billing-status" ||
|
|
537
|
+
command.kind === "billing-portal"
|
|
538
|
+
) {
|
|
539
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
540
|
+
const result =
|
|
541
|
+
command.kind === "billing-checkout"
|
|
542
|
+
? await api.createBillingCheckout(command)
|
|
543
|
+
: command.kind === "billing-status"
|
|
544
|
+
? await api.getBillingCheckout(command)
|
|
545
|
+
: await api.createBillingPortal(command.organizationId);
|
|
546
|
+
try {
|
|
547
|
+
if (command.kind === "billing-portal") assertBillingPortal(result);
|
|
548
|
+
else assertBillingCheckout(result);
|
|
549
|
+
} catch {
|
|
550
|
+
throw new ResponseContractError();
|
|
551
|
+
}
|
|
552
|
+
return success(name, { status: "succeeded", billing: result });
|
|
553
|
+
}
|
|
554
|
+
if (command.kind === "credits-account") {
|
|
555
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
556
|
+
const account = await api.getOrganizationAccount(command.organizationId);
|
|
557
|
+
assertOrganizationAccount(account);
|
|
558
|
+
return success("credits account", { status: "succeeded", account });
|
|
559
|
+
}
|
|
560
|
+
if (command.kind === "credits-balance") {
|
|
561
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
562
|
+
return success("credits balance", {
|
|
563
|
+
status: "succeeded",
|
|
564
|
+
credits: parseOrganizationCredits(
|
|
565
|
+
await api.getOrganizationCredits(command.organizationId),
|
|
566
|
+
),
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
if (command.kind === "credits-usage") {
|
|
570
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
571
|
+
const usage = await api.getOrganizationCreditUsage(command);
|
|
572
|
+
try {
|
|
573
|
+
assertOrganizationCreditUsage(usage);
|
|
574
|
+
} catch {
|
|
575
|
+
throw new ResponseContractError();
|
|
576
|
+
}
|
|
577
|
+
return success("credits usage", { status: "succeeded", usage });
|
|
578
|
+
}
|
|
579
|
+
if (command.kind === "budget-get" || command.kind === "budget-set") {
|
|
580
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
581
|
+
const value =
|
|
582
|
+
command.kind === "budget-get"
|
|
583
|
+
? await api.getProjectCreditBudget(command.projectId)
|
|
584
|
+
: await api.setProjectCreditBudget(command);
|
|
585
|
+
return success(command.kind === "budget-get" ? "budget get" : "budget set", {
|
|
586
|
+
status: "succeeded",
|
|
587
|
+
budget: parseProjectCreditBudget(value),
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
if (command.kind === "organization-create") {
|
|
591
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
592
|
+
const organization = parseOrganization(
|
|
593
|
+
await api.createOrganization({
|
|
594
|
+
name: command.name,
|
|
595
|
+
...(command.signupSource === undefined ? {} : { signupSource: command.signupSource }),
|
|
596
|
+
idempotencyKey: command.idempotencyKey,
|
|
597
|
+
}),
|
|
598
|
+
);
|
|
599
|
+
// A created workspace is only usable once it is in the access token, so select it now.
|
|
600
|
+
const selection = await this.chooseAndStoreWorkspace(
|
|
601
|
+
await api.getAccountProfile(),
|
|
602
|
+
organization.id,
|
|
603
|
+
command.credentialStore,
|
|
604
|
+
signal,
|
|
605
|
+
);
|
|
606
|
+
if (selection.outcome !== "selected")
|
|
607
|
+
throw new ProductCliFailure(
|
|
608
|
+
2,
|
|
609
|
+
"organization_not_found",
|
|
610
|
+
true,
|
|
611
|
+
`The new workspace is not yet readable for this account. Run '${this.dependencies.commandPrefix} organization use --organization ${organization.id} --json' again.`,
|
|
612
|
+
);
|
|
613
|
+
return success("organization create", {
|
|
614
|
+
status: "succeeded",
|
|
615
|
+
organization,
|
|
616
|
+
selected: organization.id,
|
|
617
|
+
next_action: `Run '${this.dependencies.commandPrefix} whoami --json' to confirm the selected workspace, then create a project.`,
|
|
618
|
+
...(await this.githubNextAction(
|
|
619
|
+
await this.authenticatedApi(command.credentialStore, signal),
|
|
620
|
+
organization.id,
|
|
621
|
+
)),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (command.kind === "organization-list") {
|
|
625
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
626
|
+
const organizations = await api.getAccountProfile();
|
|
627
|
+
return success("organization list", {
|
|
628
|
+
status: "listed",
|
|
629
|
+
organizations: organizations.map(publicWorkspace),
|
|
630
|
+
selected: await selectedWorkspaceId(this.workspaceSession(command.credentialStore)),
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
if (command.kind === "organization-use") {
|
|
634
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
635
|
+
const selection = await this.chooseAndStoreWorkspace(
|
|
636
|
+
await api.getAccountProfile(),
|
|
637
|
+
command.organizationId,
|
|
638
|
+
command.credentialStore,
|
|
639
|
+
signal,
|
|
640
|
+
);
|
|
641
|
+
if (selection.outcome !== "selected") throw this.unknownWorkspace(selection);
|
|
642
|
+
return success("organization use", {
|
|
643
|
+
status: "selected",
|
|
644
|
+
organization: publicWorkspace(selection.organization),
|
|
645
|
+
selected: selection.organization.id,
|
|
646
|
+
...(await this.githubNextAction(
|
|
647
|
+
await this.authenticatedApi(command.credentialStore, signal),
|
|
648
|
+
selection.organization.id,
|
|
649
|
+
)),
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (command.kind === "operation-get") {
|
|
653
|
+
return await this.getOperation(command, signal);
|
|
654
|
+
}
|
|
655
|
+
if (command.kind === "operation-reconcile") {
|
|
656
|
+
return await this.reconcileOperation(command, signal);
|
|
657
|
+
}
|
|
658
|
+
if (command.kind === "export-create") {
|
|
659
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
660
|
+
const password = await this.dependencies.readSecretValue(signal);
|
|
661
|
+
if (password.trim().length === 0 || new TextEncoder().encode(password).length > 1024)
|
|
662
|
+
throw new SecretInputError();
|
|
663
|
+
const operation = parseOperation(
|
|
664
|
+
await api.createProjectExport({
|
|
665
|
+
projectId: command.projectId,
|
|
666
|
+
idempotencyKey: command.idempotencyKey,
|
|
667
|
+
password,
|
|
668
|
+
}),
|
|
669
|
+
);
|
|
670
|
+
return success("export create", { status: operation.state, operation });
|
|
671
|
+
}
|
|
672
|
+
if (command.kind === "export-get") {
|
|
673
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
674
|
+
const result = await api.getProjectExport({
|
|
675
|
+
projectId: command.projectId,
|
|
676
|
+
exportId: command.exportId,
|
|
677
|
+
});
|
|
678
|
+
assertProjectExport(result);
|
|
679
|
+
return success("export get", { status: result.state, export: result });
|
|
680
|
+
}
|
|
681
|
+
if (command.kind === "feedback-submit") {
|
|
682
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
683
|
+
return success("feedback submit", {
|
|
684
|
+
status: "succeeded",
|
|
685
|
+
feedback: await api.submitFeedback(command),
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
if (command.kind === "project-create") return await this.createProject(command, signal);
|
|
689
|
+
if (command.kind === "project-list") return await this.listProjects(command, signal);
|
|
690
|
+
if (command.kind === "database-compute-set") {
|
|
691
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
692
|
+
let operation = parseOperation(await api.changeDatabaseCompute(command));
|
|
693
|
+
if (command.wait) operation = await this.waitForOperation(api, operation, signal);
|
|
694
|
+
return success("database compute set", {
|
|
695
|
+
status: command.wait ? "completed" : "accepted",
|
|
696
|
+
operation,
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
if (command.kind === "database-compute-get") {
|
|
700
|
+
const api = await this.authenticatedApi(command.credentialStore, signal),
|
|
701
|
+
compute = await api.getDatabaseCompute(command);
|
|
702
|
+
assertDatabaseCompute(compute);
|
|
703
|
+
return success("database compute get", { status: "succeeded", compute });
|
|
704
|
+
}
|
|
705
|
+
if (command.kind === "project-context") {
|
|
706
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
707
|
+
const context = await api.getProjectContext(command.projectId);
|
|
708
|
+
assertProjectContext(context);
|
|
709
|
+
return success("project context", { status: "succeeded", context });
|
|
710
|
+
}
|
|
711
|
+
if (command.kind === "project-notes-set") {
|
|
712
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
713
|
+
const notes = await api.setProjectNotes(command);
|
|
714
|
+
assertProjectNotesReceipt(notes);
|
|
715
|
+
return success("project notes set", { status: "succeeded", notes });
|
|
716
|
+
}
|
|
717
|
+
if (command.kind === "project-status") return await this.getProjectStatus(command, signal);
|
|
718
|
+
if (command.kind === "project-dev-access-create")
|
|
719
|
+
return await this.createDevAccessTicket(command, signal);
|
|
720
|
+
if (command.kind === "database-write") {
|
|
721
|
+
const file = await open(command.statementFile, "r"),
|
|
722
|
+
buffer = Buffer.alloc(65_537);
|
|
723
|
+
let statement: string;
|
|
724
|
+
try {
|
|
725
|
+
const stat = await file.stat();
|
|
726
|
+
if (!stat.isFile() || stat.size > 65_536) throw new InvalidCommandError();
|
|
727
|
+
let length = 0;
|
|
728
|
+
while (length < buffer.length) {
|
|
729
|
+
const read = await file.read(buffer, length, buffer.length - length, null);
|
|
730
|
+
if (read.bytesRead === 0) break;
|
|
731
|
+
length += read.bytesRead;
|
|
732
|
+
}
|
|
733
|
+
if (length > 65_536) throw new InvalidCommandError();
|
|
734
|
+
statement = new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
|
|
735
|
+
} finally {
|
|
736
|
+
buffer.fill(0);
|
|
737
|
+
await file.close();
|
|
738
|
+
}
|
|
739
|
+
const body = parseProjectDatabaseWrite({
|
|
740
|
+
environment: command.environment,
|
|
741
|
+
statement,
|
|
742
|
+
parameters: command.parameters,
|
|
743
|
+
});
|
|
744
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
745
|
+
const result = await api.writeProjectDatabase({
|
|
746
|
+
...body,
|
|
747
|
+
projectId: command.projectId,
|
|
748
|
+
idempotencyKey: command.idempotencyKey,
|
|
749
|
+
});
|
|
750
|
+
assertProjectDatabaseWriteReceipt(result);
|
|
751
|
+
if (result.state === "failed")
|
|
752
|
+
return {
|
|
753
|
+
exitCode: 7,
|
|
754
|
+
stdout: `${JSON.stringify({ version: 1, command: "database write", status: "error", operation_id: result.operation_id, error: result.error })}\n`,
|
|
755
|
+
stderr: "",
|
|
756
|
+
};
|
|
757
|
+
return success("database write", {
|
|
758
|
+
status: result.state,
|
|
759
|
+
operation_id: result.operation_id,
|
|
760
|
+
result,
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
if (command.kind === "database-query")
|
|
764
|
+
return await this.queryProjectDatabase(command, signal);
|
|
765
|
+
if (command.kind === "database-access-create")
|
|
766
|
+
return await this.createDatabaseAccess(command, signal);
|
|
767
|
+
if (command.kind === "database-access-list")
|
|
768
|
+
return await this.listDatabaseAccess(command, signal);
|
|
769
|
+
if (command.kind === "database-access-revoke")
|
|
770
|
+
return await this.revokeDatabaseAccess(command, signal);
|
|
771
|
+
if (command.kind === "database-psql") return await this.runProjectPsql(command, signal);
|
|
772
|
+
if (command.kind === "link") return await this.linkSource(command, signal);
|
|
773
|
+
if (command.kind === "source-auto-deploy-set")
|
|
774
|
+
return await this.configureAutoDeploy(command, signal);
|
|
775
|
+
if (command.kind === "source-auto-deploy-status")
|
|
776
|
+
return await this.getAutoDeploy(command, signal);
|
|
777
|
+
if (command.kind === "domain-cloudflare-authorize")
|
|
778
|
+
return await this.authorizeCloudflareDns(command, signal);
|
|
779
|
+
if (command.kind === "domain-cloudflare-status")
|
|
780
|
+
return await this.getCloudflareDnsStatus(command, signal);
|
|
781
|
+
if (command.kind === "domain-cloudflare-apply")
|
|
782
|
+
return await this.applyProjectDomains(command, signal);
|
|
783
|
+
if (command.kind === "paid-domain-plan") return await this.planPaidDomain(command, signal);
|
|
784
|
+
if (command.kind === "paid-domain-apply") return await this.applyPaidDomain(command, signal);
|
|
785
|
+
if (command.kind === "paid-domain-status") return await this.getPaidDomain(command, signal);
|
|
786
|
+
if (command.kind === "paid-domain-delete")
|
|
787
|
+
return await this.deletePaidDomain(command, signal);
|
|
788
|
+
if (command.kind === "plan") return await this.planDeployment(command, signal);
|
|
789
|
+
if (command.kind === "deploy") return await this.deploy(command, signal);
|
|
790
|
+
if (command.kind === "logs") return await this.logs(command, signal);
|
|
791
|
+
if (command.kind === "deployment-logs") return await this.deploymentLogs(command, signal);
|
|
792
|
+
if (command.kind === "rollback-plan") return await this.planRollback(command, signal);
|
|
793
|
+
if (command.kind === "rollback") return await this.rollback(command, signal);
|
|
794
|
+
if (command.kind === "promotion-plan") return await this.planPromotion(command, signal);
|
|
795
|
+
if (command.kind === "promote") return await this.promote(command, signal);
|
|
796
|
+
if (command.kind === "delete-plan") return await this.planDelete(command, signal);
|
|
797
|
+
if (command.kind === "delete") return await this.deleteProject(command, signal);
|
|
798
|
+
if (command.kind === "secret-list") return await this.listEnvironmentSecrets(command, signal);
|
|
799
|
+
if (command.kind === "function-runs") return await this.listFunctionRuns(command, signal);
|
|
800
|
+
if (command.kind === "secret-set") return await this.putEnvironmentSecret(command, signal);
|
|
801
|
+
if (command.kind === "mail-domain-set")
|
|
802
|
+
return await this.configureMailDomain(command, signal);
|
|
803
|
+
if (command.kind === "mail-domain-status") return await this.getMailDomain(command, signal);
|
|
804
|
+
return await this.deleteEnvironmentSecret(command, signal);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
if (error instanceof UserTokenFileError)
|
|
807
|
+
return failure(name, 3, error.code, false, error.message);
|
|
808
|
+
if (error instanceof InteractiveSessionRequiredError)
|
|
809
|
+
return failure(
|
|
810
|
+
name,
|
|
811
|
+
2,
|
|
812
|
+
"interactive_login_required",
|
|
813
|
+
false,
|
|
814
|
+
`A user API token cannot create, list or select a workspace. Run '${this.dependencies.commandPrefix} login --json' in a process without OHMYHOST_TOKEN.`,
|
|
815
|
+
);
|
|
816
|
+
this.recordDiagnostic(diagnosticDescriptor(error, signal));
|
|
817
|
+
return classifyFailure(name, error, signal);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private githubConnectAction(organizationId: string): string {
|
|
822
|
+
return `Run '${this.dependencies.commandPrefix} github connect --organization ${organizationId} --idempotency-key KEY --json' once, then link repositories covered by the installation.`;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private async githubNextAction(
|
|
826
|
+
api: Pick<ProductApi, "getGithubConnection">,
|
|
827
|
+
organizationId: string,
|
|
828
|
+
): Promise<Readonly<Record<string, string>>> {
|
|
829
|
+
// Optional onboarding guidance must not turn a completed login into failure when
|
|
830
|
+
// a scoped key cannot read source connections or the status endpoint is unavailable.
|
|
831
|
+
try {
|
|
832
|
+
const github = parseGithubOrganizationConnectionStatus(
|
|
833
|
+
await api.getGithubConnection(organizationId),
|
|
834
|
+
);
|
|
835
|
+
if (github.connection !== null && github.connection.organization_id !== organizationId)
|
|
836
|
+
throw new ResponseContractError();
|
|
837
|
+
return github.status === "connected"
|
|
838
|
+
? {}
|
|
839
|
+
: { next_action: this.githubConnectAction(organizationId) };
|
|
840
|
+
} catch {
|
|
841
|
+
return {};
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private async getCurrentIdentity(
|
|
846
|
+
command: Extract<ProductCliCommand, { kind: "whoami" }>,
|
|
847
|
+
signal: AbortSignal,
|
|
848
|
+
): Promise<CliResult> {
|
|
849
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
850
|
+
const identity = parseCurrentIdentity(await api.getCurrentIdentity());
|
|
851
|
+
// A session signed in before a workspace could be selected carries none, and then every
|
|
852
|
+
// project call answers with an empty page that reads like an empty account. Repair it here,
|
|
853
|
+
// where an agent looks first, instead of leaving the emptiness to be misread later.
|
|
854
|
+
if (identity.organization_ids.length > 0 || this.dependencies.apiToken !== undefined)
|
|
855
|
+
return success("whoami", {
|
|
856
|
+
status: "authenticated",
|
|
857
|
+
identity,
|
|
858
|
+
...(identity.organization_ids.length === 1 && identity.organization_ids[0] !== undefined
|
|
859
|
+
? await this.githubNextAction(api, identity.organization_ids[0])
|
|
860
|
+
: {}),
|
|
861
|
+
});
|
|
862
|
+
const selection = await this.chooseAndStoreWorkspace(
|
|
863
|
+
await api.getAccountProfile(),
|
|
864
|
+
undefined,
|
|
865
|
+
command.credentialStore,
|
|
866
|
+
signal,
|
|
867
|
+
);
|
|
868
|
+
if (selection.outcome === "selected")
|
|
869
|
+
return success("whoami", {
|
|
870
|
+
status: "authenticated",
|
|
871
|
+
identity: parseCurrentIdentity(await api.getCurrentIdentity()),
|
|
872
|
+
organization: publicWorkspace(selection.organization),
|
|
873
|
+
selected: selection.organization.id,
|
|
874
|
+
...(await this.githubNextAction(api, selection.organization.id)),
|
|
875
|
+
});
|
|
876
|
+
return success("whoami", {
|
|
877
|
+
status: "authenticated",
|
|
878
|
+
identity,
|
|
879
|
+
organizations:
|
|
880
|
+
selection.outcome === "choice_required" ? selection.organizations.map(publicWorkspace) : [],
|
|
881
|
+
next_action:
|
|
882
|
+
selection.outcome === "choice_required"
|
|
883
|
+
? `Ask the customer which workspace to use, then run '${this.dependencies.commandPrefix} organization use --organization ULID --json'.`
|
|
884
|
+
: `Run '${this.dependencies.commandPrefix} organization create --name NAME --idempotency-key KEY --json' to create the first workspace.`,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private recordDiagnostic(
|
|
889
|
+
event: Omit<Parameters<DiagnosticSink["record"]>[0], "correlationId">,
|
|
890
|
+
): void {
|
|
891
|
+
try {
|
|
892
|
+
this.dependencies.diagnosticSink.record({
|
|
893
|
+
correlationId: this.dependencies.createCorrelationId(),
|
|
894
|
+
...event,
|
|
895
|
+
});
|
|
896
|
+
} catch {
|
|
897
|
+
// Diagnostics are best-effort and must never replace the stable command result.
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// The reviewed boundary proof parses this signature, so keep its parameters brace-free.
|
|
902
|
+
private async login(
|
|
903
|
+
credentialStore: "native",
|
|
904
|
+
organizationId: string | undefined,
|
|
905
|
+
signal: AbortSignal,
|
|
906
|
+
): Promise<CliResult> {
|
|
907
|
+
// A device code lives five minutes and the provider offers no way to lengthen it, so the
|
|
908
|
+
// sign-in outlives the code instead: while this window lasts, a lapsed code is replaced.
|
|
909
|
+
const signInDeadline = this.dependencies.now().getTime() + SIGN_IN_WINDOW_MS;
|
|
910
|
+
let session = await this.dependencies.deviceFlow.authorize();
|
|
911
|
+
await presentAuthorization(
|
|
912
|
+
this.dependencies.deviceAuthorizationPresenter,
|
|
913
|
+
session.authorization,
|
|
914
|
+
);
|
|
915
|
+
await this.dependencies.waitUntil(
|
|
916
|
+
new Date(this.dependencies.now().getTime() + session.authorization.intervalSeconds * 1_000),
|
|
917
|
+
signal,
|
|
918
|
+
);
|
|
919
|
+
for (;;) {
|
|
920
|
+
throwIfAborted(signal);
|
|
921
|
+
let result: DevicePollResult;
|
|
922
|
+
try {
|
|
923
|
+
result = await session.poll();
|
|
924
|
+
} catch (error) {
|
|
925
|
+
// A timer may fire a fraction before the instant it was given, and the session then
|
|
926
|
+
// refuses the poll. That is not a failed sign-in: wait out the interval and ask again,
|
|
927
|
+
// instead of telling the customer their authorization failed before anyone was asked.
|
|
928
|
+
if (!(error instanceof WorkosContractError)) throw error;
|
|
929
|
+
// The code aged out while the customer was still on their way. Nobody refused, so issue
|
|
930
|
+
// the next one and show it, rather than ending a sign-in that can still succeed.
|
|
931
|
+
if (error.code === "expired_token" && this.dependencies.now().getTime() < signInDeadline) {
|
|
932
|
+
session = await this.dependencies.deviceFlow.authorize();
|
|
933
|
+
await presentAuthorization(
|
|
934
|
+
this.dependencies.deviceAuthorizationPresenter,
|
|
935
|
+
session.authorization,
|
|
936
|
+
);
|
|
937
|
+
await this.dependencies.waitUntil(
|
|
938
|
+
new Date(
|
|
939
|
+
this.dependencies.now().getTime() + session.authorization.intervalSeconds * 1_000,
|
|
940
|
+
),
|
|
941
|
+
signal,
|
|
942
|
+
);
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (error.code !== "poll_too_soon") throw error;
|
|
946
|
+
await this.dependencies.waitUntil(
|
|
947
|
+
new Date(
|
|
948
|
+
this.dependencies.now().getTime() + session.authorization.intervalSeconds * 1_000,
|
|
949
|
+
),
|
|
950
|
+
signal,
|
|
951
|
+
);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
throwIfAborted(signal);
|
|
955
|
+
if (result.state === "pending") {
|
|
956
|
+
await this.dependencies.waitUntil(result.nextPollAt, signal);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
let expiresAt: string;
|
|
960
|
+
try {
|
|
961
|
+
expiresAt = resolveDeviceTokenExpiresAt(
|
|
962
|
+
result.tokens.accessToken,
|
|
963
|
+
result.tokens.expiresInSeconds,
|
|
964
|
+
this.dependencies.now(),
|
|
965
|
+
);
|
|
966
|
+
} catch (error) {
|
|
967
|
+
if (!(error instanceof DeviceTokenExpiryError)) throw error;
|
|
968
|
+
throw new ProductCliFailure(
|
|
969
|
+
4,
|
|
970
|
+
"device_token_expiry_missing",
|
|
971
|
+
false,
|
|
972
|
+
"Run login again; the authorization response was incomplete.",
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
await this.dependencies.credentialStore(credentialStore).replace({
|
|
976
|
+
accessToken: result.tokens.accessToken,
|
|
977
|
+
refreshToken: result.tokens.refreshToken,
|
|
978
|
+
expiresAt,
|
|
979
|
+
});
|
|
980
|
+
// The workspace lives in the access token, so signing in is only half of a usable login.
|
|
981
|
+
const profile = await this.dependencies.accountProfile(() =>
|
|
982
|
+
this.authenticatedToken(credentialStore, signal),
|
|
983
|
+
);
|
|
984
|
+
const selection = await this.chooseAndStoreWorkspace(
|
|
985
|
+
profile,
|
|
986
|
+
organizationId,
|
|
987
|
+
credentialStore,
|
|
988
|
+
signal,
|
|
989
|
+
);
|
|
990
|
+
const readGithubConnection = this.dependencies.githubConnectionStatus;
|
|
991
|
+
if (selection.outcome === "selected")
|
|
992
|
+
return success("login", {
|
|
993
|
+
status: "authenticated",
|
|
994
|
+
organization: publicWorkspace(selection.organization),
|
|
995
|
+
...(readGithubConnection === undefined
|
|
996
|
+
? {}
|
|
997
|
+
: await this.githubNextAction(
|
|
998
|
+
{
|
|
999
|
+
getGithubConnection: (selectedOrganizationId) =>
|
|
1000
|
+
readGithubConnection(
|
|
1001
|
+
() => this.authenticatedToken(credentialStore, signal),
|
|
1002
|
+
selectedOrganizationId,
|
|
1003
|
+
),
|
|
1004
|
+
},
|
|
1005
|
+
selection.organization.id,
|
|
1006
|
+
)),
|
|
1007
|
+
});
|
|
1008
|
+
if (selection.outcome === "not_a_member") throw this.unknownWorkspace(selection);
|
|
1009
|
+
return success("login", {
|
|
1010
|
+
status: "authenticated",
|
|
1011
|
+
organization: null,
|
|
1012
|
+
organizations:
|
|
1013
|
+
selection.outcome === "choice_required"
|
|
1014
|
+
? selection.organizations.map(publicWorkspace)
|
|
1015
|
+
: [],
|
|
1016
|
+
next_action:
|
|
1017
|
+
selection.outcome === "choice_required"
|
|
1018
|
+
? `Ask the customer which workspace to use, then run '${this.dependencies.commandPrefix} organization use --organization ULID --json'.`
|
|
1019
|
+
: `Run '${this.dependencies.commandPrefix} organization create --name NAME --idempotency-key KEY --json' to create the first workspace.`,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private workspaceSession(credentialStore: "native"): WorkspaceSession {
|
|
1025
|
+
return {
|
|
1026
|
+
store: this.dependencies.credentialStore(credentialStore),
|
|
1027
|
+
refresher: this.dependencies.tokenRefresher,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
/** Decide the workspace and, when the answer cannot be wrong, put it into the credential. */
|
|
1032
|
+
private async chooseAndStoreWorkspace(
|
|
1033
|
+
organizations: readonly PublicWorkspace[],
|
|
1034
|
+
requested: string | undefined,
|
|
1035
|
+
credentialStore: "native",
|
|
1036
|
+
signal: AbortSignal,
|
|
1037
|
+
): Promise<WorkspaceSelection> {
|
|
1038
|
+
const selection = chooseWorkspace(organizations, requested);
|
|
1039
|
+
if (selection.outcome === "selected")
|
|
1040
|
+
await runBounded(
|
|
1041
|
+
(bounded) =>
|
|
1042
|
+
selectWorkspace(this.workspaceSession(credentialStore), selection.organization, bounded),
|
|
1043
|
+
signal,
|
|
1044
|
+
this.dependencies.lifecycleTimeoutMs,
|
|
1045
|
+
);
|
|
1046
|
+
return selection;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
private unknownWorkspace(selection: WorkspaceSelection): ProductCliFailure {
|
|
1050
|
+
return new ProductCliFailure(
|
|
1051
|
+
2,
|
|
1052
|
+
"organization_not_found",
|
|
1053
|
+
false,
|
|
1054
|
+
`You are not a member of that workspace. Run '${this.dependencies.commandPrefix} organization list --json' and choose one of ${String(selection.outcome === "not_a_member" ? selection.organizations.length : 0)}.`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
private async logout(
|
|
1059
|
+
credentialStore: "native",
|
|
1060
|
+
revoke: boolean,
|
|
1061
|
+
signal: AbortSignal,
|
|
1062
|
+
): Promise<CliResult> {
|
|
1063
|
+
const store = this.dependencies.credentialStore(credentialStore);
|
|
1064
|
+
if (!revoke) {
|
|
1065
|
+
await store.clear();
|
|
1066
|
+
return success("logout", { status: "logged_out" });
|
|
1067
|
+
}
|
|
1068
|
+
const credential = await store.read();
|
|
1069
|
+
let revocationError: unknown;
|
|
1070
|
+
try {
|
|
1071
|
+
if (credential !== undefined) {
|
|
1072
|
+
await runBounded(
|
|
1073
|
+
(bounded) => this.dependencies.tokenRevoker.revoke(credential.refreshToken, bounded),
|
|
1074
|
+
signal,
|
|
1075
|
+
this.dependencies.lifecycleTimeoutMs,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
} catch (error) {
|
|
1079
|
+
revocationError = error;
|
|
1080
|
+
} finally {
|
|
1081
|
+
await store.clear();
|
|
1082
|
+
}
|
|
1083
|
+
if (revocationError !== undefined) {
|
|
1084
|
+
if (isCancellation(revocationError) && signal.aborted) throw revocationError;
|
|
1085
|
+
if (revocationError instanceof ProviderCapabilityBlockedError) {
|
|
1086
|
+
throw new ProductCliFailure(
|
|
1087
|
+
9,
|
|
1088
|
+
"blocked_prerequisite",
|
|
1089
|
+
false,
|
|
1090
|
+
"Remote token revocation is not available in this release.",
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
if (isTimeout(revocationError)) {
|
|
1094
|
+
throw new ProductCliFailure(
|
|
1095
|
+
8,
|
|
1096
|
+
"request_timeout",
|
|
1097
|
+
true,
|
|
1098
|
+
"Remote revocation timed out; local credentials were removed.",
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
if (revocationError instanceof LifecycleTransportError) {
|
|
1102
|
+
throw new ProductCliFailure(
|
|
1103
|
+
6,
|
|
1104
|
+
"token_revocation_failed",
|
|
1105
|
+
true,
|
|
1106
|
+
"Remote revocation failed; the local credential was removed.",
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
throw revocationError;
|
|
1110
|
+
}
|
|
1111
|
+
return success("logout", { status: "logged_out" });
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private async getOperation(
|
|
1115
|
+
command: Extract<ProductCliCommand, { kind: "operation-get" }>,
|
|
1116
|
+
signal: AbortSignal,
|
|
1117
|
+
): Promise<CliResult> {
|
|
1118
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1119
|
+
const operation = parseOperation(await api.getOperation(command.operationId));
|
|
1120
|
+
return success("operation get", { status: "succeeded", operation });
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private async reconcileOperation(
|
|
1124
|
+
command: Extract<ProductCliCommand, { kind: "operation-reconcile" }>,
|
|
1125
|
+
signal: AbortSignal,
|
|
1126
|
+
): Promise<CliResult> {
|
|
1127
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1128
|
+
const reconciliation = parseProviderReconciliationAttempt(
|
|
1129
|
+
await api.reconcileOperation({
|
|
1130
|
+
operationId: command.operationId,
|
|
1131
|
+
idempotencyKey: command.idempotencyKey,
|
|
1132
|
+
}),
|
|
1133
|
+
);
|
|
1134
|
+
return success("operation reconcile", { status: "accepted", reconciliation });
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
private async createProject(
|
|
1138
|
+
command: Extract<ProductCliCommand, { kind: "project-create" }>,
|
|
1139
|
+
signal: AbortSignal,
|
|
1140
|
+
): Promise<CliResult> {
|
|
1141
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1142
|
+
const operation = parseOperation(
|
|
1143
|
+
await api.createProject({
|
|
1144
|
+
organizationId: command.organizationId,
|
|
1145
|
+
name: command.name,
|
|
1146
|
+
...(command.dataMode === undefined ? {} : { dataMode: command.dataMode }),
|
|
1147
|
+
...(command.region === undefined ? {} : { region: command.region }),
|
|
1148
|
+
idempotencyKey: command.idempotencyKey,
|
|
1149
|
+
}),
|
|
1150
|
+
);
|
|
1151
|
+
return success("project create", { status: "accepted", operation });
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
private async listProjects(
|
|
1155
|
+
command: Extract<ProductCliCommand, { kind: "project-list" }>,
|
|
1156
|
+
signal: AbortSignal,
|
|
1157
|
+
): Promise<CliResult> {
|
|
1158
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1159
|
+
const projects = parseProjectPage(
|
|
1160
|
+
await api.listProjects({
|
|
1161
|
+
...(command.cursor === undefined ? {} : { cursor: command.cursor }),
|
|
1162
|
+
limit: command.limit,
|
|
1163
|
+
}),
|
|
1164
|
+
);
|
|
1165
|
+
return success("project list", { status: "succeeded", projects });
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
private async getProjectStatus(
|
|
1169
|
+
command: Extract<ProductCliCommand, { kind: "project-status" }>,
|
|
1170
|
+
signal: AbortSignal,
|
|
1171
|
+
): Promise<CliResult> {
|
|
1172
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1173
|
+
const project = parseProjectStatus(await api.getProjectStatus(command.projectId));
|
|
1174
|
+
return success("project status", { status: "succeeded", project });
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
private async createDevAccessTicket(
|
|
1178
|
+
command: Extract<ProductCliCommand, { kind: "project-dev-access-create" }>,
|
|
1179
|
+
signal: AbortSignal,
|
|
1180
|
+
): Promise<CliResult> {
|
|
1181
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1182
|
+
const ticket = parseDevAccessTicket(await api.createDevAccessTicket(command.projectId));
|
|
1183
|
+
return success("project dev-access create", { status: "succeeded", ticket });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
private async queryProjectDatabase(
|
|
1187
|
+
command: Extract<ProductCliCommand, { kind: "database-query" }>,
|
|
1188
|
+
signal: AbortSignal,
|
|
1189
|
+
): Promise<CliResult> {
|
|
1190
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1191
|
+
const result = parseProjectDatabaseQuery(
|
|
1192
|
+
await api.queryProjectDatabase({
|
|
1193
|
+
projectId: command.projectId,
|
|
1194
|
+
environment: command.environment,
|
|
1195
|
+
statement: command.statement,
|
|
1196
|
+
parameters: command.parameters,
|
|
1197
|
+
}),
|
|
1198
|
+
);
|
|
1199
|
+
return success("database query", { status: "succeeded", result });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** The credential crosses this process once; only its metadata is ever printed again. */
|
|
1203
|
+
private async createDatabaseAccess(
|
|
1204
|
+
command: Extract<ProductCliCommand, { kind: "database-access-create" }>,
|
|
1205
|
+
signal: AbortSignal,
|
|
1206
|
+
): Promise<CliResult> {
|
|
1207
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1208
|
+
const access = await api.createProjectDatabaseAccess({
|
|
1209
|
+
projectId: command.projectId,
|
|
1210
|
+
environment: command.environment,
|
|
1211
|
+
mode: command.mode,
|
|
1212
|
+
ttlSeconds: command.ttlSeconds,
|
|
1213
|
+
label: command.label,
|
|
1214
|
+
});
|
|
1215
|
+
assertProjectDatabaseAccessCredential(access);
|
|
1216
|
+
return success("database access create", { status: "succeeded", access });
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
private async listDatabaseAccess(
|
|
1220
|
+
command: Extract<ProductCliCommand, { kind: "database-access-list" }>,
|
|
1221
|
+
signal: AbortSignal,
|
|
1222
|
+
): Promise<CliResult> {
|
|
1223
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1224
|
+
const page = await api.listProjectDatabaseAccess({
|
|
1225
|
+
projectId: command.projectId,
|
|
1226
|
+
...(command.environment === undefined ? {} : { environment: command.environment }),
|
|
1227
|
+
});
|
|
1228
|
+
assertProjectDatabaseAccessPage(page);
|
|
1229
|
+
return success("database access list", { status: "succeeded", access: page.items });
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
private async revokeDatabaseAccess(
|
|
1233
|
+
command: Extract<ProductCliCommand, { kind: "database-access-revoke" }>,
|
|
1234
|
+
signal: AbortSignal,
|
|
1235
|
+
): Promise<CliResult> {
|
|
1236
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1237
|
+
const access = await api.revokeProjectDatabaseAccess({
|
|
1238
|
+
projectId: command.projectId,
|
|
1239
|
+
accessId: command.accessId,
|
|
1240
|
+
});
|
|
1241
|
+
assertProjectDatabaseAccess(access);
|
|
1242
|
+
return success("database access revoke", { status: "succeeded", access });
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
/** psql receives the password through its own environment; the URI is never printed. */
|
|
1246
|
+
private async runProjectPsql(
|
|
1247
|
+
command: Extract<ProductCliCommand, { kind: "database-psql" }>,
|
|
1248
|
+
signal: AbortSignal,
|
|
1249
|
+
): Promise<CliResult> {
|
|
1250
|
+
const spawnPsql = this.dependencies.spawnPsql;
|
|
1251
|
+
if (spawnPsql === undefined) return psqlUnavailable();
|
|
1252
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1253
|
+
const credential = await api.createProjectDatabaseAccess({
|
|
1254
|
+
projectId: command.projectId,
|
|
1255
|
+
environment: command.environment,
|
|
1256
|
+
mode: command.mode,
|
|
1257
|
+
ttlSeconds: command.ttlSeconds,
|
|
1258
|
+
label: "psql",
|
|
1259
|
+
});
|
|
1260
|
+
assertProjectDatabaseAccessCredential(credential);
|
|
1261
|
+
const { connection_uri, psql_command, ...access } = credential;
|
|
1262
|
+
void connection_uri;
|
|
1263
|
+
void psql_command;
|
|
1264
|
+
let exitCode: number;
|
|
1265
|
+
try {
|
|
1266
|
+
exitCode = await spawnPsql({
|
|
1267
|
+
host: credential.host,
|
|
1268
|
+
user: credential.role_name,
|
|
1269
|
+
password: new URL(credential.connection_uri).password,
|
|
1270
|
+
database: credential.database,
|
|
1271
|
+
mode: credential.mode,
|
|
1272
|
+
signal,
|
|
1273
|
+
});
|
|
1274
|
+
} catch (error) {
|
|
1275
|
+
await this.revokeIssuedAccess(api, command.projectId, credential.access_id);
|
|
1276
|
+
if (error instanceof PsqlUnavailableError) return psqlUnavailable();
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
1279
|
+
await this.revokeIssuedAccess(api, command.projectId, credential.access_id);
|
|
1280
|
+
if (exitCode === 0)
|
|
1281
|
+
return success("database psql", { status: "succeeded", access, exit_code: 0 });
|
|
1282
|
+
return {
|
|
1283
|
+
exitCode,
|
|
1284
|
+
stdout: `${JSON.stringify({
|
|
1285
|
+
version: 1,
|
|
1286
|
+
command: "database psql",
|
|
1287
|
+
status: "error",
|
|
1288
|
+
error: {
|
|
1289
|
+
code: "psql_failed",
|
|
1290
|
+
retryable: false,
|
|
1291
|
+
suggested_action:
|
|
1292
|
+
"Read the psql output above. The temporary credential was revoked; request a new one with 'ohmyhost database access create'.",
|
|
1293
|
+
},
|
|
1294
|
+
})}\n`,
|
|
1295
|
+
stderr: "",
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
private async revokeIssuedAccess(
|
|
1300
|
+
api: ProductApi,
|
|
1301
|
+
projectId: string,
|
|
1302
|
+
accessId: string,
|
|
1303
|
+
): Promise<void> {
|
|
1304
|
+
try {
|
|
1305
|
+
await api.revokeProjectDatabaseAccess({ projectId, accessId });
|
|
1306
|
+
} catch {
|
|
1307
|
+
// The credential expires on its own; a failed revoke must not hide the psql result.
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private async planPaidDomain(
|
|
1312
|
+
command: Extract<ProductCliCommand, { kind: "paid-domain-plan" }>,
|
|
1313
|
+
signal: AbortSignal,
|
|
1314
|
+
): Promise<CliResult> {
|
|
1315
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1316
|
+
const plan = parsePaidDomainPlan(
|
|
1317
|
+
await api.planPaidDomain({ projectId: command.projectId, hostname: command.hostname }),
|
|
1318
|
+
);
|
|
1319
|
+
return success("domain paid plan", { status: "planned", plan });
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private async applyPaidDomain(
|
|
1323
|
+
command: Extract<ProductCliCommand, { kind: "paid-domain-apply" }>,
|
|
1324
|
+
signal: AbortSignal,
|
|
1325
|
+
): Promise<CliResult> {
|
|
1326
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1327
|
+
const domain = parsePaidDomain(
|
|
1328
|
+
await api.applyPaidDomain({
|
|
1329
|
+
projectId: command.projectId,
|
|
1330
|
+
hostname: command.hostname,
|
|
1331
|
+
idempotencyKey: command.idempotencyKey,
|
|
1332
|
+
}),
|
|
1333
|
+
);
|
|
1334
|
+
return success("domain paid apply", { status: domain.status, domain });
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
private async getPaidDomain(
|
|
1338
|
+
command: Extract<ProductCliCommand, { kind: "paid-domain-status" }>,
|
|
1339
|
+
signal: AbortSignal,
|
|
1340
|
+
): Promise<CliResult> {
|
|
1341
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1342
|
+
const domain = parsePaidDomain(await api.getPaidDomain(command.projectId));
|
|
1343
|
+
return success("domain paid status", { status: domain.status, domain });
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
private async deletePaidDomain(
|
|
1347
|
+
command: Extract<ProductCliCommand, { kind: "paid-domain-delete" }>,
|
|
1348
|
+
signal: AbortSignal,
|
|
1349
|
+
): Promise<CliResult> {
|
|
1350
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1351
|
+
const domain = parsePaidDomain(
|
|
1352
|
+
await api.deletePaidDomain({
|
|
1353
|
+
projectId: command.projectId,
|
|
1354
|
+
hostname: command.hostname,
|
|
1355
|
+
idempotencyKey: command.idempotencyKey,
|
|
1356
|
+
}),
|
|
1357
|
+
);
|
|
1358
|
+
return success("domain paid delete", { status: domain.status, domain });
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
private async linkSource(
|
|
1362
|
+
command: Extract<ProductCliCommand, { kind: "link" }>,
|
|
1363
|
+
signal: AbortSignal,
|
|
1364
|
+
): Promise<CliResult> {
|
|
1365
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1366
|
+
const accepted = parseOperation(await api.linkSource(command));
|
|
1367
|
+
const operation = await this.waitForOperation(api, accepted, signal);
|
|
1368
|
+
const project = await api.getProjectStatus(command.projectId);
|
|
1369
|
+
parseProjectStatus(project);
|
|
1370
|
+
const source = project.source;
|
|
1371
|
+
if (
|
|
1372
|
+
project.project_id !== command.projectId ||
|
|
1373
|
+
source === null ||
|
|
1374
|
+
source.status !== "ready" ||
|
|
1375
|
+
source.repository_full_name.toLowerCase() !==
|
|
1376
|
+
`${command.repositoryOwner}/${command.repositoryName}`.toLowerCase()
|
|
1377
|
+
)
|
|
1378
|
+
throw new ResponseContractError();
|
|
1379
|
+
await this.dependencies.projectLinkStore.save({
|
|
1380
|
+
version: 1,
|
|
1381
|
+
api_origin: this.dependencies.productApiOrigin,
|
|
1382
|
+
project_id: command.projectId,
|
|
1383
|
+
installation_id: source.installation_id,
|
|
1384
|
+
repository_full_name: source.repository_full_name,
|
|
1385
|
+
});
|
|
1386
|
+
return success("link", { status: "completed", operation });
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
private async configureAutoDeploy(
|
|
1390
|
+
command: Extract<ProductCliCommand, { kind: "source-auto-deploy-set" }>,
|
|
1391
|
+
signal: AbortSignal,
|
|
1392
|
+
): Promise<CliResult> {
|
|
1393
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1394
|
+
const autoDeploy = await api.configureAutoDeploy(command);
|
|
1395
|
+
return success("source auto-deploy set", { status: "configured", auto_deploy: autoDeploy });
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
private async getAutoDeploy(
|
|
1399
|
+
command: Extract<ProductCliCommand, { kind: "source-auto-deploy-status" }>,
|
|
1400
|
+
signal: AbortSignal,
|
|
1401
|
+
): Promise<CliResult> {
|
|
1402
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1403
|
+
const autoDeploy = await api.getAutoDeploy(command.projectId);
|
|
1404
|
+
return success("source auto-deploy status", { status: "ready", auto_deploy: autoDeploy });
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
private async authorizeCloudflareDns(
|
|
1408
|
+
command: Extract<ProductCliCommand, { kind: "domain-cloudflare-authorize" }>,
|
|
1409
|
+
signal: AbortSignal,
|
|
1410
|
+
): Promise<CliResult> {
|
|
1411
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1412
|
+
const authorization = parseCloudflareDnsAuthorization(
|
|
1413
|
+
await api.authorizeCloudflareDns({
|
|
1414
|
+
projectId: command.projectId,
|
|
1415
|
+
zone: command.zone,
|
|
1416
|
+
idempotencyKey: command.idempotencyKey,
|
|
1417
|
+
}),
|
|
1418
|
+
);
|
|
1419
|
+
return success("domain cloudflare authorize", {
|
|
1420
|
+
status: "authorization_required",
|
|
1421
|
+
authorization,
|
|
1422
|
+
});
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
private async getCloudflareDnsStatus(
|
|
1426
|
+
command: Extract<ProductCliCommand, { kind: "domain-cloudflare-status" }>,
|
|
1427
|
+
signal: AbortSignal,
|
|
1428
|
+
): Promise<CliResult> {
|
|
1429
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1430
|
+
const cloudflareDns = parseCloudflareDnsAuthorizationStatus(
|
|
1431
|
+
await api.getCloudflareDnsStatus(command.projectId),
|
|
1432
|
+
);
|
|
1433
|
+
return success("domain cloudflare status", {
|
|
1434
|
+
status: cloudflareDns.status,
|
|
1435
|
+
cloudflare_dns: cloudflareDns,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
private async applyProjectDomains(
|
|
1440
|
+
command: Extract<ProductCliCommand, { kind: "domain-cloudflare-apply" }>,
|
|
1441
|
+
signal: AbortSignal,
|
|
1442
|
+
): Promise<CliResult> {
|
|
1443
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1444
|
+
const accepted = parseOperation(
|
|
1445
|
+
await api.applyProjectDomains({
|
|
1446
|
+
projectId: command.projectId,
|
|
1447
|
+
idempotencyKey: command.idempotencyKey,
|
|
1448
|
+
}),
|
|
1449
|
+
);
|
|
1450
|
+
const operation = await this.waitForOperation(api, accepted, signal);
|
|
1451
|
+
return success("domain cloudflare apply", { status: "completed", operation });
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
private async planDeployment(
|
|
1455
|
+
command: Extract<ProductCliCommand, { kind: "plan" }>,
|
|
1456
|
+
signal: AbortSignal,
|
|
1457
|
+
): Promise<CliResult> {
|
|
1458
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1459
|
+
const plan = parseDeploymentPlan(await api.planDeployment(command));
|
|
1460
|
+
return success("plan", { status: "planned", plan });
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
private async deploy(
|
|
1464
|
+
command: Extract<ProductCliCommand, { kind: "deploy" }>,
|
|
1465
|
+
signal: AbortSignal,
|
|
1466
|
+
): Promise<CliResult> {
|
|
1467
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1468
|
+
let operation = parseOperation(await api.createDeployment(command));
|
|
1469
|
+
if (command.wait) operation = await this.waitForOperation(api, operation, signal);
|
|
1470
|
+
// A healthy Dev deployment answers 404 on its own origin until a ticket is redeemed. Without
|
|
1471
|
+
// this sentence the first thing a customer sees after a successful deploy looks like a failure.
|
|
1472
|
+
const notes =
|
|
1473
|
+
operation.state === "succeeded"
|
|
1474
|
+
? [
|
|
1475
|
+
`A Dev URL answers 404 until you redeem an access ticket: run '${this.dependencies.commandPrefix} project dev-access create --project ULID --json' and open its redeem_url once.`,
|
|
1476
|
+
]
|
|
1477
|
+
: [];
|
|
1478
|
+
return success("deploy", {
|
|
1479
|
+
status: command.wait ? "completed" : "accepted",
|
|
1480
|
+
operation,
|
|
1481
|
+
...(notes.length > 0 ? { notes } : {}),
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
private async logs(
|
|
1486
|
+
command: Extract<ProductCliCommand, { kind: "logs" }>,
|
|
1487
|
+
signal: AbortSignal,
|
|
1488
|
+
): Promise<CliResult> {
|
|
1489
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1490
|
+
let eventCount = 0;
|
|
1491
|
+
for await (const event of await api.streamOperationEvents(command.operationId, signal)) {
|
|
1492
|
+
const parsed = parseOperationEvent(event);
|
|
1493
|
+
this.dependencies.operationEventSink?.write(`${JSON.stringify(parsed)}\n`);
|
|
1494
|
+
eventCount += 1;
|
|
1495
|
+
if (
|
|
1496
|
+
parsed["type"] === "OperationSucceeded" ||
|
|
1497
|
+
parsed["type"] === "OperationFailed" ||
|
|
1498
|
+
parsed["type"] === "OperationCancelled"
|
|
1499
|
+
) {
|
|
1500
|
+
break;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
return success("logs", { status: "completed", event_count: eventCount });
|
|
1504
|
+
}
|
|
1505
|
+
private async deploymentLogs(
|
|
1506
|
+
command: Extract<ProductCliCommand, { kind: "deployment-logs" }>,
|
|
1507
|
+
signal: AbortSignal,
|
|
1508
|
+
): Promise<CliResult> {
|
|
1509
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1510
|
+
let count = 0;
|
|
1511
|
+
for await (const event of await api.streamDeploymentLogs(
|
|
1512
|
+
{ projectId: command.projectId, deploymentId: command.deploymentId },
|
|
1513
|
+
signal,
|
|
1514
|
+
)) {
|
|
1515
|
+
this.dependencies.operationEventSink?.write(`${JSON.stringify(event)}\n`);
|
|
1516
|
+
count += 1;
|
|
1517
|
+
}
|
|
1518
|
+
return success("deployment logs", { status: "completed", event_count: count });
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
private async planRollback(
|
|
1522
|
+
command: Extract<ProductCliCommand, { kind: "rollback-plan" }>,
|
|
1523
|
+
signal: AbortSignal,
|
|
1524
|
+
): Promise<CliResult> {
|
|
1525
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1526
|
+
return success("rollback plan", {
|
|
1527
|
+
status: "planned",
|
|
1528
|
+
plan: parseGuardedActionPlan(await api.planRollback(command)),
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
private async rollback(
|
|
1533
|
+
command: Extract<ProductCliCommand, { kind: "rollback" }>,
|
|
1534
|
+
signal: AbortSignal,
|
|
1535
|
+
): Promise<CliResult> {
|
|
1536
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1537
|
+
const operation = parseOperation(await api.rollback(command));
|
|
1538
|
+
return success("rollback", { status: "accepted", operation });
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
private async planPromotion(
|
|
1542
|
+
command: Extract<ProductCliCommand, { kind: "promotion-plan" }>,
|
|
1543
|
+
signal: AbortSignal,
|
|
1544
|
+
): Promise<CliResult> {
|
|
1545
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1546
|
+
return success("deployment promote plan", {
|
|
1547
|
+
status: "planned",
|
|
1548
|
+
plan: parseGuardedActionPlan(await api.planPromotion(command)),
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
private async promote(
|
|
1553
|
+
command: Extract<ProductCliCommand, { kind: "promote" }>,
|
|
1554
|
+
signal: AbortSignal,
|
|
1555
|
+
): Promise<CliResult> {
|
|
1556
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1557
|
+
let operation = parseOperation(await api.promote(command));
|
|
1558
|
+
if (command.wait) operation = await this.waitForOperation(api, operation, signal);
|
|
1559
|
+
return success("deployment promote", {
|
|
1560
|
+
status: command.wait ? "completed" : "accepted",
|
|
1561
|
+
operation,
|
|
1562
|
+
});
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private async planDelete(
|
|
1566
|
+
command: Extract<ProductCliCommand, { kind: "delete-plan" }>,
|
|
1567
|
+
signal: AbortSignal,
|
|
1568
|
+
): Promise<CliResult> {
|
|
1569
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1570
|
+
return success("delete plan", {
|
|
1571
|
+
status: "planned",
|
|
1572
|
+
plan: parseGuardedActionPlan(await api.planDelete(command.projectId)),
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
private async deleteProject(
|
|
1577
|
+
command: Extract<ProductCliCommand, { kind: "delete" }>,
|
|
1578
|
+
signal: AbortSignal,
|
|
1579
|
+
): Promise<CliResult> {
|
|
1580
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1581
|
+
const operation = parseOperation(await api.deleteProject(command));
|
|
1582
|
+
return success("delete", { status: "accepted", operation });
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
private async listEnvironmentSecrets(
|
|
1586
|
+
command: Extract<ProductCliCommand, { kind: "secret-list" }>,
|
|
1587
|
+
signal: AbortSignal,
|
|
1588
|
+
): Promise<CliResult> {
|
|
1589
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1590
|
+
const page = parseEnvironmentSecretPage(
|
|
1591
|
+
await api.listEnvironmentSecrets({
|
|
1592
|
+
projectId: command.projectId,
|
|
1593
|
+
environmentId: command.environmentId,
|
|
1594
|
+
}),
|
|
1595
|
+
);
|
|
1596
|
+
return success("secret list", { status: "listed", secrets: page.items });
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1599
|
+
private async listFunctionRuns(
|
|
1600
|
+
command: Extract<ProductCliCommand, { kind: "function-runs" }>,
|
|
1601
|
+
signal: AbortSignal,
|
|
1602
|
+
): Promise<CliResult> {
|
|
1603
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1604
|
+
const page = parseFunctionRunPage(
|
|
1605
|
+
await api.listFunctionRuns({
|
|
1606
|
+
projectId: command.projectId,
|
|
1607
|
+
environmentId: command.environmentId,
|
|
1608
|
+
limit: command.limit,
|
|
1609
|
+
}),
|
|
1610
|
+
);
|
|
1611
|
+
return success("function runs", { status: "listed", runs: page.items });
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
private async putEnvironmentSecret(
|
|
1615
|
+
command: Extract<ProductCliCommand, { kind: "secret-set" }>,
|
|
1616
|
+
signal: AbortSignal,
|
|
1617
|
+
): Promise<CliResult> {
|
|
1618
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1619
|
+
const value = await this.dependencies.readSecretValue(signal);
|
|
1620
|
+
const valueBytes = new TextEncoder().encode(value);
|
|
1621
|
+
if (valueBytes.byteLength === 0 || valueBytes.byteLength > 5_120) {
|
|
1622
|
+
valueBytes.fill(0);
|
|
1623
|
+
throw new SecretInputError();
|
|
1624
|
+
}
|
|
1625
|
+
try {
|
|
1626
|
+
const secret = parseEnvironmentSecret(
|
|
1627
|
+
await api.putEnvironmentSecret({
|
|
1628
|
+
projectId: command.projectId,
|
|
1629
|
+
environmentId: command.environmentId,
|
|
1630
|
+
name: command.name,
|
|
1631
|
+
value,
|
|
1632
|
+
idempotencyKey: command.idempotencyKey,
|
|
1633
|
+
}),
|
|
1634
|
+
);
|
|
1635
|
+
const completed = command.wait
|
|
1636
|
+
? await this.waitForSecretDelivery(
|
|
1637
|
+
api,
|
|
1638
|
+
command.projectId,
|
|
1639
|
+
command.environmentId,
|
|
1640
|
+
secret,
|
|
1641
|
+
signal,
|
|
1642
|
+
)
|
|
1643
|
+
: secret;
|
|
1644
|
+
return success("secret set", {
|
|
1645
|
+
status: command.wait ? "ready" : "stored",
|
|
1646
|
+
secret: completed,
|
|
1647
|
+
});
|
|
1648
|
+
} finally {
|
|
1649
|
+
valueBytes.fill(0);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
private async deleteEnvironmentSecret(
|
|
1654
|
+
command: Extract<ProductCliCommand, { kind: "secret-delete" }>,
|
|
1655
|
+
signal: AbortSignal,
|
|
1656
|
+
): Promise<CliResult> {
|
|
1657
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1658
|
+
const deleted = parseEnvironmentSecretDeletion(
|
|
1659
|
+
await api.deleteEnvironmentSecret({
|
|
1660
|
+
projectId: command.projectId,
|
|
1661
|
+
environmentId: command.environmentId,
|
|
1662
|
+
name: command.name,
|
|
1663
|
+
idempotencyKey: command.idempotencyKey,
|
|
1664
|
+
}),
|
|
1665
|
+
);
|
|
1666
|
+
const delivery = command.wait
|
|
1667
|
+
? await this.waitForSecretDelivery(
|
|
1668
|
+
api,
|
|
1669
|
+
command.projectId,
|
|
1670
|
+
command.environmentId,
|
|
1671
|
+
{ desired_generation: deleted.desired_generation },
|
|
1672
|
+
signal,
|
|
1673
|
+
)
|
|
1674
|
+
: deleted;
|
|
1675
|
+
return success("secret delete", {
|
|
1676
|
+
status: command.wait ? "ready" : deleted.status,
|
|
1677
|
+
secret: { name: deleted.name, ...secretDeliveryProjection(delivery) },
|
|
1678
|
+
});
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
private async waitForSecretDelivery(
|
|
1682
|
+
api: ProductApi,
|
|
1683
|
+
projectId: string,
|
|
1684
|
+
environmentId: string,
|
|
1685
|
+
stored: Readonly<{ desired_generation: number }> | PublicEnvironmentSecret,
|
|
1686
|
+
signal: AbortSignal,
|
|
1687
|
+
) {
|
|
1688
|
+
const desiredGeneration = stored.desired_generation;
|
|
1689
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
1690
|
+
const page = parseEnvironmentSecretPage(
|
|
1691
|
+
await api.listEnvironmentSecrets({ projectId, environmentId }),
|
|
1692
|
+
);
|
|
1693
|
+
if (page.last_error !== null || page.delivery_state === "failed") {
|
|
1694
|
+
throw new ProductCliFailure(
|
|
1695
|
+
10,
|
|
1696
|
+
"secret_delivery_failed",
|
|
1697
|
+
true,
|
|
1698
|
+
"Inspect secret metadata and deployment logs, then retry reconciliation.",
|
|
1699
|
+
);
|
|
1700
|
+
}
|
|
1701
|
+
if (
|
|
1702
|
+
page.delivery_state === "ready" &&
|
|
1703
|
+
page.desired_generation >= desiredGeneration &&
|
|
1704
|
+
page.applied_generation >= desiredGeneration
|
|
1705
|
+
) {
|
|
1706
|
+
// Every item carries the environment's generations; only the item with the stored
|
|
1707
|
+
// secret's name describes the secret that was just set. A deletion has no item left
|
|
1708
|
+
// and reads the environment delivery state instead.
|
|
1709
|
+
if (!("name" in stored)) return page;
|
|
1710
|
+
return (
|
|
1711
|
+
page.items.find((secret) => secret.name === stored.name) ?? {
|
|
1712
|
+
...stored,
|
|
1713
|
+
desired_generation: page.desired_generation,
|
|
1714
|
+
applied_generation: page.applied_generation,
|
|
1715
|
+
delivery_state: page.delivery_state,
|
|
1716
|
+
}
|
|
1717
|
+
);
|
|
1718
|
+
}
|
|
1719
|
+
await this.dependencies.waitUntil(
|
|
1720
|
+
new Date(this.dependencies.now().getTime() + 1_000),
|
|
1721
|
+
signal,
|
|
1722
|
+
);
|
|
1723
|
+
}
|
|
1724
|
+
throw new ProductCliFailure(
|
|
1725
|
+
8,
|
|
1726
|
+
"secret_delivery_wait_timeout",
|
|
1727
|
+
true,
|
|
1728
|
+
"Run 'ohmyhost secret list --json' to inspect delivery metadata.",
|
|
1729
|
+
);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
private async configureMailDomain(
|
|
1733
|
+
command: Extract<ProductCliCommand, { kind: "mail-domain-set" }>,
|
|
1734
|
+
signal: AbortSignal,
|
|
1735
|
+
): Promise<CliResult> {
|
|
1736
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1737
|
+
const mailDomain = parseProjectMailDomain(
|
|
1738
|
+
await api.configureMailDomain({
|
|
1739
|
+
projectId: command.projectId,
|
|
1740
|
+
domain: command.domain,
|
|
1741
|
+
idempotencyKey: command.idempotencyKey,
|
|
1742
|
+
}),
|
|
1743
|
+
);
|
|
1744
|
+
return success("mail domain set", { status: mailDomain.status, mail_domain: mailDomain });
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
private async getMailDomain(
|
|
1748
|
+
command: Extract<ProductCliCommand, { kind: "mail-domain-status" }>,
|
|
1749
|
+
signal: AbortSignal,
|
|
1750
|
+
): Promise<CliResult> {
|
|
1751
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1752
|
+
const mailDomain = parseProjectMailDomain(await api.getMailDomain(command.projectId));
|
|
1753
|
+
return success("mail domain status", { status: mailDomain.status, mail_domain: mailDomain });
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
private async waitForOperation(
|
|
1757
|
+
api: ProductApi,
|
|
1758
|
+
initial: ReturnType<typeof parseOperation>,
|
|
1759
|
+
signal: AbortSignal,
|
|
1760
|
+
): Promise<ReturnType<typeof parseOperation>> {
|
|
1761
|
+
let operation = initial;
|
|
1762
|
+
for (
|
|
1763
|
+
let attempt = 0;
|
|
1764
|
+
attempt < 120 &&
|
|
1765
|
+
(operation.state === "queued" || operation.state === "running") &&
|
|
1766
|
+
operation.reconciliation?.state !== "required";
|
|
1767
|
+
attempt += 1
|
|
1768
|
+
) {
|
|
1769
|
+
await this.dependencies.waitUntil(
|
|
1770
|
+
new Date(this.dependencies.now().getTime() + 1_000),
|
|
1771
|
+
signal,
|
|
1772
|
+
);
|
|
1773
|
+
operation = parseOperation(await api.getOperation(operation.id));
|
|
1774
|
+
}
|
|
1775
|
+
if (operation.reconciliation?.state === "required")
|
|
1776
|
+
throw new ProductCliFailure(
|
|
1777
|
+
10,
|
|
1778
|
+
"operation_reconciliation_required",
|
|
1779
|
+
false,
|
|
1780
|
+
`${operation.reconciliation.suggested_action} Inspect 'ohmyhost operation get ${operation.id} --json' and use 'ohmyhost operation reconcile' after confirmation.`,
|
|
1781
|
+
operation,
|
|
1782
|
+
);
|
|
1783
|
+
if (operation.state === "queued" || operation.state === "running")
|
|
1784
|
+
throw new ProductCliFailure(
|
|
1785
|
+
8,
|
|
1786
|
+
"operation_wait_timeout",
|
|
1787
|
+
true,
|
|
1788
|
+
`Run 'ohmyhost operation get ${operation.id} --json' to inspect the existing operation before resubmitting a mutation.`,
|
|
1789
|
+
operation,
|
|
1790
|
+
);
|
|
1791
|
+
if (operation.state === "failed" && operation.error !== undefined)
|
|
1792
|
+
throw new ProductCliFailure(
|
|
1793
|
+
10,
|
|
1794
|
+
operation.error.code,
|
|
1795
|
+
operation.error.retryable,
|
|
1796
|
+
operation.error.suggested_action,
|
|
1797
|
+
operation,
|
|
1798
|
+
);
|
|
1799
|
+
if (operation.state === "cancelled")
|
|
1800
|
+
throw new ProductCliFailure(
|
|
1801
|
+
10,
|
|
1802
|
+
"operation_not_succeeded",
|
|
1803
|
+
false,
|
|
1804
|
+
`The operation was cancelled. Inspect 'ohmyhost operation get ${operation.id} --json' before creating a new plan.`,
|
|
1805
|
+
operation,
|
|
1806
|
+
);
|
|
1807
|
+
return operation;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
private async authenticatedApi(
|
|
1811
|
+
credentialStore: "native",
|
|
1812
|
+
signal: AbortSignal,
|
|
1813
|
+
): Promise<ProductApi> {
|
|
1814
|
+
await this.authenticatedToken(credentialStore, signal);
|
|
1815
|
+
return this.dependencies.productApi(() => this.authenticatedToken(credentialStore, signal));
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
private async authenticatedToken(
|
|
1819
|
+
credentialStore: "native",
|
|
1820
|
+
signal: AbortSignal,
|
|
1821
|
+
): Promise<string> {
|
|
1822
|
+
if (this.dependencies.apiToken !== undefined) return this.dependencies.apiToken;
|
|
1823
|
+
const store = this.dependencies.credentialStore(credentialStore);
|
|
1824
|
+
const stored = await store.read();
|
|
1825
|
+
if (stored === undefined) {
|
|
1826
|
+
throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
|
|
1827
|
+
}
|
|
1828
|
+
let refresh: { readonly credential: CurrentCredential; readonly refreshed: boolean };
|
|
1829
|
+
try {
|
|
1830
|
+
refresh = await this.refreshIfExpired(stored, signal);
|
|
1831
|
+
} catch (error) {
|
|
1832
|
+
if (!(error instanceof TokenRefreshRejectedError)) throw error;
|
|
1833
|
+
await store.clear();
|
|
1834
|
+
throw new ProductCliFailure(5, "authentication_required", false, "Run 'ohmyhost login'.");
|
|
1835
|
+
}
|
|
1836
|
+
if (refresh.refreshed) await store.replace(refresh.credential);
|
|
1837
|
+
return refresh.credential.accessToken;
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
private async refreshIfExpired(
|
|
1841
|
+
credential: StoredCredential,
|
|
1842
|
+
signal: AbortSignal,
|
|
1843
|
+
): Promise<{ readonly credential: CurrentCredential; readonly refreshed: boolean }> {
|
|
1844
|
+
const expiresAt = credential.expiresAt === undefined ? NaN : Date.parse(credential.expiresAt);
|
|
1845
|
+
if (
|
|
1846
|
+
Number.isFinite(expiresAt) &&
|
|
1847
|
+
expiresAt > this.dependencies.now().getTime() + this.dependencies.lifecycleTimeoutMs
|
|
1848
|
+
) {
|
|
1849
|
+
return {
|
|
1850
|
+
credential: {
|
|
1851
|
+
accessToken: credential.accessToken,
|
|
1852
|
+
refreshToken: credential.refreshToken,
|
|
1853
|
+
expiresAt: credential.expiresAt as string,
|
|
1854
|
+
...workspaceOf(credential),
|
|
1855
|
+
},
|
|
1856
|
+
refreshed: false,
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
try {
|
|
1860
|
+
const refreshed = await runBounded(
|
|
1861
|
+
(bounded) =>
|
|
1862
|
+
this.dependencies.tokenRefresher.refresh(
|
|
1863
|
+
credential.refreshToken,
|
|
1864
|
+
bounded,
|
|
1865
|
+
credential.workosOrganizationId,
|
|
1866
|
+
),
|
|
1867
|
+
signal,
|
|
1868
|
+
this.dependencies.lifecycleTimeoutMs,
|
|
1869
|
+
);
|
|
1870
|
+
const refreshedExpiry = Date.parse(refreshed.expiresAt);
|
|
1871
|
+
if (
|
|
1872
|
+
!Number.isFinite(refreshedExpiry) ||
|
|
1873
|
+
refreshedExpiry <= this.dependencies.now().getTime()
|
|
1874
|
+
) {
|
|
1875
|
+
throw new Error("invalid refreshed expiry");
|
|
1876
|
+
}
|
|
1877
|
+
// WorkOS returns a bare credential; the selection only survives if we carry it forward.
|
|
1878
|
+
// WorkOS returns a bare credential; the selection only survives if we carry it forward.
|
|
1879
|
+
return { credential: { ...refreshed, ...workspaceOf(credential) }, refreshed: true };
|
|
1880
|
+
} catch (error) {
|
|
1881
|
+
if (isCancellation(error) && signal.aborted) throw error;
|
|
1882
|
+
if (error instanceof ProviderCapabilityBlockedError) {
|
|
1883
|
+
throw new ProductCliFailure(
|
|
1884
|
+
9,
|
|
1885
|
+
"blocked_prerequisite",
|
|
1886
|
+
false,
|
|
1887
|
+
"Token refresh is not available in this release; run 'ohmyhost login'.",
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
if (isTimeout(error)) {
|
|
1891
|
+
throw new ProductCliFailure(8, "request_timeout", true, "Retry the command.");
|
|
1892
|
+
}
|
|
1893
|
+
if (error instanceof LifecycleTransportError) {
|
|
1894
|
+
throw new ProductCliFailure(
|
|
1895
|
+
5,
|
|
1896
|
+
"token_refresh_failed",
|
|
1897
|
+
true,
|
|
1898
|
+
"Run 'ohmyhost login' if retrying does not succeed.",
|
|
1899
|
+
);
|
|
1900
|
+
}
|
|
1901
|
+
throw error;
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function secretDeliveryProjection(value: {
|
|
1907
|
+
readonly desired_generation: number;
|
|
1908
|
+
readonly applied_generation: number;
|
|
1909
|
+
readonly delivery_state: "not_deployed" | "pending" | "ready" | "failed";
|
|
1910
|
+
readonly runtime_provider: "wfp" | null;
|
|
1911
|
+
readonly applied_secret_count: number;
|
|
1912
|
+
readonly last_error: string | null;
|
|
1913
|
+
}) {
|
|
1914
|
+
return Object.freeze({
|
|
1915
|
+
desired_generation: value.desired_generation,
|
|
1916
|
+
applied_generation: value.applied_generation,
|
|
1917
|
+
delivery_state: value.delivery_state,
|
|
1918
|
+
runtime_provider: value.runtime_provider,
|
|
1919
|
+
applied_secret_count: value.applied_secret_count,
|
|
1920
|
+
last_error: value.last_error,
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const SIGN_IN_WINDOW_MS = 30 * 60 * 1_000;
|
|
1925
|
+
|
|
1926
|
+
const presentAuthorization = async (
|
|
1927
|
+
presenter: DeviceAuthorizationPresenter,
|
|
1928
|
+
authorization: DeviceAuthorization,
|
|
1929
|
+
): Promise<void> =>
|
|
1930
|
+
presenter.present({
|
|
1931
|
+
verificationUri: redactVerificationUri(authorization.verificationUri),
|
|
1932
|
+
// One click instead of typing the code; absent when the provider omits it.
|
|
1933
|
+
...(authorization.verificationUriComplete === undefined
|
|
1934
|
+
? {}
|
|
1935
|
+
: { verificationUriComplete: authorization.verificationUriComplete }),
|
|
1936
|
+
userCode: authorization.userCode,
|
|
1937
|
+
expiresInSeconds: authorization.expiresInSeconds,
|
|
1938
|
+
});
|
|
1939
|
+
|
|
1940
|
+
const redactVerificationUri = (value: string): string => {
|
|
1941
|
+
const url = new URL(value);
|
|
1942
|
+
url.search = "";
|
|
1943
|
+
url.hash = "";
|
|
1944
|
+
return url.toString();
|
|
1945
|
+
};
|
|
1946
|
+
|
|
1947
|
+
const classifyFailure = (command: CommandLabel, error: unknown, signal: AbortSignal): CliResult => {
|
|
1948
|
+
if (signal.aborted || isCancellation(error)) {
|
|
1949
|
+
return failure(command, 130, "command_cancelled", true, "Run the command again.");
|
|
1950
|
+
}
|
|
1951
|
+
if (isTimeout(error)) {
|
|
1952
|
+
return failure(command, 8, "request_timeout", true, "Retry the command.");
|
|
1953
|
+
}
|
|
1954
|
+
if (error instanceof ProductCliFailure) {
|
|
1955
|
+
return failure(
|
|
1956
|
+
command,
|
|
1957
|
+
error.exitCode,
|
|
1958
|
+
error.code,
|
|
1959
|
+
error.retryable,
|
|
1960
|
+
error.suggestedAction,
|
|
1961
|
+
error.operation,
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
if (error instanceof SecretInputError) {
|
|
1965
|
+
return failure(
|
|
1966
|
+
command,
|
|
1967
|
+
2,
|
|
1968
|
+
"secret_input_invalid",
|
|
1969
|
+
false,
|
|
1970
|
+
"Pipe a non-empty UTF-8 value of at most 5120 bytes through standard input.",
|
|
1971
|
+
);
|
|
1972
|
+
}
|
|
1973
|
+
if (error instanceof WorkosContractError) {
|
|
1974
|
+
if (error.code === "access_denied") {
|
|
1975
|
+
return failure(command, 4, "device_authorization_denied", false, "Run login again.");
|
|
1976
|
+
}
|
|
1977
|
+
if (error.code === "expired_token") {
|
|
1978
|
+
return failure(command, 4, "device_authorization_expired", false, "Run login again.");
|
|
1979
|
+
}
|
|
1980
|
+
return failure(command, 4, "device_authorization_failed", true, "Run login again.");
|
|
1981
|
+
}
|
|
1982
|
+
if (error instanceof CredentialStoreUnavailableError) {
|
|
1983
|
+
return failure(
|
|
1984
|
+
command,
|
|
1985
|
+
3,
|
|
1986
|
+
"credential_store_unavailable",
|
|
1987
|
+
true,
|
|
1988
|
+
"Unlock or repair the selected credential store and retry.",
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
if (error instanceof ResponseContractError) {
|
|
1992
|
+
return failure(
|
|
1993
|
+
command,
|
|
1994
|
+
7,
|
|
1995
|
+
"response_contract_invalid",
|
|
1996
|
+
false,
|
|
1997
|
+
"Retry only after the client or server contract is repaired.",
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
if (error instanceof PublicResponseTooLargeError) {
|
|
2001
|
+
return failure(
|
|
2002
|
+
command,
|
|
2003
|
+
7,
|
|
2004
|
+
"response_too_large",
|
|
2005
|
+
false,
|
|
2006
|
+
"Retry only after the remote response is repaired.",
|
|
2007
|
+
);
|
|
2008
|
+
}
|
|
2009
|
+
if (looksLikeProblem(error)) {
|
|
2010
|
+
try {
|
|
2011
|
+
const problem = parseSafeProblem(error);
|
|
2012
|
+
return failure(
|
|
2013
|
+
command,
|
|
2014
|
+
7,
|
|
2015
|
+
problem.code,
|
|
2016
|
+
problem.retryable,
|
|
2017
|
+
problem.suggestedAction,
|
|
2018
|
+
undefined,
|
|
2019
|
+
problem.retryAfterSeconds,
|
|
2020
|
+
);
|
|
2021
|
+
} catch {
|
|
2022
|
+
return failure(
|
|
2023
|
+
command,
|
|
2024
|
+
7,
|
|
2025
|
+
"response_contract_invalid",
|
|
2026
|
+
false,
|
|
2027
|
+
"Retry only after the client or server contract is repaired.",
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
if (looksLikeRemoteObject(error)) {
|
|
2032
|
+
return failure(
|
|
2033
|
+
command,
|
|
2034
|
+
7,
|
|
2035
|
+
"response_contract_invalid",
|
|
2036
|
+
false,
|
|
2037
|
+
"Retry only after the client or server contract is repaired.",
|
|
2038
|
+
);
|
|
2039
|
+
}
|
|
2040
|
+
if (error instanceof NetworkTransportError) {
|
|
2041
|
+
return failure(command, 8, "network_error", true, "Check connectivity and retry.");
|
|
2042
|
+
}
|
|
2043
|
+
return failure(command, 1, "internal_error", false, "Retry the command.");
|
|
2044
|
+
};
|
|
2045
|
+
|
|
2046
|
+
const psqlUnavailable = (): CliResult =>
|
|
2047
|
+
failure(
|
|
2048
|
+
"database psql",
|
|
2049
|
+
9,
|
|
2050
|
+
"psql_unavailable",
|
|
2051
|
+
false,
|
|
2052
|
+
"Install the PostgreSQL client (psql) and make it available on PATH, or run 'ohmyhost database access create' and connect with your own SQL client.",
|
|
2053
|
+
);
|
|
2054
|
+
|
|
2055
|
+
const success = (command: CommandLabel, body: Readonly<Record<string, unknown>>): CliResult => ({
|
|
2056
|
+
exitCode: 0,
|
|
2057
|
+
stdout: `${JSON.stringify({ version: 1, command, ...body })}\n`,
|
|
2058
|
+
stderr: "",
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
const failure = (
|
|
2062
|
+
command: CommandLabel,
|
|
2063
|
+
exitCode: number,
|
|
2064
|
+
code: string,
|
|
2065
|
+
retryable: boolean,
|
|
2066
|
+
suggestedAction: string,
|
|
2067
|
+
operation?: ReturnType<typeof parseOperation>,
|
|
2068
|
+
retryAfterSeconds?: number,
|
|
2069
|
+
): CliResult => {
|
|
2070
|
+
const document: CliErrorDocument = {
|
|
2071
|
+
version: 1,
|
|
2072
|
+
command,
|
|
2073
|
+
status: "error",
|
|
2074
|
+
...(operation === undefined ? {} : { operation_id: operation.id }),
|
|
2075
|
+
error: {
|
|
2076
|
+
code,
|
|
2077
|
+
retryable,
|
|
2078
|
+
suggested_action: suggestedAction,
|
|
2079
|
+
...(retryAfterSeconds === undefined ? {} : { retry_after_seconds: retryAfterSeconds }),
|
|
2080
|
+
...(operation?.error === undefined ? {} : { message: operation.error.message }),
|
|
2081
|
+
},
|
|
2082
|
+
};
|
|
2083
|
+
return { exitCode, stdout: `${JSON.stringify(document)}\n`, stderr: "" };
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
|
|
2087
|
+
if (command.kind === "export-create") return "export create";
|
|
2088
|
+
if (command.kind === "export-get") return "export get";
|
|
2089
|
+
if (command.kind === "token-create") return "token create";
|
|
2090
|
+
if (command.kind === "token-list") return "token list";
|
|
2091
|
+
if (command.kind === "token-revoke") return "token revoke";
|
|
2092
|
+
if (command.kind === "billing-recharge-get") return "billing recharge get";
|
|
2093
|
+
if (command.kind === "billing-recharge-set") return "billing recharge set";
|
|
2094
|
+
if (command.kind === "billing-checkout") return "billing checkout";
|
|
2095
|
+
if (command.kind === "billing-status") return "billing status";
|
|
2096
|
+
if (command.kind === "billing-portal") return "billing portal";
|
|
2097
|
+
if (command.kind === "credits-account") return "credits account";
|
|
2098
|
+
if (command.kind === "credits-balance") return "credits balance";
|
|
2099
|
+
if (command.kind === "credits-usage") return "credits usage";
|
|
2100
|
+
if (command.kind === "budget-get") return "budget get";
|
|
2101
|
+
if (command.kind === "budget-set") return "budget set";
|
|
2102
|
+
if (command.kind === "operation-get") return "operation get";
|
|
2103
|
+
if (command.kind === "operation-reconcile") return "operation reconcile";
|
|
2104
|
+
if (command.kind === "feedback-submit") return "feedback submit";
|
|
2105
|
+
if (command.kind === "project-create") return "project create";
|
|
2106
|
+
if (command.kind === "organization-create") return "organization create";
|
|
2107
|
+
if (command.kind === "organization-list") return "organization list";
|
|
2108
|
+
if (command.kind === "organization-use") return "organization use";
|
|
2109
|
+
if (command.kind === "github-connect") return "github connect";
|
|
2110
|
+
if (command.kind === "github-status") return "github status";
|
|
2111
|
+
if (command.kind === "project-list") return "project list";
|
|
2112
|
+
if (command.kind === "database-compute-set") return "database compute set";
|
|
2113
|
+
if (command.kind === "database-compute-get") return "database compute get";
|
|
2114
|
+
if (command.kind === "project-context") return "project context";
|
|
2115
|
+
if (command.kind === "project-notes-set") return "project notes set";
|
|
2116
|
+
if (command.kind === "project-status") return "project status";
|
|
2117
|
+
if (command.kind === "project-dev-access-create") return "project dev-access create";
|
|
2118
|
+
if (command.kind === "database-write") return "database write";
|
|
2119
|
+
if (command.kind === "database-query") return "database query";
|
|
2120
|
+
if (command.kind === "database-access-create") return "database access create";
|
|
2121
|
+
if (command.kind === "database-access-list") return "database access list";
|
|
2122
|
+
if (command.kind === "database-access-revoke") return "database access revoke";
|
|
2123
|
+
if (command.kind === "database-psql") return "database psql";
|
|
2124
|
+
if (command.kind === "source-auto-deploy-set") return "source auto-deploy set";
|
|
2125
|
+
if (command.kind === "source-auto-deploy-status") return "source auto-deploy status";
|
|
2126
|
+
if (command.kind === "domain-cloudflare-authorize") return "domain cloudflare authorize";
|
|
2127
|
+
if (command.kind === "domain-cloudflare-status") return "domain cloudflare status";
|
|
2128
|
+
if (command.kind === "domain-cloudflare-apply") return "domain cloudflare apply";
|
|
2129
|
+
if (command.kind === "paid-domain-plan") return "domain paid plan";
|
|
2130
|
+
if (command.kind === "paid-domain-apply") return "domain paid apply";
|
|
2131
|
+
if (command.kind === "paid-domain-status") return "domain paid status";
|
|
2132
|
+
if (command.kind === "paid-domain-delete") return "domain paid delete";
|
|
2133
|
+
if (command.kind === "rollback-plan") return "rollback plan";
|
|
2134
|
+
if (command.kind === "promotion-plan") return "deployment promote plan";
|
|
2135
|
+
if (command.kind === "deployment-logs") return "deployment logs";
|
|
2136
|
+
if (command.kind === "promote") return "deployment promote";
|
|
2137
|
+
if (command.kind === "delete-plan") return "delete plan";
|
|
2138
|
+
if (command.kind === "secret-list") return "secret list";
|
|
2139
|
+
if (command.kind === "function-runs") return "function runs";
|
|
2140
|
+
if (command.kind === "secret-set") return "secret set";
|
|
2141
|
+
if (command.kind === "secret-delete") return "secret delete";
|
|
2142
|
+
if (command.kind === "mail-domain-set") return "mail domain set";
|
|
2143
|
+
if (command.kind === "mail-domain-status") return "mail domain status";
|
|
2144
|
+
return command.kind;
|
|
2145
|
+
};
|
|
2146
|
+
|
|
2147
|
+
const boundedSignal = (parent: AbortSignal, timeoutMs: number): AbortSignal =>
|
|
2148
|
+
AbortSignal.any([parent, AbortSignal.timeout(timeoutMs)]);
|
|
2149
|
+
|
|
2150
|
+
const runBounded = async <T>(
|
|
2151
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
2152
|
+
parent: AbortSignal,
|
|
2153
|
+
timeoutMs: number,
|
|
2154
|
+
): Promise<T> => {
|
|
2155
|
+
const signal = boundedSignal(parent, timeoutMs);
|
|
2156
|
+
return new Promise<T>((resolve, reject) => {
|
|
2157
|
+
const cleanup = (): void => signal.removeEventListener("abort", abort);
|
|
2158
|
+
const abort = (): void => {
|
|
2159
|
+
cleanup();
|
|
2160
|
+
reject(signal.reason);
|
|
2161
|
+
};
|
|
2162
|
+
if (signal.aborted) {
|
|
2163
|
+
abort();
|
|
2164
|
+
return;
|
|
2165
|
+
}
|
|
2166
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
2167
|
+
operation(signal).then(
|
|
2168
|
+
(value) => {
|
|
2169
|
+
cleanup();
|
|
2170
|
+
resolve(value);
|
|
2171
|
+
},
|
|
2172
|
+
(error: unknown) => {
|
|
2173
|
+
cleanup();
|
|
2174
|
+
reject(error);
|
|
2175
|
+
},
|
|
2176
|
+
);
|
|
2177
|
+
});
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
const throwIfAborted = (signal: AbortSignal): void => {
|
|
2181
|
+
if (signal.aborted) throw signal.reason;
|
|
2182
|
+
};
|
|
2183
|
+
|
|
2184
|
+
const isCancellation = (error: unknown): boolean =>
|
|
2185
|
+
error instanceof DOMException && error.name === "AbortError";
|
|
2186
|
+
|
|
2187
|
+
const isTimeout = (error: unknown): boolean =>
|
|
2188
|
+
error instanceof DOMException && error.name === "TimeoutError";
|
|
2189
|
+
|
|
2190
|
+
const looksLikeProblem = (error: unknown): boolean =>
|
|
2191
|
+
typeof error === "object" &&
|
|
2192
|
+
error !== null &&
|
|
2193
|
+
("code" in error || "status" in error || "suggested_action" in error);
|
|
2194
|
+
|
|
2195
|
+
const looksLikeRemoteObject = (error: unknown): boolean =>
|
|
2196
|
+
typeof error === "object" && error !== null && !(error instanceof Error);
|
|
2197
|
+
|
|
2198
|
+
const diagnosticDescriptor = (
|
|
2199
|
+
error: unknown,
|
|
2200
|
+
signal: AbortSignal,
|
|
2201
|
+
): Omit<Parameters<DiagnosticSink["record"]>[0], "correlationId"> => {
|
|
2202
|
+
if (signal.aborted || isCancellation(error)) {
|
|
2203
|
+
return { category: "cancelled", subsystem: "runtime", errorKind: "cancelled" };
|
|
2204
|
+
}
|
|
2205
|
+
if (isTimeout(error)) {
|
|
2206
|
+
return { category: "timeout", subsystem: "runtime", errorKind: "timeout" };
|
|
2207
|
+
}
|
|
2208
|
+
if (error instanceof CredentialStoreUnavailableError) {
|
|
2209
|
+
return {
|
|
2210
|
+
category: "credential_store",
|
|
2211
|
+
subsystem: "credential_store",
|
|
2212
|
+
errorKind: "unavailable",
|
|
2213
|
+
};
|
|
2214
|
+
}
|
|
2215
|
+
if (error instanceof SecretInputError) {
|
|
2216
|
+
return { category: "configuration", subsystem: "runtime", errorKind: "invalid_response" };
|
|
2217
|
+
}
|
|
2218
|
+
if (error instanceof WorkosContractError) {
|
|
2219
|
+
return {
|
|
2220
|
+
category: "device_authorization",
|
|
2221
|
+
subsystem: "authentication",
|
|
2222
|
+
errorKind: "invalid_response",
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
if (error instanceof ResponseContractError || error instanceof PublicResponseTooLargeError) {
|
|
2226
|
+
return { category: "response_contract", subsystem: "sdk", errorKind: "invalid_response" };
|
|
2227
|
+
}
|
|
2228
|
+
if (error instanceof NetworkTransportError) {
|
|
2229
|
+
return { category: "network", subsystem: "sdk", errorKind: "network_transport" };
|
|
2230
|
+
}
|
|
2231
|
+
if (
|
|
2232
|
+
error instanceof ProviderCapabilityBlockedError ||
|
|
2233
|
+
(error instanceof ProductCliFailure && error.code === "blocked_prerequisite")
|
|
2234
|
+
) {
|
|
2235
|
+
return { category: "provider_blocked", subsystem: "lifecycle", errorKind: "blocked" };
|
|
2236
|
+
}
|
|
2237
|
+
if (error instanceof ProductCliFailure) {
|
|
2238
|
+
if (error.code === "request_timeout") {
|
|
2239
|
+
return { category: "timeout", subsystem: "lifecycle", errorKind: "timeout" };
|
|
2240
|
+
}
|
|
2241
|
+
if (error.code === "token_refresh_failed" || error.code === "token_revocation_failed") {
|
|
2242
|
+
return { category: "network", subsystem: "lifecycle", errorKind: "network_transport" };
|
|
2243
|
+
}
|
|
2244
|
+
if (error.code === "device_token_expiry_missing") {
|
|
2245
|
+
return {
|
|
2246
|
+
category: "response_contract",
|
|
2247
|
+
subsystem: "authentication",
|
|
2248
|
+
errorKind: "invalid_response",
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
if (error.code === "authentication_required") {
|
|
2252
|
+
return {
|
|
2253
|
+
category: "credential_store",
|
|
2254
|
+
subsystem: "credential_store",
|
|
2255
|
+
errorKind: "unavailable",
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
if (looksLikeProblem(error)) {
|
|
2260
|
+
try {
|
|
2261
|
+
parseSafeProblem(error);
|
|
2262
|
+
return { category: "remote_problem", subsystem: "sdk", errorKind: "remote_problem" };
|
|
2263
|
+
} catch {
|
|
2264
|
+
return { category: "response_contract", subsystem: "sdk", errorKind: "invalid_response" };
|
|
2265
|
+
}
|
|
2266
|
+
}
|
|
2267
|
+
if (looksLikeRemoteObject(error)) {
|
|
2268
|
+
return { category: "response_contract", subsystem: "sdk", errorKind: "invalid_response" };
|
|
2269
|
+
}
|
|
2270
|
+
return { category: "internal", subsystem: "runtime", errorKind: "unexpected" };
|
|
2271
|
+
};
|