@amerged/ohmyhost-cli 0.1.40 → 0.1.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +1 -1
- package/dist/{chunk-MRBUGDRW.js → chunk-HBM2X2MV.js} +1626 -259
- package/dist/index.js +5 -3
- package/dist/licenses/pgcf-bridge-NOTICE +1 -1
- package/package.json +1 -1
- package/src/apps/product-cli/package.json +1 -1
- package/src/apps/product-cli/src/cli.ts +440 -40
- package/src/apps/product-cli/src/command.ts +492 -19
- package/src/apps/product-cli/src/composition.ts +6 -3
- package/src/apps/product-cli/src/product-api.ts +366 -13
- package/src/apps/product-cli/src/runtime-contract.ts +1 -10
- package/src/packages/contracts/package.json +5 -0
- package/src/packages/contracts/src/credit-pricing.ts +78 -50
- package/src/packages/contracts/src/database-access.ts +24 -45
- package/src/packages/contracts/src/database-events.ts +697 -0
- package/src/packages/contracts/src/feedback.ts +0 -5
- package/src/packages/contracts/src/problem-policy.ts +28 -0
- package/src/packages/postgres-bridge/NOTICE +1 -1
- package/src/packages/postgres-bridge/src/bridge.ts +0 -4
- package/src/packages/sdk-ts/package.json +1 -1
- package/src/packages/sdk-ts/src/generated/index.ts +109 -0
- package/src/packages/sdk-ts/src/generated/sdk.gen.ts +702 -10
- package/src/packages/sdk-ts/src/generated/types.gen.ts +1205 -49
|
@@ -28,6 +28,11 @@ import {
|
|
|
28
28
|
} from "@ohmyhost/contracts/database-access";
|
|
29
29
|
import { assertOrganizationAccount } from "@ohmyhost/contracts/credit-pricing";
|
|
30
30
|
import { assertDatabaseCompute } from "@ohmyhost/contracts/database-compute";
|
|
31
|
+
import {
|
|
32
|
+
parseDatabaseScheduleSetRequest,
|
|
33
|
+
parseDatabaseWebhookCreateRequest,
|
|
34
|
+
type DatabaseScheduleSetRequest,
|
|
35
|
+
} from "@ohmyhost/contracts/database-events";
|
|
31
36
|
import cliPackage from "../package.json" with { type: "json" };
|
|
32
37
|
import { isUserSecretName } from "@ohmyhost/contracts/secret-names";
|
|
33
38
|
import {
|
|
@@ -281,7 +286,7 @@ export interface ProductCliDependencies {
|
|
|
281
286
|
readonly operationEventSink?: { write(value: string): void };
|
|
282
287
|
readonly readSecretValue: (signal: AbortSignal) => Promise<string>;
|
|
283
288
|
readonly spawnPsql?: (input: {
|
|
284
|
-
readonly websocketUrl
|
|
289
|
+
readonly websocketUrl: string;
|
|
285
290
|
readonly host: string;
|
|
286
291
|
readonly user: string;
|
|
287
292
|
readonly password: string;
|
|
@@ -351,7 +356,7 @@ const HELP_USAGE = Object.freeze({
|
|
|
351
356
|
"billing portal":
|
|
352
357
|
"ohmyhost billing portal --organization ULID --json (short-lived human URL; request fresh after expiry)",
|
|
353
358
|
"credits usage":
|
|
354
|
-
"ohmyhost credits usage --organization ULID --
|
|
359
|
+
"ohmyhost credits usage --organization ULID --json (billed usage of the last up to 14 UTC days by day, project and meter, and each project's use this UTC month)",
|
|
355
360
|
"budget get": "ohmyhost budget get --project ULID --json",
|
|
356
361
|
"budget set":
|
|
357
362
|
"ohmyhost budget set --project ULID --credits NUMBER|none [--mode continue|stop] --idempotency-key KEY --json",
|
|
@@ -414,6 +419,34 @@ const HELP_USAGE = Object.freeze({
|
|
|
414
419
|
"ohmyhost database access list --project ULID [--environment dev|prod] --json",
|
|
415
420
|
"database access revoke":
|
|
416
421
|
"ohmyhost database access revoke --project ULID --access ULID --yes --json",
|
|
422
|
+
"database webhook create":
|
|
423
|
+
"ohmyhost database webhook create --project ULID --environment dev|prod --url HTTPS_URL --event-types TYPE[,TYPE] [--headers-file PATH] --idempotency-key KEY --yes --json (the signing secret is shown once in this answer; types: database.created, database.ready, database.woke, database.slept, database.suspended, database.resized, database.deleted; the headers file is a UTF-8 JSON object of at most 8 KiB)",
|
|
424
|
+
"database webhook list":
|
|
425
|
+
"ohmyhost database webhook list --project ULID [--environment dev|prod] --json (dev when omitted; the secret is never listed)",
|
|
426
|
+
"database webhook get":
|
|
427
|
+
"ohmyhost database webhook get --project ULID [--environment dev|prod] --webhook ID --json",
|
|
428
|
+
"database webhook delete":
|
|
429
|
+
"ohmyhost database webhook delete --project ULID --environment dev|prod --webhook ID --yes --json",
|
|
430
|
+
"database webhook test":
|
|
431
|
+
"ohmyhost database webhook test --project ULID --environment dev|prod --webhook ID --yes --json (sends one signed test request to the receiver; nothing is stored)",
|
|
432
|
+
"database webhook deliveries":
|
|
433
|
+
"ohmyhost database webhook deliveries --project ULID [--environment dev|prod] [--webhook ID] [--limit 1..100] [--cursor CURSOR] --json",
|
|
434
|
+
"database webhook replay":
|
|
435
|
+
"ohmyhost database webhook replay --project ULID --environment dev|prod --delivery ID --yes --json",
|
|
436
|
+
"database schedule set":
|
|
437
|
+
"ohmyhost database schedule set --project ULID --environment dev|prod --name NAME (--cron EXPR | --every-seconds N) --statements-file PATH [--timeout-ms N] [--enabled true|false] --yes --json (runs the statements in the file, a UTF-8 JSON array of 1 to 20 SQL strings, as the runtime role of the application; cron has five fields in UTC; every-seconds is 60 to 2678400; timeout-ms is 1000 to 30000, default 10000)",
|
|
438
|
+
"database schedule list":
|
|
439
|
+
"ohmyhost database schedule list --project ULID [--environment dev|prod] --json (dev when omitted)",
|
|
440
|
+
"database schedule get":
|
|
441
|
+
"ohmyhost database schedule get --project ULID [--environment dev|prod] --name NAME --json",
|
|
442
|
+
"database schedule delete":
|
|
443
|
+
"ohmyhost database schedule delete --project ULID --environment dev|prod --name NAME --yes --json",
|
|
444
|
+
"database schedule runs":
|
|
445
|
+
"ohmyhost database schedule runs --project ULID [--environment dev|prod] --name NAME [--limit 1..50] --json",
|
|
446
|
+
"database schedule run":
|
|
447
|
+
"ohmyhost database schedule run --project ULID --environment dev|prod --name NAME --yes --json (queues one run now, in addition to the timer)",
|
|
448
|
+
"database executions":
|
|
449
|
+
"ohmyhost database executions --project ULID [--environment dev|prod] --from ISO --to ISO --granularity hour|day [--limit 1..100] [--cursor CURSOR] --json (UTC timestamps like 2026-10-30T00:00:00.000Z aligned to the granularity, at most 31 days; counts schedule runs, webhook attempts and events per hour or day; usage counters only, nothing is charged for them until the operator prices them)",
|
|
417
450
|
"database psql":
|
|
418
451
|
"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)",
|
|
419
452
|
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)",
|
|
@@ -528,6 +561,20 @@ const HELP_COMMANDS = Object.freeze(
|
|
|
528
561
|
Object.entries(HELP_USAGE).map(([command, usage]) => Object.freeze({ command, usage })),
|
|
529
562
|
);
|
|
530
563
|
|
|
564
|
+
/**
|
|
565
|
+
* The entries a help topic selects: an exact command name, or else every command of a group such as
|
|
566
|
+
* `database`. An exact name wins, so `source publish` does not list `source publish complete`.
|
|
567
|
+
*/
|
|
568
|
+
const helpEntries = (topic: string | undefined) => {
|
|
569
|
+
if (topic === undefined) return HELP_COMMANDS;
|
|
570
|
+
const exact = HELP_COMMANDS.filter((entry) => entry.command === topic);
|
|
571
|
+
return exact.length > 0
|
|
572
|
+
? exact
|
|
573
|
+
: HELP_COMMANDS.filter((entry) => entry.command.startsWith(`${topic} `));
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
export const helpTopicKnown = (topic: string): boolean => helpEntries(topic).length > 0;
|
|
577
|
+
|
|
531
578
|
export const cliMetadataResult = (kind: "help" | "version", topic?: string): CliResult =>
|
|
532
579
|
kind === "help"
|
|
533
580
|
? success("help", {
|
|
@@ -536,7 +583,7 @@ export const cliMetadataResult = (kind: "help" | "version", topic?: string): Cli
|
|
|
536
583
|
usage: "ohmyhost <command> [options] [--profile-name NAME] --json",
|
|
537
584
|
environment:
|
|
538
585
|
"OHMYHOST_ENVIRONMENT=development|production (default: production); OHMYHOST_PROFILE=NAME binds this process to one saved login",
|
|
539
|
-
commands:
|
|
586
|
+
commands: helpEntries(topic),
|
|
540
587
|
})
|
|
541
588
|
: success("version", { status: "succeeded", cli_version: CLI_VERSION });
|
|
542
589
|
|
|
@@ -569,23 +616,49 @@ export const invalidCommandAction = (argv: readonly string[], commandPrefix: str
|
|
|
569
616
|
}`;
|
|
570
617
|
};
|
|
571
618
|
|
|
619
|
+
/**
|
|
620
|
+
* The retry step after an ambiguous failure, as a clause that follows "Then" or a semicolon. A
|
|
621
|
+
* command that takes --idempotency-key repeats its change with that key. A mutation without a key
|
|
622
|
+
* (--yes but no key) may already have applied its change, so its state is read first. Any other
|
|
623
|
+
* command only reads or signs in; it keeps the general advice.
|
|
624
|
+
*/
|
|
625
|
+
const retryAdvice = (command: string): string => {
|
|
626
|
+
const usage = Object.hasOwn(HELP_USAGE, command)
|
|
627
|
+
? HELP_USAGE[command as CustomerCommandLabel]
|
|
628
|
+
: "";
|
|
629
|
+
if (usage.includes("--idempotency-key"))
|
|
630
|
+
return "run the same command again with the same --idempotency-key; the change may already have been applied, and a new key could apply it twice";
|
|
631
|
+
if (usage.includes("--yes"))
|
|
632
|
+
return "this command has no idempotency key and the change may already have been applied; check its current state first with the matching list or get command (for example database access list --project ULID --json), and run the command again only if the change is not there";
|
|
633
|
+
return "run the same command again; this command has no idempotency key, so check its current state first if it may have changed anything";
|
|
634
|
+
};
|
|
635
|
+
|
|
572
636
|
/** The next step after an unexpected client failure; the stderr diagnostic line identifies it. */
|
|
573
|
-
export const
|
|
574
|
-
|
|
637
|
+
export const internalErrorAction = (command: string): string =>
|
|
638
|
+
`The ohmyhost client stopped on an unexpected error; the stderr line starting with [ohmyhost: identifies it. Compare 'ohmyhost --version' with https://ohmyho.st/client-release.json and install the published client if it is older, then ${retryAdvice(command)}. If the current client fails the same way, report it with feedback submit (kind bug, error code internal_error) and quote that stderr line.`;
|
|
575
639
|
|
|
576
640
|
/** The local credential store or saved-login index failed; a sandbox is the usual cause. */
|
|
577
641
|
export const CREDENTIAL_STORE_UNAVAILABLE_ACTION =
|
|
578
642
|
"This computer's credential store (for example macOS Keychain or Windows Credential Manager) or the saved-login list in ~/.ohmyhost/profiles could not be read or written; nothing was changed. If the agent runs in a sandbox, run the command with access to them before asking the customer to sign in again. If the store is locked, ask the customer to unlock it, then run the command again. On a server, container or CI runner without a credential store, do not run login: load OHMYHOST_TOKEN from a private env file with a token from the portal's API Tokens page.";
|
|
579
643
|
|
|
580
|
-
const
|
|
581
|
-
|
|
644
|
+
const responseContractAction = (command: string): string =>
|
|
645
|
+
`The ohmyho.st API answered in a form this client version does not accept, usually because the client is older than the service. Compare 'ohmyhost --version' with https://ohmyho.st/client-release.json; if it is older, install the published CLI and MCP and reload MCP. Then ${retryAdvice(command)}. If the current version answers this too, report it with feedback submit (kind bug, error code response_contract_invalid).`;
|
|
582
646
|
|
|
583
|
-
const
|
|
584
|
-
|
|
647
|
+
const requestTimeoutAction = (command: string): string =>
|
|
648
|
+
`The ohmyho.st API did not answer within 30 seconds; ${retryAdvice(command)}.`;
|
|
585
649
|
|
|
586
650
|
const LOGOUT_UNCONFIRMED_ACTION =
|
|
587
651
|
"The saved login was removed from this computer, but ohmyho.st did not confirm that its session ended. Running logout again cannot retry this, because the login is gone; do not sign in again just to revoke it. Tell the customer the session end was not confirmed.";
|
|
588
652
|
|
|
653
|
+
const HEADERS_FILE_INVALID_ACTION =
|
|
654
|
+
"Put one JSON object of header names and values in the file named by --headers-file: UTF-8, at most 8 KiB, unique names, values of visible ASCII characters. Names such as content-type, host, content-length, connection, transfer-encoding, user-agent, te, upgrade and expect, and names starting with pgcf-, cf- or x-forwarded-, are refused. Nothing was sent; fix the file and run the command again.";
|
|
655
|
+
|
|
656
|
+
const SCHEDULE_STATEMENTS_FILE_INVALID_ACTION =
|
|
657
|
+
"Put one JSON array of 1 to 20 non-empty SQL strings in the file named by --statements-file: UTF-8, at most 64 KiB in total and 16 KiB per statement, without NUL characters. Nothing was saved; fix the file and run the command again.";
|
|
658
|
+
|
|
659
|
+
const SCHEDULE_INVALID_ACTION =
|
|
660
|
+
"Check the schedule request: --cron takes five fields in UTC (9 to 120 characters), or --every-seconds takes 60 to 2678400, never both; --timeout-ms takes 1000 to 30000; the statements file must hold 1 to 20 non-blank SQL strings of at most 16 KiB each and 64 KiB in total. Nothing was saved; fix it and run the command again.";
|
|
661
|
+
|
|
589
662
|
const STATEMENT_FILE_INVALID_ACTION =
|
|
590
663
|
"Put one INSERT, UPDATE or DELETE statement that starts with that keyword (no leading comment or WITH), UTF-8, at most 64 KiB, in the file named by --statement-file, then rerun with the same --idempotency-key.";
|
|
591
664
|
|
|
@@ -652,6 +725,8 @@ export class ProductCli {
|
|
|
652
725
|
let command: ProductCliCommand;
|
|
653
726
|
try {
|
|
654
727
|
command = parseProductCliCommand(argv);
|
|
728
|
+
if (command.kind === "help" && command.topic !== undefined && !helpTopicKnown(command.topic))
|
|
729
|
+
throw new InvalidCommandError();
|
|
655
730
|
} catch (error) {
|
|
656
731
|
if (error instanceof InvalidCommandError) {
|
|
657
732
|
return failure(
|
|
@@ -672,7 +747,7 @@ export class ProductCli {
|
|
|
672
747
|
1,
|
|
673
748
|
"internal_error",
|
|
674
749
|
false,
|
|
675
|
-
|
|
750
|
+
internalErrorAction(recognizedCommandLabel(argv)),
|
|
676
751
|
);
|
|
677
752
|
}
|
|
678
753
|
return this.runCommand(command, signal);
|
|
@@ -844,7 +919,7 @@ export class ProductCli {
|
|
|
844
919
|
}
|
|
845
920
|
if (command.kind === "credits-usage") {
|
|
846
921
|
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
847
|
-
const usage = await api.getOrganizationCreditUsage(command);
|
|
922
|
+
const usage = await api.getOrganizationCreditUsage(command.organizationId);
|
|
848
923
|
try {
|
|
849
924
|
assertOrganizationCreditUsage(usage);
|
|
850
925
|
} catch {
|
|
@@ -1116,6 +1191,34 @@ export class ProductCli {
|
|
|
1116
1191
|
return await this.listDatabaseAccess(command, signal);
|
|
1117
1192
|
if (command.kind === "database-access-revoke")
|
|
1118
1193
|
return await this.revokeDatabaseAccess(command, signal);
|
|
1194
|
+
if (command.kind === "database-webhook-create")
|
|
1195
|
+
return await this.createDatabaseWebhook(command, signal);
|
|
1196
|
+
if (command.kind === "database-webhook-list")
|
|
1197
|
+
return await this.listDatabaseWebhooks(command, signal);
|
|
1198
|
+
if (command.kind === "database-webhook-get")
|
|
1199
|
+
return await this.getDatabaseWebhook(command, signal);
|
|
1200
|
+
if (command.kind === "database-webhook-delete")
|
|
1201
|
+
return await this.deleteDatabaseWebhook(command, signal);
|
|
1202
|
+
if (command.kind === "database-webhook-test")
|
|
1203
|
+
return await this.testDatabaseWebhook(command, signal);
|
|
1204
|
+
if (command.kind === "database-webhook-deliveries")
|
|
1205
|
+
return await this.listDatabaseWebhookDeliveries(command, signal);
|
|
1206
|
+
if (command.kind === "database-webhook-replay")
|
|
1207
|
+
return await this.replayDatabaseWebhookDelivery(command, signal);
|
|
1208
|
+
if (command.kind === "database-schedule-set")
|
|
1209
|
+
return await this.setDatabaseSchedule(command, signal);
|
|
1210
|
+
if (command.kind === "database-schedule-list")
|
|
1211
|
+
return await this.listDatabaseSchedules(command, signal);
|
|
1212
|
+
if (command.kind === "database-schedule-get")
|
|
1213
|
+
return await this.getDatabaseSchedule(command, signal);
|
|
1214
|
+
if (command.kind === "database-schedule-delete")
|
|
1215
|
+
return await this.deleteDatabaseSchedule(command, signal);
|
|
1216
|
+
if (command.kind === "database-schedule-runs")
|
|
1217
|
+
return await this.listDatabaseScheduleRuns(command, signal);
|
|
1218
|
+
if (command.kind === "database-schedule-run")
|
|
1219
|
+
return await this.runDatabaseSchedule(command, signal);
|
|
1220
|
+
if (command.kind === "database-executions")
|
|
1221
|
+
return await this.getDatabaseExecutionUsage(command, signal);
|
|
1119
1222
|
if (command.kind === "database-psql") return await this.runProjectPsql(command, signal);
|
|
1120
1223
|
if (command.kind === "link") return await this.linkSource(command, signal);
|
|
1121
1224
|
if (command.kind === "source-auto-deploy-set")
|
|
@@ -1849,6 +1952,209 @@ export class ProductCli {
|
|
|
1849
1952
|
return success("database access revoke", { status: "succeeded", access });
|
|
1850
1953
|
}
|
|
1851
1954
|
|
|
1955
|
+
/** The signing secret is returned by create only; every other answer carries no secret. */
|
|
1956
|
+
private async createDatabaseWebhook(
|
|
1957
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-create" }>,
|
|
1958
|
+
signal: AbortSignal,
|
|
1959
|
+
): Promise<CliResult> {
|
|
1960
|
+
const request = await webhookCreateRequest(command);
|
|
1961
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1962
|
+
const webhook = await api.createProjectDatabaseWebhook({
|
|
1963
|
+
projectId: command.projectId,
|
|
1964
|
+
environment: command.environment,
|
|
1965
|
+
idempotencyKey: command.idempotencyKey,
|
|
1966
|
+
url: request.url,
|
|
1967
|
+
eventTypes: request.event_types,
|
|
1968
|
+
headers: request.headers,
|
|
1969
|
+
});
|
|
1970
|
+
return success("database webhook create", { status: "succeeded", webhook });
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
private async listDatabaseWebhooks(
|
|
1974
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-list" }>,
|
|
1975
|
+
signal: AbortSignal,
|
|
1976
|
+
): Promise<CliResult> {
|
|
1977
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1978
|
+
const page = await api.listProjectDatabaseWebhooks({
|
|
1979
|
+
projectId: command.projectId,
|
|
1980
|
+
environment: command.environment,
|
|
1981
|
+
});
|
|
1982
|
+
return success("database webhook list", { status: "succeeded", webhook: page.items });
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
private async getDatabaseWebhook(
|
|
1986
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-get" }>,
|
|
1987
|
+
signal: AbortSignal,
|
|
1988
|
+
): Promise<CliResult> {
|
|
1989
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
1990
|
+
const webhook = await api.getProjectDatabaseWebhook({
|
|
1991
|
+
projectId: command.projectId,
|
|
1992
|
+
environment: command.environment,
|
|
1993
|
+
webhookId: command.webhookId,
|
|
1994
|
+
});
|
|
1995
|
+
return success("database webhook get", { status: "succeeded", webhook });
|
|
1996
|
+
}
|
|
1997
|
+
|
|
1998
|
+
private async deleteDatabaseWebhook(
|
|
1999
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-delete" }>,
|
|
2000
|
+
signal: AbortSignal,
|
|
2001
|
+
): Promise<CliResult> {
|
|
2002
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2003
|
+
const deleted = await api.deleteProjectDatabaseWebhook({
|
|
2004
|
+
projectId: command.projectId,
|
|
2005
|
+
environment: command.environment,
|
|
2006
|
+
webhookId: command.webhookId,
|
|
2007
|
+
});
|
|
2008
|
+
return success("database webhook delete", { status: "succeeded", deleted });
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
private async testDatabaseWebhook(
|
|
2012
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-test" }>,
|
|
2013
|
+
signal: AbortSignal,
|
|
2014
|
+
): Promise<CliResult> {
|
|
2015
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2016
|
+
const result = await api.testProjectDatabaseWebhook({
|
|
2017
|
+
projectId: command.projectId,
|
|
2018
|
+
environment: command.environment,
|
|
2019
|
+
webhookId: command.webhookId,
|
|
2020
|
+
});
|
|
2021
|
+
return success("database webhook test", { status: "succeeded", result });
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
private async listDatabaseWebhookDeliveries(
|
|
2025
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-deliveries" }>,
|
|
2026
|
+
signal: AbortSignal,
|
|
2027
|
+
): Promise<CliResult> {
|
|
2028
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2029
|
+
const page = await api.listProjectDatabaseWebhookDeliveries({
|
|
2030
|
+
projectId: command.projectId,
|
|
2031
|
+
environment: command.environment,
|
|
2032
|
+
...(command.webhookId === null ? {} : { webhookId: command.webhookId }),
|
|
2033
|
+
...(command.limit === null ? {} : { limit: command.limit }),
|
|
2034
|
+
...(command.cursor === null ? {} : { cursor: command.cursor }),
|
|
2035
|
+
});
|
|
2036
|
+
return success("database webhook deliveries", {
|
|
2037
|
+
status: "succeeded",
|
|
2038
|
+
delivery: page.items,
|
|
2039
|
+
next_cursor: page.next_cursor,
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
private async replayDatabaseWebhookDelivery(
|
|
2044
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-replay" }>,
|
|
2045
|
+
signal: AbortSignal,
|
|
2046
|
+
): Promise<CliResult> {
|
|
2047
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2048
|
+
const replay = await api.replayProjectDatabaseWebhookDelivery({
|
|
2049
|
+
projectId: command.projectId,
|
|
2050
|
+
environment: command.environment,
|
|
2051
|
+
deliveryId: command.deliveryId,
|
|
2052
|
+
});
|
|
2053
|
+
return success("database webhook replay", { status: "succeeded", replay });
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/** The statements file and the schedule fields are checked before any request is sent. */
|
|
2057
|
+
private async setDatabaseSchedule(
|
|
2058
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-set" }>,
|
|
2059
|
+
signal: AbortSignal,
|
|
2060
|
+
): Promise<CliResult> {
|
|
2061
|
+
const request = await scheduleSetRequest(command);
|
|
2062
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2063
|
+
const schedule = await api.setProjectDatabaseSchedule({
|
|
2064
|
+
projectId: command.projectId,
|
|
2065
|
+
environment: command.environment,
|
|
2066
|
+
name: command.name,
|
|
2067
|
+
request,
|
|
2068
|
+
});
|
|
2069
|
+
return success("database schedule set", { status: "succeeded", schedule });
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
private async listDatabaseSchedules(
|
|
2073
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-list" }>,
|
|
2074
|
+
signal: AbortSignal,
|
|
2075
|
+
): Promise<CliResult> {
|
|
2076
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2077
|
+
const page = await api.listProjectDatabaseSchedules({
|
|
2078
|
+
projectId: command.projectId,
|
|
2079
|
+
environment: command.environment,
|
|
2080
|
+
});
|
|
2081
|
+
return success("database schedule list", { status: "succeeded", schedule: page.items });
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
private async getDatabaseSchedule(
|
|
2085
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-get" }>,
|
|
2086
|
+
signal: AbortSignal,
|
|
2087
|
+
): Promise<CliResult> {
|
|
2088
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2089
|
+
const schedule = await api.getProjectDatabaseSchedule({
|
|
2090
|
+
projectId: command.projectId,
|
|
2091
|
+
environment: command.environment,
|
|
2092
|
+
name: command.name,
|
|
2093
|
+
});
|
|
2094
|
+
return success("database schedule get", { status: "succeeded", schedule });
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
private async deleteDatabaseSchedule(
|
|
2098
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-delete" }>,
|
|
2099
|
+
signal: AbortSignal,
|
|
2100
|
+
): Promise<CliResult> {
|
|
2101
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2102
|
+
const deleted = await api.deleteProjectDatabaseSchedule({
|
|
2103
|
+
projectId: command.projectId,
|
|
2104
|
+
environment: command.environment,
|
|
2105
|
+
name: command.name,
|
|
2106
|
+
});
|
|
2107
|
+
return success("database schedule delete", { status: "succeeded", deleted });
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
private async listDatabaseScheduleRuns(
|
|
2111
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-runs" }>,
|
|
2112
|
+
signal: AbortSignal,
|
|
2113
|
+
): Promise<CliResult> {
|
|
2114
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2115
|
+
const page = await api.listProjectDatabaseScheduleRuns({
|
|
2116
|
+
projectId: command.projectId,
|
|
2117
|
+
environment: command.environment,
|
|
2118
|
+
name: command.name,
|
|
2119
|
+
...(command.limit === null ? {} : { limit: command.limit }),
|
|
2120
|
+
});
|
|
2121
|
+
return success("database schedule runs", { status: "succeeded", schedule_run: page.items });
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
private async runDatabaseSchedule(
|
|
2125
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-run" }>,
|
|
2126
|
+
signal: AbortSignal,
|
|
2127
|
+
): Promise<CliResult> {
|
|
2128
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2129
|
+
const run = await api.runProjectDatabaseSchedule({
|
|
2130
|
+
projectId: command.projectId,
|
|
2131
|
+
environment: command.environment,
|
|
2132
|
+
name: command.name,
|
|
2133
|
+
});
|
|
2134
|
+
return success("database schedule run", { status: "succeeded", run });
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
private async getDatabaseExecutionUsage(
|
|
2138
|
+
command: Extract<ProductCliCommand, { kind: "database-executions" }>,
|
|
2139
|
+
signal: AbortSignal,
|
|
2140
|
+
): Promise<CliResult> {
|
|
2141
|
+
const api = await this.authenticatedApi(command.credentialStore, signal);
|
|
2142
|
+
const page = await api.getProjectDatabaseExecutionUsage({
|
|
2143
|
+
projectId: command.projectId,
|
|
2144
|
+
environment: command.environment,
|
|
2145
|
+
from: command.from,
|
|
2146
|
+
to: command.to,
|
|
2147
|
+
granularity: command.granularity,
|
|
2148
|
+
...(command.limit === null ? {} : { limit: command.limit }),
|
|
2149
|
+
...(command.cursor === null ? {} : { cursor: command.cursor }),
|
|
2150
|
+
});
|
|
2151
|
+
return success("database executions", {
|
|
2152
|
+
status: "succeeded",
|
|
2153
|
+
usage: page.items,
|
|
2154
|
+
next_cursor: page.next_cursor,
|
|
2155
|
+
});
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1852
2158
|
/** psql receives the password through its own environment; the URI is never printed. */
|
|
1853
2159
|
private async runProjectPsql(
|
|
1854
2160
|
command: Extract<ProductCliCommand, { kind: "database-psql" }>,
|
|
@@ -1874,7 +2180,7 @@ export class ProductCli {
|
|
|
1874
2180
|
host: credential.host,
|
|
1875
2181
|
user: credential.role_name,
|
|
1876
2182
|
password: decodeURIComponent(new URL(credential.connection_uri).password),
|
|
1877
|
-
|
|
2183
|
+
websocketUrl: credential.websocket_url,
|
|
1878
2184
|
database: credential.database,
|
|
1879
2185
|
mode: credential.mode,
|
|
1880
2186
|
signal,
|
|
@@ -2761,7 +3067,8 @@ export class ProductCli {
|
|
|
2761
3067
|
);
|
|
2762
3068
|
}
|
|
2763
3069
|
if (isTimeout(error)) {
|
|
2764
|
-
|
|
3070
|
+
// classifyFailure replaces this text with the advice for the command that timed out.
|
|
3071
|
+
throw new ProductCliFailure(8, "request_timeout", true, requestTimeoutAction("unknown"));
|
|
2765
3072
|
}
|
|
2766
3073
|
if (error instanceof LifecycleTransportError) {
|
|
2767
3074
|
throw new ProductCliFailure(
|
|
@@ -2860,11 +3167,16 @@ const classifyFailure = (
|
|
|
2860
3167
|
130,
|
|
2861
3168
|
"command_cancelled",
|
|
2862
3169
|
true,
|
|
2863
|
-
|
|
3170
|
+
`The command was stopped by an interrupt or termination signal, for example the shell's command timeout, before it finished; ${retryAdvice(command)}. Give login and commands with --wait or --follow several minutes, or run them in the background.`,
|
|
2864
3171
|
);
|
|
2865
3172
|
}
|
|
2866
3173
|
if (isTimeout(error)) {
|
|
2867
|
-
return failure(command, 8, "request_timeout", true,
|
|
3174
|
+
return failure(command, 8, "request_timeout", true, requestTimeoutAction(command));
|
|
3175
|
+
}
|
|
3176
|
+
// A retryable request_timeout is a token refresh that timed out inside this command; the logout
|
|
3177
|
+
// timeout is not retryable and keeps its own advice.
|
|
3178
|
+
if (error instanceof ProductCliFailure && error.code === "request_timeout" && error.retryable) {
|
|
3179
|
+
return failure(command, 8, "request_timeout", true, requestTimeoutAction(command));
|
|
2868
3180
|
}
|
|
2869
3181
|
if (error instanceof ProductCliFailure) {
|
|
2870
3182
|
return failure(
|
|
@@ -2924,13 +3236,7 @@ const classifyFailure = (
|
|
|
2924
3236
|
);
|
|
2925
3237
|
}
|
|
2926
3238
|
if (error instanceof ResponseContractError) {
|
|
2927
|
-
return failure(
|
|
2928
|
-
command,
|
|
2929
|
-
7,
|
|
2930
|
-
"response_contract_invalid",
|
|
2931
|
-
false,
|
|
2932
|
-
RESPONSE_CONTRACT_INVALID_ACTION,
|
|
2933
|
-
);
|
|
3239
|
+
return failure(command, 7, "response_contract_invalid", false, responseContractAction(command));
|
|
2934
3240
|
}
|
|
2935
3241
|
if (error instanceof PublicResponseTooLargeError) {
|
|
2936
3242
|
return failure(
|
|
@@ -2963,18 +3269,12 @@ const classifyFailure = (
|
|
|
2963
3269
|
7,
|
|
2964
3270
|
"response_contract_invalid",
|
|
2965
3271
|
false,
|
|
2966
|
-
|
|
3272
|
+
responseContractAction(command),
|
|
2967
3273
|
);
|
|
2968
3274
|
}
|
|
2969
3275
|
}
|
|
2970
3276
|
if (looksLikeRemoteObject(error)) {
|
|
2971
|
-
return failure(
|
|
2972
|
-
command,
|
|
2973
|
-
7,
|
|
2974
|
-
"response_contract_invalid",
|
|
2975
|
-
false,
|
|
2976
|
-
RESPONSE_CONTRACT_INVALID_ACTION,
|
|
2977
|
-
);
|
|
3277
|
+
return failure(command, 7, "response_contract_invalid", false, responseContractAction(command));
|
|
2978
3278
|
}
|
|
2979
3279
|
if (error instanceof NetworkTransportError) {
|
|
2980
3280
|
return failure(
|
|
@@ -2982,10 +3282,10 @@ const classifyFailure = (
|
|
|
2982
3282
|
8,
|
|
2983
3283
|
"network_error",
|
|
2984
3284
|
true,
|
|
2985
|
-
|
|
3285
|
+
`The request did not reach the ohmyho.st API, or its answer was cut off. If the agent runs in a sandbox without network access, run the command with network access. Otherwise ${retryAdvice(command)}.`,
|
|
2986
3286
|
);
|
|
2987
3287
|
}
|
|
2988
|
-
return failure(command, 1, "internal_error", false,
|
|
3288
|
+
return failure(command, 1, "internal_error", false, internalErrorAction(command));
|
|
2989
3289
|
};
|
|
2990
3290
|
|
|
2991
3291
|
/** A 409 idempotency_key_reused problem, with what its answer carries. */
|
|
@@ -3006,36 +3306,122 @@ const OPERATION_STREAM_CONNECTIONS = 4;
|
|
|
3006
3306
|
* The single DML statement of `database write --statement-file`: a regular UTF-8 file of at most
|
|
3007
3307
|
* 64 KiB. Anything else, including a missing file, is refused before any request.
|
|
3008
3308
|
*/
|
|
3009
|
-
const readStatementFile =
|
|
3010
|
-
|
|
3011
|
-
|
|
3309
|
+
const readStatementFile = (path: string): Promise<string> =>
|
|
3310
|
+
readBoundedUtf8File(
|
|
3311
|
+
path,
|
|
3312
|
+
65_536,
|
|
3313
|
+
() => new ProductCliFailure(2, "statement_file_invalid", false, STATEMENT_FILE_INVALID_ACTION),
|
|
3314
|
+
);
|
|
3315
|
+
|
|
3316
|
+
/** A regular UTF-8 file of at most maxBytes; anything else is refused with the given failure. */
|
|
3317
|
+
const readBoundedUtf8File = async (
|
|
3318
|
+
path: string,
|
|
3319
|
+
maxBytes: number,
|
|
3320
|
+
refusal: () => ProductCliFailure,
|
|
3321
|
+
): Promise<string> => {
|
|
3012
3322
|
let file: Awaited<ReturnType<typeof open>>;
|
|
3013
3323
|
try {
|
|
3014
3324
|
file = await open(path, "r");
|
|
3015
3325
|
} catch {
|
|
3016
|
-
throw
|
|
3326
|
+
throw refusal();
|
|
3017
3327
|
}
|
|
3018
|
-
const buffer = Buffer.alloc(
|
|
3328
|
+
const buffer = Buffer.alloc(maxBytes + 1);
|
|
3019
3329
|
try {
|
|
3020
3330
|
const stat = await file.stat();
|
|
3021
|
-
if (!stat.isFile() || stat.size >
|
|
3331
|
+
if (!stat.isFile() || stat.size > maxBytes) throw refusal();
|
|
3022
3332
|
let length = 0;
|
|
3023
3333
|
while (length < buffer.length) {
|
|
3024
3334
|
const read = await file.read(buffer, length, buffer.length - length, null);
|
|
3025
3335
|
if (read.bytesRead === 0) break;
|
|
3026
3336
|
length += read.bytesRead;
|
|
3027
3337
|
}
|
|
3028
|
-
if (length >
|
|
3338
|
+
if (length > maxBytes) throw refusal();
|
|
3029
3339
|
return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
|
|
3030
3340
|
} catch (error) {
|
|
3031
3341
|
if (error instanceof ProductCliFailure) throw error;
|
|
3032
|
-
throw
|
|
3342
|
+
throw refusal();
|
|
3033
3343
|
} finally {
|
|
3034
3344
|
buffer.fill(0);
|
|
3035
3345
|
await file.close();
|
|
3036
3346
|
}
|
|
3037
3347
|
};
|
|
3038
3348
|
|
|
3349
|
+
/** The create request with the headers of `--headers-file`; a refused file sends nothing. */
|
|
3350
|
+
const webhookCreateRequest = async (
|
|
3351
|
+
command: Extract<ProductCliCommand, { kind: "database-webhook-create" }>,
|
|
3352
|
+
) => {
|
|
3353
|
+
const headersFailure = () =>
|
|
3354
|
+
new ProductCliFailure(2, "headers_file_invalid", false, HEADERS_FILE_INVALID_ACTION);
|
|
3355
|
+
const headers =
|
|
3356
|
+
command.headersFile === null
|
|
3357
|
+
? undefined
|
|
3358
|
+
: await readBoundedUtf8File(command.headersFile, 8_192, headersFailure).then(
|
|
3359
|
+
(text: string): unknown => {
|
|
3360
|
+
try {
|
|
3361
|
+
return JSON.parse(text) as unknown;
|
|
3362
|
+
} catch {
|
|
3363
|
+
throw headersFailure();
|
|
3364
|
+
}
|
|
3365
|
+
},
|
|
3366
|
+
);
|
|
3367
|
+
try {
|
|
3368
|
+
return parseDatabaseWebhookCreateRequest({
|
|
3369
|
+
url: command.url,
|
|
3370
|
+
event_types: command.eventTypes,
|
|
3371
|
+
headers,
|
|
3372
|
+
});
|
|
3373
|
+
} catch {
|
|
3374
|
+
throw headersFailure();
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
|
|
3378
|
+
/**
|
|
3379
|
+
* The schedule request of `database schedule set`: the statements file is a UTF-8 JSON array, and
|
|
3380
|
+
* the schedule fields are checked by the same contract the service uses. A refused file or field
|
|
3381
|
+
* sends nothing.
|
|
3382
|
+
*/
|
|
3383
|
+
const scheduleSetRequest = async (
|
|
3384
|
+
command: Extract<ProductCliCommand, { kind: "database-schedule-set" }>,
|
|
3385
|
+
): Promise<DatabaseScheduleSetRequest> => {
|
|
3386
|
+
const statementsFailure = () =>
|
|
3387
|
+
new ProductCliFailure(
|
|
3388
|
+
2,
|
|
3389
|
+
"statements_file_invalid",
|
|
3390
|
+
false,
|
|
3391
|
+
SCHEDULE_STATEMENTS_FILE_INVALID_ACTION,
|
|
3392
|
+
);
|
|
3393
|
+
const statements = await readBoundedUtf8File(
|
|
3394
|
+
command.statementsFile,
|
|
3395
|
+
65_536,
|
|
3396
|
+
statementsFailure,
|
|
3397
|
+
).then((text: string): unknown => {
|
|
3398
|
+
try {
|
|
3399
|
+
return JSON.parse(text) as unknown;
|
|
3400
|
+
} catch {
|
|
3401
|
+
throw statementsFailure();
|
|
3402
|
+
}
|
|
3403
|
+
});
|
|
3404
|
+
// The statements are checked on their own first, so a bad file always answers statements_file_invalid.
|
|
3405
|
+
// The placeholder cadence only lets the contract reach the statements; it is never sent.
|
|
3406
|
+
try {
|
|
3407
|
+
parseDatabaseScheduleSetRequest({ every_seconds: 60, statements, confirm: true });
|
|
3408
|
+
} catch {
|
|
3409
|
+
throw statementsFailure();
|
|
3410
|
+
}
|
|
3411
|
+
try {
|
|
3412
|
+
return parseDatabaseScheduleSetRequest({
|
|
3413
|
+
...(command.cron === null ? {} : { cron: command.cron }),
|
|
3414
|
+
...(command.everySeconds === null ? {} : { every_seconds: command.everySeconds }),
|
|
3415
|
+
statements,
|
|
3416
|
+
...(command.timeoutMs === null ? {} : { timeout_ms: command.timeoutMs }),
|
|
3417
|
+
...(command.enabled === null ? {} : { enabled: command.enabled }),
|
|
3418
|
+
confirm: true,
|
|
3419
|
+
});
|
|
3420
|
+
} catch {
|
|
3421
|
+
throw new ProductCliFailure(2, "schedule_invalid", false, SCHEDULE_INVALID_ACTION);
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
|
|
3039
3425
|
const psqlUnavailable = (): CliResult =>
|
|
3040
3426
|
failure(
|
|
3041
3427
|
"database psql",
|
|
@@ -3140,6 +3526,20 @@ const parsedCommandName = (command: ProductCliCommand): CommandLabel => {
|
|
|
3140
3526
|
if (command.kind === "database-access-list") return "database access list";
|
|
3141
3527
|
if (command.kind === "database-access-revoke") return "database access revoke";
|
|
3142
3528
|
if (command.kind === "database-psql") return "database psql";
|
|
3529
|
+
if (command.kind === "database-webhook-create") return "database webhook create";
|
|
3530
|
+
if (command.kind === "database-webhook-list") return "database webhook list";
|
|
3531
|
+
if (command.kind === "database-webhook-get") return "database webhook get";
|
|
3532
|
+
if (command.kind === "database-webhook-delete") return "database webhook delete";
|
|
3533
|
+
if (command.kind === "database-webhook-test") return "database webhook test";
|
|
3534
|
+
if (command.kind === "database-webhook-deliveries") return "database webhook deliveries";
|
|
3535
|
+
if (command.kind === "database-webhook-replay") return "database webhook replay";
|
|
3536
|
+
if (command.kind === "database-schedule-set") return "database schedule set";
|
|
3537
|
+
if (command.kind === "database-schedule-list") return "database schedule list";
|
|
3538
|
+
if (command.kind === "database-schedule-get") return "database schedule get";
|
|
3539
|
+
if (command.kind === "database-schedule-delete") return "database schedule delete";
|
|
3540
|
+
if (command.kind === "database-schedule-runs") return "database schedule runs";
|
|
3541
|
+
if (command.kind === "database-schedule-run") return "database schedule run";
|
|
3542
|
+
if (command.kind === "database-executions") return "database executions";
|
|
3143
3543
|
if (command.kind === "source-auto-deploy-set") return "source auto-deploy set";
|
|
3144
3544
|
if (command.kind === "source-auto-deploy-status") return "source auto-deploy status";
|
|
3145
3545
|
if (command.kind === "domain-cloudflare-authorize") return "domain cloudflare authorize";
|