@pithy-sh/cli 0.1.0
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 +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
6
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
7
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
8
|
+
import type { ControlPlaneConnection } from "@pithy-sh/core/src/controlPlane/data/connection";
|
|
9
|
+
import { Ed25519PublicJwk } from "@pithy-sh/core/src/controlPlane/data/connection";
|
|
10
|
+
import type { ControlPlaneScope } from "@pithy-sh/core/src/controlPlane/scope/scope";
|
|
11
|
+
import { ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
12
|
+
import { defineCommand } from "citty";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import { createCliAudit } from "../audit/cliAudit";
|
|
15
|
+
import { type CloudflareAccountSelection, resolveCloudflare } from "../cloudflare/config";
|
|
16
|
+
import { httpDashboardClient } from "../dashboard/api";
|
|
17
|
+
import {
|
|
18
|
+
authorizeDashboard,
|
|
19
|
+
type ConnectReport,
|
|
20
|
+
connectDashboard,
|
|
21
|
+
type DisconnectReport,
|
|
22
|
+
dashboardStatus,
|
|
23
|
+
disconnectDashboard,
|
|
24
|
+
type OfflinePublicKey,
|
|
25
|
+
type RotateReport,
|
|
26
|
+
rotateDashboardKey,
|
|
27
|
+
type StatusReport,
|
|
28
|
+
} from "../dashboard/connect";
|
|
29
|
+
import type { DashboardClient, DeviceAuthorization } from "../dashboard/contract";
|
|
30
|
+
import { defaultGrant, type GrantableScope, grantableScopes } from "../dashboard/grant";
|
|
31
|
+
import { type ConnectionRegistry, openConnectionRegistry } from "../dashboard/registry";
|
|
32
|
+
import { describeConnectTarget, resolveConnectTarget } from "../dashboard/resolveTarget";
|
|
33
|
+
import { loadProject, projectCloudflareAccount, requireProjectName } from "../project/config";
|
|
34
|
+
import { ENV_ARG, requireEnvironment } from "../project/environment";
|
|
35
|
+
import { projectCapabilities, resolveWorkers } from "../project/workerScope";
|
|
36
|
+
import { isProductionEnv } from "../seed/safety";
|
|
37
|
+
import { formatDone, formatJsonLine, formatList, withErrorReporting } from "../terminal/output";
|
|
38
|
+
import { dim } from "../terminal/style";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `pithy dashboard` — connect a management client to an environment, rotate its key, revoke it, or
|
|
42
|
+
* look at what is registered (docs/CONTROL-PLANE.md §15).
|
|
43
|
+
*
|
|
44
|
+
* **Connection is project-wide, per environment, never per Worker.** A Worker is only how the CLI
|
|
45
|
+
* finds the app database; the row is keyed on `--env`.
|
|
46
|
+
*
|
|
47
|
+
* Every subcommand runs headlessly with `--json`, prompts only at a real terminal, and writes machine
|
|
48
|
+
* output to stdout with diagnostics — the device code, the waiting line — on stderr, so a `--json`
|
|
49
|
+
* consumer parses one clean line.
|
|
50
|
+
*
|
|
51
|
+
* The `format*Report` functions are exported and pure for the same reason `formatMigrateReport` is:
|
|
52
|
+
* the output contract is what an operator and an agent both read, and it should be testable without a
|
|
53
|
+
* project on disk.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/** Where a human-facing pair list gets its label column. */
|
|
57
|
+
type Row = { name: string; description: string };
|
|
58
|
+
|
|
59
|
+
/** Render an aligned label/value block, or nothing when there is nothing to say. */
|
|
60
|
+
function block(rows: Row[]): string {
|
|
61
|
+
return rows.length === 0 ? "" : `${formatList(rows)}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Collect every `--scope` from the raw argv. citty keeps only the last occurrence of a repeated
|
|
66
|
+
* string flag, so a multi-scope grant would otherwise silently narrow to one — the same reason
|
|
67
|
+
* `--permission` and `--set` are read from `rawArgs`.
|
|
68
|
+
*/
|
|
69
|
+
export function collectScopeFlags(rawArgs: string[]): string[] {
|
|
70
|
+
const found: string[] = [];
|
|
71
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
72
|
+
const arg = rawArgs[i];
|
|
73
|
+
if (arg === "--scope") {
|
|
74
|
+
const value = rawArgs[i + 1];
|
|
75
|
+
if (value !== undefined) {
|
|
76
|
+
found.push(value);
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
} else if (arg?.startsWith("--scope=")) {
|
|
80
|
+
found.push(arg.slice("--scope=".length));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return found;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read a JWK file's contents into the key half of an offline registration.
|
|
88
|
+
*
|
|
89
|
+
* The id comes from `--key-id`, else the file's own `kid`, and is required either way: it is what a
|
|
90
|
+
* token's `kid` header names, and a key nobody can address is a key nobody can use. The JWK itself
|
|
91
|
+
* goes through `Ed25519PublicJwk`, so a P-256 key or a private key with a `d` component is refused
|
|
92
|
+
* here rather than written into the adopter's authorization row.
|
|
93
|
+
*/
|
|
94
|
+
export function parsePublicKey(raw: string, keyId: string | undefined): { keyId: string; publicKey: Ed25519PublicJwk } {
|
|
95
|
+
let body: unknown;
|
|
96
|
+
try {
|
|
97
|
+
body = JSON.parse(raw);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new ValidationError(
|
|
100
|
+
{
|
|
101
|
+
message: "That public key file isn't JSON.",
|
|
102
|
+
action: 'Pass a JWK file: {"kty":"OKP","crv":"Ed25519","x":"…","kid":"…"}.',
|
|
103
|
+
},
|
|
104
|
+
{ cause: error },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsed = Ed25519PublicJwk.safeParse(body);
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
throw new ValidationError({
|
|
111
|
+
message: "That key isn't an Ed25519 public JWK.",
|
|
112
|
+
action: "The seam accepts Ed25519 only. Export the public half as a JWK and try again.",
|
|
113
|
+
detail: z.prettifyError(parsed.error),
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const id = keyId ?? (typeof (body as { kid?: unknown }).kid === "string" ? (body as { kid: string }).kid : undefined);
|
|
118
|
+
if (!id) {
|
|
119
|
+
throw new ValidationError({
|
|
120
|
+
message: "That key has no id.",
|
|
121
|
+
action: "Pass --key-id, or put a `kid` in the JWK. Every token names its key in the kid header.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { keyId: id, publicKey: parsed.data };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Render a connect run. `connected` and `registered` finish; `needs_reconnect` deliberately does not. */
|
|
128
|
+
export function formatConnectReport(report: ConnectReport, options: { json: boolean }): string {
|
|
129
|
+
if (options.json) return `${formatJsonLine({ command: "dashboard.connect", ...report })}\n`;
|
|
130
|
+
|
|
131
|
+
const rows: Row[] = [
|
|
132
|
+
{ name: "Connection", description: dim(report.connectionId) },
|
|
133
|
+
{ name: "Issuer", description: dim(report.issuer) },
|
|
134
|
+
{ name: "Worker", description: dim(report.workerUrl) },
|
|
135
|
+
{ name: "Scopes", description: dim(report.scopes.join(", ")) },
|
|
136
|
+
...(report.keyId === null ? [] : [{ name: "Key", description: dim(report.keyId) }]),
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
if (report.status === "needs_reconnect") {
|
|
140
|
+
// Registered, but the round-trip failed. Never `Done.` — the connection does not work yet.
|
|
141
|
+
return [
|
|
142
|
+
`Registered ${report.environment}, but the signed ping didn't get through.`,
|
|
143
|
+
report.detail ?? "The worker didn't answer.",
|
|
144
|
+
block(rows).trimEnd(),
|
|
145
|
+
`Check the worker URL, then run pithy dashboard connect --env ${report.environment} --update --worker-url <url>.`,
|
|
146
|
+
"",
|
|
147
|
+
].join("\n");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const headline = report.updated
|
|
151
|
+
? `Re-pointed ${report.environment}.`
|
|
152
|
+
: report.status === "registered"
|
|
153
|
+
? `Registered ${report.environment}.`
|
|
154
|
+
: `Connected ${report.environment}.`;
|
|
155
|
+
const unproven =
|
|
156
|
+
report.status === "registered"
|
|
157
|
+
? "No dashboard was involved, so nothing proved the key. Prove it with a signed ping from your own client.\n"
|
|
158
|
+
: "";
|
|
159
|
+
return `${headline}\n${block(rows)}${unproven}${formatDone()}\n`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Render a rotation. The line about the old key staying live is the point of the whole command. */
|
|
163
|
+
export function formatRotateReport(report: RotateReport, options: { json: boolean }): string {
|
|
164
|
+
if (options.json) return `${formatJsonLine({ command: "dashboard.rotate", ...report })}\n`;
|
|
165
|
+
|
|
166
|
+
const previous = report.previousKeyIds.join(", ");
|
|
167
|
+
if (report.status === "needs_reconnect") {
|
|
168
|
+
const lines = [
|
|
169
|
+
`Registered ${report.keyId} on ${report.environment}, but the signed ping didn't get through.`,
|
|
170
|
+
report.detail ?? "The worker didn't answer with the new key.",
|
|
171
|
+
];
|
|
172
|
+
// The reassurance that matters: an unproven successor costs nothing, because nothing was expired.
|
|
173
|
+
if (previous !== "") lines.push(`${previous} is still live, so nothing is locked out.`);
|
|
174
|
+
return `${lines.join("\n")}\n`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const kept =
|
|
178
|
+
previous === ""
|
|
179
|
+
? ""
|
|
180
|
+
: `${previous} is still live. Expire it from your management client once it has proven ${report.keyId}.\n`;
|
|
181
|
+
return `Rotated ${report.environment}. New key ${report.keyId}.\n${kept}${formatDone()}\n`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Render a revocation. It succeeded locally whatever the management client had to say about it. */
|
|
185
|
+
export function formatDisconnectReport(report: DisconnectReport, options: { json: boolean }): string {
|
|
186
|
+
if (options.json) return `${formatJsonLine({ command: "dashboard.disconnect", ...report })}\n`;
|
|
187
|
+
if (!report.removed) return `Nothing connected to ${report.environment}.\n${formatDone()}\n`;
|
|
188
|
+
|
|
189
|
+
const courtesy = report.dashboardNotified
|
|
190
|
+
? ""
|
|
191
|
+
: `The management client wasn't told: ${report.detail ?? "no reason given."} Your revocation stands.\n`;
|
|
192
|
+
return `Disconnected ${report.environment}.\n${courtesy}${formatDone()}\n`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Render the registration state, keys and ages included. */
|
|
196
|
+
export function formatStatusReport(report: StatusReport, options: { json: boolean }): string {
|
|
197
|
+
if (options.json) return `${formatJsonLine({ command: "dashboard.status", ...report })}\n`;
|
|
198
|
+
if (!report.connected) {
|
|
199
|
+
return [
|
|
200
|
+
`Nothing connected to ${report.environment}.`,
|
|
201
|
+
`Run pithy dashboard connect --env ${report.environment} to register a management client.`,
|
|
202
|
+
"",
|
|
203
|
+
].join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const headline =
|
|
207
|
+
report.status === "unverified"
|
|
208
|
+
? `${report.environment} is connected. Not verified — pass --verify to prove it with a signed ping.`
|
|
209
|
+
: report.status === "connected"
|
|
210
|
+
? `${report.environment} is connected and answering.`
|
|
211
|
+
: `${report.environment} needs reconnecting. ${report.detail ?? ""}`.trimEnd();
|
|
212
|
+
|
|
213
|
+
const rows: Row[] = [
|
|
214
|
+
{ name: "Connection", description: dim(report.connectionId ?? "") },
|
|
215
|
+
{ name: "Issuer", description: dim(report.issuer ?? "") },
|
|
216
|
+
{ name: "Worker", description: dim(report.workerUrl ?? "") },
|
|
217
|
+
{ name: "Scopes", description: dim(report.scopes.join(", ")) },
|
|
218
|
+
];
|
|
219
|
+
const keys = report.keys.map((key) => ({
|
|
220
|
+
name: key.keyId,
|
|
221
|
+
description: dim(`${key.live ? "live" : key.revokedAt ? "revoked" : "expired"} ${key.ageDays} days`),
|
|
222
|
+
}));
|
|
223
|
+
const parts = [headline, block(rows).trimEnd(), block(keys).trimEnd()].filter((part) => part !== "");
|
|
224
|
+
return `${parts.join("\n")}\n`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** True only at a real terminal without `--json` — the one condition a prompt is answerable. */
|
|
228
|
+
function isInteractive(json: boolean): boolean {
|
|
229
|
+
return !json && Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Show the device code on stderr, so `--json`'s stdout line stays the only machine output. */
|
|
233
|
+
function announce(authorization: DeviceAuthorization): void {
|
|
234
|
+
process.stderr.write(`Open ${authorization.verificationUri} and enter ${authorization.userCode}.\n`);
|
|
235
|
+
process.stderr.write("▸ Waiting for approval...\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The device-code flow, wired to this CLI's announcer. */
|
|
239
|
+
const authorize = (client: DashboardClient): Promise<string> => authorizeDashboard(client, { announce });
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The recorder every write to the connection row records itself through (#294).
|
|
243
|
+
*
|
|
244
|
+
* Built over the handle {@link openConnectionRegistry} resolved, never over a database id looked up a
|
|
245
|
+
* second time — the row and its record have to land in the same store, and on `dev` a resolved id names
|
|
246
|
+
* the real remote database while the row goes to local Miniflare.
|
|
247
|
+
*
|
|
248
|
+
* Two things are deliberately tolerant. `audit` composed by **any** Worker means the project has a
|
|
249
|
+
* trail, because a connection is project-wide and a `--worker` here only says which `wrangler.jsonc`
|
|
250
|
+
* resolves the database. And Cloudflare credentials are optional: connecting a `dev` environment touches
|
|
251
|
+
* no account, so demanding an account token to record it would make the trail depend on something the
|
|
252
|
+
* action does not. Without one the row is written unattributed rather than dropped.
|
|
253
|
+
*
|
|
254
|
+
* **A mismatched account names nobody, and does not throw here (#236).** This resolves credentials only to
|
|
255
|
+
* put a name on the actor, so it reads through {@link resolveCloudflare} rather than {@link cloudflareEnv}:
|
|
256
|
+
* an operator whose pin and credentials disagree must not be recorded in their own trail as the account
|
|
257
|
+
* the project does not claim, and a trail wiring must not be the thing that refuses a `dev` run that
|
|
258
|
+
* touches no account at all. The refusal for the environments that *do* reach an account is
|
|
259
|
+
* `openConnectionRegistry`'s, stated once, ahead of the fan-out.
|
|
260
|
+
*/
|
|
261
|
+
async function openAudit(projectDir: string, env: string, account: CloudflareAccountSelection | null) {
|
|
262
|
+
const capabilities = await resolveWorkers({ projectDir }).then(projectCapabilities).catch(NO_CAPABILITIES);
|
|
263
|
+
const resolved = resolveCloudflare({ account });
|
|
264
|
+
const accountId = resolved.mismatch ? "" : (resolved.vars.CLOUDFLARE_ACCOUNT_ID ?? "");
|
|
265
|
+
const apiToken = resolved.mismatch ? "" : (resolved.vars.CLOUDFLARE_API_TOKEN ?? "");
|
|
266
|
+
const named = accountId !== "" && apiToken !== "";
|
|
267
|
+
return (database: D1Database) =>
|
|
268
|
+
createCliAudit({
|
|
269
|
+
projectDir,
|
|
270
|
+
env,
|
|
271
|
+
// `--env` here is the environment acted on as well as where the row lands: a connection is per
|
|
272
|
+
// environment, and this command touches exactly the one named.
|
|
273
|
+
actedOn: env,
|
|
274
|
+
capabilities,
|
|
275
|
+
database,
|
|
276
|
+
...(named ? { clients: new CloudflareClients({ accountId, apiToken }), apiToken } : {}),
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** A capability lookup that failed says nothing rather than throwing — auditing never breaks a command. */
|
|
281
|
+
const NO_CAPABILITIES = (): Capability[] => [];
|
|
282
|
+
|
|
283
|
+
/** Open the registry, run the work, and always release the driver. */
|
|
284
|
+
async function withRegistry<T>(
|
|
285
|
+
options: { env: string; worker?: string },
|
|
286
|
+
work: (registry: ConnectionRegistry) => Promise<T>,
|
|
287
|
+
): Promise<T> {
|
|
288
|
+
// Every subcommand opens the registry, so this is the one door the `--env` check belongs behind —
|
|
289
|
+
// and the one place the project's account is resolved for it (#234). A `--env staging` lookup opens
|
|
290
|
+
// the app database over REST, and it is this project's account that says which one that is.
|
|
291
|
+
const projectDir = process.cwd();
|
|
292
|
+
const env = requireEnvironment(options.env);
|
|
293
|
+
const account = await projectCloudflareAccount(projectDir);
|
|
294
|
+
const registry = await openConnectionRegistry({
|
|
295
|
+
projectDir,
|
|
296
|
+
env,
|
|
297
|
+
account,
|
|
298
|
+
...(options.worker === undefined ? {} : { worker: options.worker }),
|
|
299
|
+
openAudit: await openAudit(projectDir, env, account),
|
|
300
|
+
});
|
|
301
|
+
try {
|
|
302
|
+
return await work(registry);
|
|
303
|
+
} finally {
|
|
304
|
+
await registry.dispose();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The flags every subcommand shares. */
|
|
309
|
+
const commonArgs = {
|
|
310
|
+
env: ENV_ARG,
|
|
311
|
+
worker: { type: "string", description: "Which worker's wrangler.jsonc resolves the app database (apps/<name>)" },
|
|
312
|
+
origin: { type: "string", description: "The management client's origin (default https://app.pithy.sh)" },
|
|
313
|
+
json: { type: "boolean", default: false, description: "Machine-readable output" },
|
|
314
|
+
} as const;
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Offer this Worker's grantable scopes at a terminal, preselected to the default grant.
|
|
318
|
+
*
|
|
319
|
+
* **The options are read off what the Worker composes, not typed here.** A hardcoded pair offered
|
|
320
|
+
* `manifest:read` and `keys:rotate` and nothing else — so the operator was shown a choice between two
|
|
321
|
+
* things neither of which reads any of their data, on a Worker composing capabilities with a dozen
|
|
322
|
+
* scopes between them. Reading the composed surface means an adopter sees every operation their own
|
|
323
|
+
* Worker actually exposes, described in the capability's own words.
|
|
324
|
+
*
|
|
325
|
+
* Preselected to {@link defaultGrant} — every read, plus the seam's own pair — because narrowing is the
|
|
326
|
+
* point of showing the list at all: `keys:rotate` and `audit:events:read_detail` are exactly the grants
|
|
327
|
+
* somebody may not want to make, and a grant nobody is shown is a grant nobody considers. `ping` is
|
|
328
|
+
* absent throughout; it is not grantable (docs/CONTROL-PLANE.md §8).
|
|
329
|
+
*/
|
|
330
|
+
async function promptScopes(
|
|
331
|
+
grantable: readonly GrantableScope[],
|
|
332
|
+
preselected: ControlPlaneScope[],
|
|
333
|
+
): Promise<ControlPlaneScope[]> {
|
|
334
|
+
const { isCancel, multiselect } = await import("@clack/prompts");
|
|
335
|
+
const answer = await multiselect({
|
|
336
|
+
message: "What may this management client do?",
|
|
337
|
+
options: grantable.map((entry) => ({
|
|
338
|
+
value: entry.scope,
|
|
339
|
+
label: entry.scope,
|
|
340
|
+
// The capability's own summary. A client renders these beside a pane; so does this.
|
|
341
|
+
hint: `${entry.capability} — ${entry.summary}`,
|
|
342
|
+
})),
|
|
343
|
+
initialValues: preselected,
|
|
344
|
+
required: false,
|
|
345
|
+
});
|
|
346
|
+
if (isCancel(answer)) {
|
|
347
|
+
process.stderr.write("Canceled.\n");
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
return answer as ControlPlaneScope[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Confirm a revocation at a terminal. `--yes` skips it, which is how CI runs the same command. */
|
|
354
|
+
/** What `revoke-key` did — enough for `--json` to be actionable without a second call. */
|
|
355
|
+
export interface RevokeKeyReport {
|
|
356
|
+
environment: string;
|
|
357
|
+
keyId: string;
|
|
358
|
+
/** False when no key answered to that id. Re-running a revoke is not an error. */
|
|
359
|
+
revoked: boolean;
|
|
360
|
+
/** The connection after the write, or null when nothing matched. */
|
|
361
|
+
connection: ControlPlaneConnection | null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Render a key revocation, and say plainly when it left the connection with nothing live. */
|
|
365
|
+
export function formatRevokeKeyReport(report: RevokeKeyReport, options: { json: boolean }): string {
|
|
366
|
+
if (options.json) return `${formatJsonLine({ command: "dashboard.revoke-key", ...report })}\n`;
|
|
367
|
+
if (!report.revoked) {
|
|
368
|
+
return `No key ${report.keyId} on ${report.environment}.\nRun pithy dashboard status --env ${report.environment} to list the keys.\n`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Revoking the last live key is allowed — a leaked key comes out whether or not it was the only one —
|
|
372
|
+
// but it leaves the connection denying every call, and that must be said rather than discovered.
|
|
373
|
+
const live = (report.connection?.keys ?? []).filter((key) => key.revokedAt === null && key.validUntil === null);
|
|
374
|
+
const stranded =
|
|
375
|
+
live.length === 0
|
|
376
|
+
? `That was the last live key, so ${report.environment} now denies every management call.\nRun pithy dashboard connect --env ${report.environment} to register a new one.\n`
|
|
377
|
+
: "";
|
|
378
|
+
return `Revoked ${report.keyId} on ${report.environment}.\n${stranded}${formatDone()}\n`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function confirmRevokeKey(keyId: string, env: string): Promise<void> {
|
|
382
|
+
const { confirm, isCancel } = await import("@clack/prompts");
|
|
383
|
+
const answer = await confirm({ message: `Revoke key ${keyId} on ${env}?`, initialValue: false });
|
|
384
|
+
if (isCancel(answer) || answer !== true) {
|
|
385
|
+
process.stderr.write("Canceled.\n");
|
|
386
|
+
process.exit(1);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function confirmDisconnect(env: string): Promise<void> {
|
|
391
|
+
const { confirm, isCancel } = await import("@clack/prompts");
|
|
392
|
+
const answer = await confirm({ message: `Disconnect the management client from ${env}?`, initialValue: false });
|
|
393
|
+
if (isCancel(answer) || answer !== true) {
|
|
394
|
+
process.stderr.write("Canceled.\n");
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** `pithy dashboard connect` — register a management client, or re-point the one already registered. */
|
|
400
|
+
const connect = defineCommand({
|
|
401
|
+
meta: { name: "connect", description: "Connect a management client to an environment, and prove it with a ping" },
|
|
402
|
+
args: {
|
|
403
|
+
...commonArgs,
|
|
404
|
+
worker: {
|
|
405
|
+
type: "string",
|
|
406
|
+
description: "Which worker composes the admin surface (apps/<name>). Required when the project has several",
|
|
407
|
+
},
|
|
408
|
+
"worker-url": { type: "string", description: "Override the resolved worker URL for this environment" },
|
|
409
|
+
scope: { type: "string", description: "Grant one scope: --scope manifest:read (repeatable)" },
|
|
410
|
+
update: { type: "boolean", default: false, description: "Re-point an existing connection's URL and scopes" },
|
|
411
|
+
"public-key": { type: "string", description: "Register a JWK you generated yourself — no dashboard involved" },
|
|
412
|
+
issuer: { type: "string", description: "With --public-key: the iss your own management client presents" },
|
|
413
|
+
"key-id": { type: "string", description: "With --public-key: the key id, if the JWK carries no kid" },
|
|
414
|
+
project: { type: "string", description: "Override the project name sent to the management client" },
|
|
415
|
+
},
|
|
416
|
+
// `rawArgs` because a repeated `--scope` only survives there.
|
|
417
|
+
run: ({ args, rawArgs }) =>
|
|
418
|
+
withErrorReporting(args.json, async () => {
|
|
419
|
+
const scopes = collectScopeFlags(rawArgs) as ControlPlaneScope[];
|
|
420
|
+
const offlinePath = args["public-key"] !== undefined;
|
|
421
|
+
|
|
422
|
+
// Cross-arg rules, checked here rather than by a helper: docs/STACK.md proposes
|
|
423
|
+
// requireWhen/exactlyOne, but they do not exist yet, and inventing them for one command would be
|
|
424
|
+
// a worse trade than four explicit throws.
|
|
425
|
+
if (offlinePath && args.issuer === undefined) {
|
|
426
|
+
throw new ValidationError({
|
|
427
|
+
message: "--public-key needs --issuer.",
|
|
428
|
+
action: "Pass --issuer https://<your-client> — it is the iss every one of your tokens must carry.",
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
if (!offlinePath && args.issuer !== undefined) {
|
|
432
|
+
throw new ValidationError({
|
|
433
|
+
message: "--issuer only applies with --public-key.",
|
|
434
|
+
action: "Drop --issuer. The dashboard returns the issuer a connection is registered against.",
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (!offlinePath && args["key-id"] !== undefined) {
|
|
438
|
+
throw new ValidationError({
|
|
439
|
+
message: "--key-id only applies with --public-key.",
|
|
440
|
+
action: "Drop --key-id. The dashboard mints the keypair and names the key.",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
if (args.update && args["worker-url"] === undefined && scopes.length === 0 && !offlinePath) {
|
|
444
|
+
throw new ValidationError({
|
|
445
|
+
message: "Nothing to update.",
|
|
446
|
+
action: "Pass --worker-url, --scope, or both.",
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
let publicKey: OfflinePublicKey | undefined;
|
|
451
|
+
if (offlinePath) {
|
|
452
|
+
const raw = await readFile(args["public-key"] as string, "utf8");
|
|
453
|
+
const key = parsePublicKey(raw, args["key-id"]);
|
|
454
|
+
publicKey = { ...key, issuer: args.issuer as string };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const interactive = isInteractive(args.json);
|
|
458
|
+
const projectDir = process.cwd();
|
|
459
|
+
|
|
460
|
+
// Which Worker, at what address, with the seam mounted where — resolved from the project rather
|
|
461
|
+
// than demanded. `--worker-url` still overrides (a proxy in front of the Worker has an address no
|
|
462
|
+
// config knows), and an update that is only re-pointing scopes needs no address at all.
|
|
463
|
+
//
|
|
464
|
+
// This replaces a `--worker-url` that had no fallback whatsoever: the interactive path prompted for
|
|
465
|
+
// it free-text, and the non-interactive path — the agent and CI path — simply threw.
|
|
466
|
+
//
|
|
467
|
+
// Resolve only when an address is actually needed: on any create, and on an update that is
|
|
468
|
+
// re-pointing. A **key-only update** — `--update` with no `--worker-url`, including the offline
|
|
469
|
+
// `--public-key` rotation — needs no address at all, and resolving one would demand a Worker that
|
|
470
|
+
// resolves *and* composes the seam. That regressed offline rotation for a proxy-fronted project,
|
|
471
|
+
// whose address no config knows and whose whole point is not needing us.
|
|
472
|
+
const needsAddress = !args.update || args["worker-url"] !== undefined;
|
|
473
|
+
const target = !needsAddress
|
|
474
|
+
? null
|
|
475
|
+
: await resolveConnectTarget({
|
|
476
|
+
projectDir,
|
|
477
|
+
environment: args.env,
|
|
478
|
+
...(args.worker === undefined ? {} : { worker: args.worker }),
|
|
479
|
+
...(args["worker-url"] === undefined ? {} : { workerUrl: args["worker-url"] }),
|
|
480
|
+
});
|
|
481
|
+
if (target && interactive) process.stdout.write(`${dim(describeConnectTarget(target))}\n`);
|
|
482
|
+
const workerUrl = target?.workerUrl ?? args["worker-url"];
|
|
483
|
+
// What this Worker composes, which is what the grant is derived from. Empty on a key-only update,
|
|
484
|
+
// where no address was resolved and no grant is being decided.
|
|
485
|
+
const composed = target?.worker.capabilities ?? [];
|
|
486
|
+
|
|
487
|
+
// Only on a create. On an update, no `--scope` means "leave the grant alone", and a prompt that
|
|
488
|
+
// defaulted to everything would quietly widen it.
|
|
489
|
+
//
|
|
490
|
+
// `undefined` means "not specified — use the default grant"; `[]` means "deliberately nothing".
|
|
491
|
+
// Collapsing those two is a real bug rather than a tidy-up: an operator who deselected every scope
|
|
492
|
+
// at the prompt would have been handed the full default set, `keys:rotate` included — the exact
|
|
493
|
+
// opposite of what they just said. So an explicit empty selection is passed through as empty.
|
|
494
|
+
const granted: ControlPlaneScope[] | undefined =
|
|
495
|
+
scopes.length > 0
|
|
496
|
+
? scopes
|
|
497
|
+
: interactive && !args.update
|
|
498
|
+
? await promptScopes(grantableScopes(composed), defaultGrant(composed))
|
|
499
|
+
: undefined;
|
|
500
|
+
|
|
501
|
+
// One load, two answers. `--project` overrides only the *name* sent to the client; whether this
|
|
502
|
+
// environment holds live data is the project's policy either way, and it is exactly the list that
|
|
503
|
+
// gates a destructive seed. Resolved here because this is where the CLI knows it — a management
|
|
504
|
+
// client guessing from the name gives an adopter who calls production `live` the safe-looking
|
|
505
|
+
// treatment on their real users.
|
|
506
|
+
const config = await loadProject(projectDir);
|
|
507
|
+
const project = args.project ?? requireProjectName(config);
|
|
508
|
+
const isProduction = isProductionEnv(args.env, config.seed?.productionEnvironments);
|
|
509
|
+
|
|
510
|
+
const report = await withRegistry(args, (registry) =>
|
|
511
|
+
connectDashboard({
|
|
512
|
+
registry,
|
|
513
|
+
project,
|
|
514
|
+
environment: args.env,
|
|
515
|
+
isProduction,
|
|
516
|
+
...(workerUrl === undefined ? {} : { workerUrl }),
|
|
517
|
+
...(target === null ? {} : { basePath: target.basePath }),
|
|
518
|
+
...(granted === undefined ? {} : { scopes: granted }),
|
|
519
|
+
// The default grant is derived from these when no `--scope` narrowed it (#248). Sent even
|
|
520
|
+
// when `granted` is set, so nothing depends on which branch above ran.
|
|
521
|
+
capabilities: composed,
|
|
522
|
+
update: args.update,
|
|
523
|
+
...(publicKey === undefined
|
|
524
|
+
? {
|
|
525
|
+
client: httpDashboardClient(args.origin === undefined ? {} : { origin: args.origin }),
|
|
526
|
+
authorize,
|
|
527
|
+
}
|
|
528
|
+
: { publicKey }),
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
process.stdout.write(formatConnectReport(report, { json: args.json }));
|
|
532
|
+
}),
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
/** `pithy dashboard rotate` — append a successor key and prove it. Never expires the old one. */
|
|
536
|
+
const rotate = defineCommand({
|
|
537
|
+
meta: { name: "rotate", description: "Register a successor key and prove it with a ping (the old key stays live)" },
|
|
538
|
+
args: commonArgs,
|
|
539
|
+
run: ({ args }) =>
|
|
540
|
+
withErrorReporting(args.json, async () => {
|
|
541
|
+
const report = await withRegistry(args, (registry) =>
|
|
542
|
+
rotateDashboardKey({
|
|
543
|
+
registry,
|
|
544
|
+
client: httpDashboardClient(args.origin === undefined ? {} : { origin: args.origin }),
|
|
545
|
+
environment: args.env,
|
|
546
|
+
authorize,
|
|
547
|
+
}),
|
|
548
|
+
);
|
|
549
|
+
process.stdout.write(formatRotateReport(report, { json: args.json }));
|
|
550
|
+
}),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
/** `pithy dashboard disconnect` — delete the registration. Local, immediate, and unilateral. */
|
|
554
|
+
const disconnect = defineCommand({
|
|
555
|
+
meta: { name: "disconnect", description: "Revoke this environment's management-client connection" },
|
|
556
|
+
args: {
|
|
557
|
+
...commonArgs,
|
|
558
|
+
yes: { type: "boolean", default: false, description: "Skip the confirmation (how CI runs it)" },
|
|
559
|
+
local: { type: "boolean", default: false, description: "Delete the row without telling the management client" },
|
|
560
|
+
},
|
|
561
|
+
run: ({ args }) =>
|
|
562
|
+
withErrorReporting(args.json, async () => {
|
|
563
|
+
if (isInteractive(args.json) && !args.yes) await confirmDisconnect(args.env);
|
|
564
|
+
|
|
565
|
+
const report = await withRegistry(args, (registry) =>
|
|
566
|
+
disconnectDashboard({
|
|
567
|
+
registry,
|
|
568
|
+
environment: args.env,
|
|
569
|
+
// `--local` skips the courtesy call entirely. The revocation is identical either way; this
|
|
570
|
+
// only avoids a browser sign-in when the dashboard is unreachable or no longer wanted.
|
|
571
|
+
...(args.local
|
|
572
|
+
? {}
|
|
573
|
+
: {
|
|
574
|
+
client: httpDashboardClient(args.origin === undefined ? {} : { origin: args.origin }),
|
|
575
|
+
authorize,
|
|
576
|
+
}),
|
|
577
|
+
}),
|
|
578
|
+
);
|
|
579
|
+
process.stdout.write(formatDisconnectReport(report, { json: args.json }));
|
|
580
|
+
}),
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* `pithy dashboard revoke-key` — pull one key without tearing down the connection.
|
|
585
|
+
*
|
|
586
|
+
* The narrow instrument beside `disconnect`'s blunt one. A management client that leaked a single key
|
|
587
|
+
* does not need the whole connection rebuilt, and an adopter who has to choose between "do nothing" and
|
|
588
|
+
* "reconnect everything" will pick the wrong one under pressure.
|
|
589
|
+
*
|
|
590
|
+
* Purely local: it writes `revokedAt` into their own D1 and tells nobody. That is the point — revocation
|
|
591
|
+
* that needed the other side's cooperation would not be revocation.
|
|
592
|
+
*/
|
|
593
|
+
const revokeKey = defineCommand({
|
|
594
|
+
meta: { name: "revoke-key", description: "Revoke one registered key immediately, leaving the connection in place" },
|
|
595
|
+
args: {
|
|
596
|
+
...commonArgs,
|
|
597
|
+
"key-id": { type: "string", required: true, description: "The key to revoke, as reported by `status`" },
|
|
598
|
+
yes: { type: "boolean", default: false, description: "Skip the confirmation (how CI runs it)" },
|
|
599
|
+
},
|
|
600
|
+
run: ({ args }) =>
|
|
601
|
+
withErrorReporting(args.json, async () => {
|
|
602
|
+
if (isInteractive(args.json) && !args.yes) await confirmRevokeKey(args["key-id"], args.env);
|
|
603
|
+
|
|
604
|
+
const report = await withRegistry(args, async (registry) => {
|
|
605
|
+
const updated = await registry.revokeKey(args["key-id"], new Date());
|
|
606
|
+
return { environment: args.env, keyId: args["key-id"], revoked: updated !== null, connection: updated };
|
|
607
|
+
});
|
|
608
|
+
process.stdout.write(formatRevokeKeyReport(report, { json: args.json }));
|
|
609
|
+
}),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
/** `pithy dashboard status` — what is registered, and (with `--verify`) whether it still answers. */
|
|
613
|
+
const status = defineCommand({
|
|
614
|
+
meta: { name: "status", description: "Report this environment's connection, its keys, and their ages" },
|
|
615
|
+
args: {
|
|
616
|
+
...commonArgs,
|
|
617
|
+
verify: { type: "boolean", default: false, description: "Prove the connection with a signed ping" },
|
|
618
|
+
},
|
|
619
|
+
run: ({ args }) =>
|
|
620
|
+
withErrorReporting(args.json, async () => {
|
|
621
|
+
const client = args.verify
|
|
622
|
+
? httpDashboardClient(args.origin === undefined ? {} : { origin: args.origin })
|
|
623
|
+
: undefined;
|
|
624
|
+
|
|
625
|
+
const report = await withRegistry(args, async (registry) =>
|
|
626
|
+
dashboardStatus({
|
|
627
|
+
registry,
|
|
628
|
+
environment: args.env,
|
|
629
|
+
// Looking is free; proving costs a browser sign-in. So the probe is opt-in, and its absence
|
|
630
|
+
// is reported as `unverified` rather than guessed at.
|
|
631
|
+
...(client === undefined
|
|
632
|
+
? {}
|
|
633
|
+
: {
|
|
634
|
+
verify: async (connection) => {
|
|
635
|
+
const token = await authorize(client);
|
|
636
|
+
return client.verifyConnection(token, connection.id, connection.workerUrl);
|
|
637
|
+
},
|
|
638
|
+
}),
|
|
639
|
+
}),
|
|
640
|
+
);
|
|
641
|
+
process.stdout.write(formatStatusReport(report, { json: args.json }));
|
|
642
|
+
}),
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
export default defineCommand({
|
|
646
|
+
meta: {
|
|
647
|
+
name: "dashboard",
|
|
648
|
+
description: "Connect, rotate, revoke, and inspect a management client (control-plane seam)",
|
|
649
|
+
},
|
|
650
|
+
subCommands: { connect, rotate, "revoke-key": revokeKey, disconnect, status },
|
|
651
|
+
});
|