@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,406 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import type { D1Database } from "@cloudflare/workers-types";
|
|
7
|
+
import { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import { parse } from "comment-json";
|
|
9
|
+
import { loadProject, requireProjectName } from "../project/config";
|
|
10
|
+
import { type CapabilitySet, isUnknown, projectCapabilitySet, type UnknownSet } from "../project/workerScope";
|
|
11
|
+
import { discoverWorkers } from "../project/workers";
|
|
12
|
+
import { AUDIT_DESTINATION_ENV } from "../provision/resources";
|
|
13
|
+
import { createCliLogger } from "../terminal/logger";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one way a CLI command records an audit event.
|
|
17
|
+
*
|
|
18
|
+
* Auditing is a **first-class deliverable, not an optional extra** (CLAUDE.md §Security): every
|
|
19
|
+
* security-relevant action a command takes — minting a credential, changing a schema, deploying,
|
|
20
|
+
* creating or destroying infrastructure — should leave a record of what happened and who did it. But
|
|
21
|
+
* `@pithy-sh/audit` is an optional capability an adopter may not have composed, and the CLI must never
|
|
22
|
+
* hard-depend on it. This module reconciles the two: commands call `emit` unconditionally, and the
|
|
23
|
+
* helper decides whether there is anywhere to write.
|
|
24
|
+
*
|
|
25
|
+
* Three things must all hold for an event to land: the project composes the `audit` capability, the
|
|
26
|
+
* package resolves from the project's own install (loaded by guarded dynamic import), and the target
|
|
27
|
+
* environment's app database is resolvable. When any is missing the returned emitter is a no-op, so a
|
|
28
|
+
* call site never needs a conditional. Writes are **non-fatal** — a failed audit write is logged and
|
|
29
|
+
* swallowed, never allowed to break the command it was recording.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The audit event a CLI command describes: every field except the actor, which is resolved from the CF
|
|
34
|
+
* token rather than passed. This is `@pithy-sh/audit`'s own `CliAuditEvent`, referenced by a **type-only**
|
|
35
|
+
* import so it is erased at runtime — the CLI keeps its guarded dynamic import for the *runtime* path and
|
|
36
|
+
* gains no hard dependency, while the event shape stays the single one defined in the audit package. A
|
|
37
|
+
* parallel interface here would silently drift from `AuditEventInput` the first time a field is added.
|
|
38
|
+
*/
|
|
39
|
+
export type CliAuditEvent = import("@pithy-sh/audit/src/cli/emitFromCLI").CliAuditEvent;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where a CLI-recorded event came from. Referenced **type-only**, like {@link CliAuditEvent} above and
|
|
43
|
+
* for the same reason: the shape stays the single one the audit package defines, while the CLI keeps
|
|
44
|
+
* its guarded dynamic import for the runtime path and gains no hard dependency on an optional package.
|
|
45
|
+
*/
|
|
46
|
+
type AuditOrigin = import("@pithy-sh/audit/src/recorder").AuditOrigin;
|
|
47
|
+
|
|
48
|
+
/** Who a CLI-recorded event is attributed to. Type-only for the same reason as the two above. */
|
|
49
|
+
type ResolvedActor = import("@pithy-sh/audit/src/cli/resolveActor").ResolvedActor;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The actor recorded when no Cloudflare token is at hand to name one.
|
|
53
|
+
*
|
|
54
|
+
* `system`, with a note — deliberately the same shape `resolveActor` falls back to when a token cannot
|
|
55
|
+
* be attributed, so one filter finds every unattributed row rather than two. A person at a terminal is
|
|
56
|
+
* not a system job, and this does not claim otherwise: it says the writer could not be named, which is
|
|
57
|
+
* the true and useful thing. A command reaching a real Cloudflare resource always has a token, so this
|
|
58
|
+
* is the local-only case — `pithy dashboard connect --env dev` against a Miniflare store touches no
|
|
59
|
+
* account, and demanding an account credential to record it would be the wrong dependency.
|
|
60
|
+
*/
|
|
61
|
+
const UNATTRIBUTED: ResolvedActor = {
|
|
62
|
+
actorType: "system",
|
|
63
|
+
actorId: null,
|
|
64
|
+
metadata: {
|
|
65
|
+
actorResolutionFailed: true,
|
|
66
|
+
note: "No Cloudflare API token in this environment, so the operator at the terminal could not be named.",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Records an audit event. Always safe to call: a no-op when auditing is unavailable, never throws.
|
|
72
|
+
* The CLI counterpart of core's in-Worker {@link import("@pithy-sh/core/src/audit/recorder").AuditEmit}
|
|
73
|
+
* seam, and deliberately the same shape — an always-callable function rather than an optional one — so a
|
|
74
|
+
* call site never guards. The two differ only in their event type: the in-Worker emitter supplies the
|
|
75
|
+
* actor, while the CLI resolves it from the token.
|
|
76
|
+
*/
|
|
77
|
+
export type CliAuditEmit = (event: CliAuditEvent) => Promise<void>;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The emitter returned whenever auditing is unavailable: accept the event and drop it. The CLI analog of
|
|
81
|
+
* core's {@link noopEmit}, which cannot be reused directly only because the two seams carry different event
|
|
82
|
+
* types — the intent, and the guarantee that calling it is always safe, are identical.
|
|
83
|
+
*/
|
|
84
|
+
const NO_OP: CliAuditEmit = async () => {};
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The emitter returned when the project's capability set is **unknowable** — a Worker's `pithy.config.ts`
|
|
88
|
+
* would not load, so whether this project composes `audit` cannot be answered from here (#455).
|
|
89
|
+
*
|
|
90
|
+
* It is deliberately *not* {@link NO_OP}. Eight commands built their emitter from a capability list
|
|
91
|
+
* flattened by `.catch(() => [])`, which made "cannot tell" indistinguishable from "this project never
|
|
92
|
+
* composed audit" — so one uninstalled capability package in a CI checkout let `pithy deploy --env prod`
|
|
93
|
+
* ship every Worker, print `Done.`, exit 0, and write no row for a project that audits. This module's own
|
|
94
|
+
* standard settles it: **an audit trail you cannot tell is broken is worse than none.** So every event
|
|
95
|
+
* that would have been recorded names itself on the way past, on the same logger a dropped write uses.
|
|
96
|
+
*
|
|
97
|
+
* It still never throws. Auditing must not break the command it records — it just stops being silent.
|
|
98
|
+
*/
|
|
99
|
+
function unrecordable(reason: string): CliAuditEmit {
|
|
100
|
+
const log = createCliLogger().child("audit");
|
|
101
|
+
return async (event) => {
|
|
102
|
+
log.error("audit event not recorded", {
|
|
103
|
+
action: event.action,
|
|
104
|
+
// The set's own diagnosis, which names the worker. Inventing one here is how the absent-config case
|
|
105
|
+
// came to be reported as "a config will not load", pointing at a file that does not exist (#454).
|
|
106
|
+
reason,
|
|
107
|
+
action_required: "Fix it — pithy doctor names the fault — and re-run to record this event.",
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The `wrangler.jsonc` slice the audit target is resolved from: the app database, per environment. */
|
|
113
|
+
interface WranglerAppConfig {
|
|
114
|
+
d1_databases?: { binding: string; database_id?: string }[];
|
|
115
|
+
env?: Record<string, { d1_databases?: { binding: string; database_id?: string }[] } | undefined>;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** One Worker's `DB` database id for an environment, or undefined when the file or the id is absent. */
|
|
119
|
+
async function databaseIdIn(workerDir: string, env: string): Promise<string | undefined> {
|
|
120
|
+
try {
|
|
121
|
+
const config = parse(await readFile(join(workerDir, "wrangler.jsonc"), "utf8")) as unknown as WranglerAppConfig;
|
|
122
|
+
const stanza = env === "dev" ? config : config.env?.[env];
|
|
123
|
+
return stanza?.d1_databases?.find((database) => database.binding === "DB")?.database_id;
|
|
124
|
+
} catch {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The app database's id for an environment — where audit rows live. Every Worker lives in `apps/<name>/`
|
|
131
|
+
* with its own `wrangler.jsonc`, so the id comes from a Worker's file: `dev` reads its top-level bindings,
|
|
132
|
+
* every other environment its `env.<name>` stanza.
|
|
133
|
+
*
|
|
134
|
+
* **Which Worker?** `worker` names one when the caller has an explicit `--worker`. Otherwise this takes the
|
|
135
|
+
* first discovered Worker that declares a `DB` id for the environment — deliberately *not* an
|
|
136
|
+
* ambiguity error. Workers share a resource by declaring the same binding name, so every Worker with a `DB`
|
|
137
|
+
* binding points at the one app database; and auditing must never break the command it is recording, which
|
|
138
|
+
* an "ambiguous, pass --worker" throw would do the moment a project grew a second Worker. Undefined when no
|
|
139
|
+
* Worker resolves one, which leaves the emitter inert.
|
|
140
|
+
*/
|
|
141
|
+
export async function resolveAuditDatabaseId(
|
|
142
|
+
projectDir: string,
|
|
143
|
+
env: string,
|
|
144
|
+
worker?: string,
|
|
145
|
+
): Promise<string | undefined> {
|
|
146
|
+
const workers = await discoverWorkers(projectDir).catch(() => []);
|
|
147
|
+
const targets =
|
|
148
|
+
worker === undefined
|
|
149
|
+
? workers
|
|
150
|
+
: workers.filter((candidate) => candidate.name === worker || candidate.dir.endsWith(`/${worker}`));
|
|
151
|
+
for (const target of targets) {
|
|
152
|
+
const id = await databaseIdIn(target.dir, env);
|
|
153
|
+
if (id) return id;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether an env-scoped operation reaches a **remote** system. `dev` is the local Miniflare store under
|
|
160
|
+
* `.wrangler/`; every other environment resolves to a real Cloudflare resource over REST.
|
|
161
|
+
*
|
|
162
|
+
* This is the line auditing is drawn on: an action is recorded when it changes something outside the
|
|
163
|
+
* developer's machine — from their own environment, from CI, or in production — and not otherwise. A local
|
|
164
|
+
* `dev` run changes nothing shared, so there is nothing to record; and it must not *try*, because `dev`
|
|
165
|
+
* resolves to the top-level `database_id`, which is a real remote database (wrangler requires one and only
|
|
166
|
+
* emulates it locally). Auditing a local action would mean a credentialed network write into a database the
|
|
167
|
+
* action never touched.
|
|
168
|
+
*/
|
|
169
|
+
export function isRemoteEnv(env: string): boolean {
|
|
170
|
+
return env !== "dev";
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* {@link createCliAudit}, but only for operations whose target environment decides whether they are remote
|
|
175
|
+
* — the **data-plane** commands (`seed`, `migrate`, a `--drop`), which run against local Miniflare for `dev`
|
|
176
|
+
* and against real Cloudflare otherwise. A local run gets an inert emitter.
|
|
177
|
+
*
|
|
178
|
+
* Control-plane commands (`deploy`, `provision`, `feature destroy`) are remote *whatever* env
|
|
179
|
+
* is named — the env only picks which database the record lands in — so they call {@link createCliAudit}
|
|
180
|
+
* directly and are always audited.
|
|
181
|
+
*/
|
|
182
|
+
export async function createRemoteCliAudit(options: CreateCliAuditOptions): Promise<CliAuditEmit> {
|
|
183
|
+
if (!isRemoteEnv(options.env)) return NO_OP;
|
|
184
|
+
return createCliAudit(options);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Options for {@link createCliAudit}. */
|
|
188
|
+
/**
|
|
189
|
+
* The origin a CLI-recorded event carries.
|
|
190
|
+
*
|
|
191
|
+
* `worker` and `version` are **always null**, and that is the accurate answer rather than a gap: a
|
|
192
|
+
* `pithy` command runs in Node, not in a Worker, so no Worker recorded it and there is no Cloudflare
|
|
193
|
+
* build id to name. (`pithy deploy` does learn the version of the Worker it *ships*, but that is a fact
|
|
194
|
+
* about the deployed resource, not about the process writing the row, so it stays in the event's
|
|
195
|
+
* metadata where it belongs.) `options.worker` is not the origin either — it is
|
|
196
|
+
* a *lookup filter* for finding the audit database, so writing it here would attribute
|
|
197
|
+
* `pithy migrate --worker api` to the `api` Worker, which did nothing.
|
|
198
|
+
*
|
|
199
|
+
* The project is resolved the same way every other name-composing command resolves it, and a failure to
|
|
200
|
+
* resolve is reported as `null` rather than thrown: auditing must never break the command it records,
|
|
201
|
+
* and a project with no `name` is exactly the broken state a `pithy doctor` run needs to survive.
|
|
202
|
+
*/
|
|
203
|
+
async function cliOrigin(projectDir: string, environment: string | null): Promise<AuditOrigin> {
|
|
204
|
+
try {
|
|
205
|
+
return { project: requireProjectName(await loadProject(projectDir)), environment, worker: null, version: null };
|
|
206
|
+
} catch {
|
|
207
|
+
return { project: null, environment, worker: null, version: null };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* How a CLI audit reaches a database, and who it can name as the actor. Two shapes, because the second
|
|
213
|
+
* one is what a caller holding an open handle actually has.
|
|
214
|
+
*
|
|
215
|
+
* **Resolved** is the ordinary case: `clients` and `apiToken` are both required, the audit database id
|
|
216
|
+
* is read from a Worker's `wrangler.jsonc`, and the row is written over REST.
|
|
217
|
+
*
|
|
218
|
+
* **Injected** is for a command that already has the adopter's database open and must write *into that
|
|
219
|
+
* one*. `pithy dashboard` is the case (#294): its lifecycle events record a row it just wrote through
|
|
220
|
+
* the connection registry, and an event that landed in a separately-resolved database would be a record
|
|
221
|
+
* of a write that did not happen there. On `dev` those are not even the same store — the registry's
|
|
222
|
+
* handle is the local Miniflare one and a resolved id names the real remote database — so resolving
|
|
223
|
+
* again would put the event somewhere the action never touched.
|
|
224
|
+
*
|
|
225
|
+
* With a handle injected the Cloudflare pair becomes optional, because it is no longer what finds the
|
|
226
|
+
* database — it only names the actor, and a purely local action legitimately has neither. Absent, the
|
|
227
|
+
* row is attributed to {@link UNATTRIBUTED}. The union is what keeps that from loosening the ordinary
|
|
228
|
+
* case: drop `clients` without injecting a database and it does not compile.
|
|
229
|
+
*/
|
|
230
|
+
export type CliAuditTarget =
|
|
231
|
+
| {
|
|
232
|
+
/** Write into this already-open database rather than resolving one from `wrangler.jsonc`. */
|
|
233
|
+
database: D1Database;
|
|
234
|
+
/** Cloudflare clients, used only to name the actor here. Optional: a local action has none. */
|
|
235
|
+
clients?: CloudflareClients;
|
|
236
|
+
/** The active CF API token — the actor's identity. Optional for the same reason. */
|
|
237
|
+
apiToken?: string;
|
|
238
|
+
}
|
|
239
|
+
| {
|
|
240
|
+
/** Not injected, so the database is resolved from the project's `wrangler.jsonc`. */
|
|
241
|
+
database?: undefined;
|
|
242
|
+
/** The configured Cloudflare clients, used for the D1 write and to resolve the actor behind the token. */
|
|
243
|
+
clients: CloudflareClients;
|
|
244
|
+
/** The active CF API token — the actor's identity (a person locally, the CI token in automation). */
|
|
245
|
+
apiToken: string;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
/** Everything a CLI audit needs beyond {@link CliAuditTarget}: what is being recorded, and where it belongs. */
|
|
249
|
+
export interface CliAuditContext {
|
|
250
|
+
/** The project root — the parent of `apps/`, whose Workers' `wrangler.jsonc` resolve the audit database. */
|
|
251
|
+
projectDir: string;
|
|
252
|
+
/**
|
|
253
|
+
* Which database audit rows are **written to** — a routing choice, not a statement about the action.
|
|
254
|
+
*
|
|
255
|
+
* These are two different things and conflating them put a false value in the trail. Eight commands
|
|
256
|
+
* hardcode this to `dev` precisely *because* they span environments: a provisioning run touches every
|
|
257
|
+
* managed environment at once, and `pithy feature` deliberately writes to the project's durable
|
|
258
|
+
* database rather than the feature's, which does not exist yet at `provision` and is deleted by
|
|
259
|
+
* `destroy`. Recording that routing choice as the environment acted on would claim every production
|
|
260
|
+
* credential write happened in dev.
|
|
261
|
+
*/
|
|
262
|
+
env: string;
|
|
263
|
+
/**
|
|
264
|
+
* The environment the command acts on, recorded in each row's `environment` column.
|
|
265
|
+
*
|
|
266
|
+
* Omit it when one invocation spans several environments — `null` reads as "not recorded", which is
|
|
267
|
+
* true, where naming one of them is not. Such a command should instead set `environment` on each
|
|
268
|
+
* event it emits, which is where the real answer is known.
|
|
269
|
+
*/
|
|
270
|
+
actedOn?: string | null;
|
|
271
|
+
/**
|
|
272
|
+
* The capabilities in play — auditing is wired only when `audit` is among them.
|
|
273
|
+
*
|
|
274
|
+
* An {@link UnknownSet} is the third state and is not `[]`: it says the project's capability set could
|
|
275
|
+
* not be determined, and it produces a loud emitter carrying that set's own diagnosis rather than a
|
|
276
|
+
* silent one. See {@link unrecordable}.
|
|
277
|
+
*/
|
|
278
|
+
capabilities: CapabilitySet;
|
|
279
|
+
/** Restrict the audit-database lookup to this Worker (a command's `--worker`). Optional. */
|
|
280
|
+
worker?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Everything {@link createCliAudit} takes: what is being recorded, and how it reaches a database. */
|
|
284
|
+
export type CreateCliAuditOptions = CliAuditContext & CliAuditTarget;
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Build the audit emitter for a command. Returns a no-op emitter — never null — so call sites stay free
|
|
288
|
+
* of `if (audit)` branching; that unconditional shape is what keeps auditing from being quietly dropped
|
|
289
|
+
* as commands are added. The actor is resolved once and cached, so a command emitting several events
|
|
290
|
+
* costs one identity lookup.
|
|
291
|
+
*/
|
|
292
|
+
export async function createCliAudit(options: CreateCliAuditOptions): Promise<CliAuditEmit> {
|
|
293
|
+
const capabilities = options.capabilities;
|
|
294
|
+
if (isUnknown(capabilities)) return unrecordable(capabilities.unknown);
|
|
295
|
+
if (!capabilities.some((capability) => capability.name === "audit")) return NO_OP;
|
|
296
|
+
|
|
297
|
+
// An injected handle skips the lookup entirely — it is already the database the action wrote to, and
|
|
298
|
+
// resolving a second one is how an event ends up recorded against a store nothing touched.
|
|
299
|
+
const database =
|
|
300
|
+
options.database ??
|
|
301
|
+
(await (async () => {
|
|
302
|
+
const databaseId = await resolveAuditDatabaseId(options.projectDir, options.env, options.worker);
|
|
303
|
+
return databaseId === undefined ? undefined : (options.clients?.d1(databaseId) as unknown as D1Database);
|
|
304
|
+
})());
|
|
305
|
+
if (!database) return NO_OP;
|
|
306
|
+
|
|
307
|
+
let emitFromCLI: typeof import("@pithy-sh/audit/src/cli/emitFromCLI").emitFromCLI;
|
|
308
|
+
let createCachedActorResolver: typeof import("@pithy-sh/audit/src/cli/resolveActor").createCachedActorResolver;
|
|
309
|
+
try {
|
|
310
|
+
({ emitFromCLI } = await import("@pithy-sh/audit/src/cli/emitFromCLI"));
|
|
311
|
+
({ createCachedActorResolver } = await import("@pithy-sh/audit/src/cli/resolveActor"));
|
|
312
|
+
} catch {
|
|
313
|
+
return NO_OP; // audit isn't installed in this project — nothing to record through.
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const origin = await cliOrigin(options.projectDir, options.actedOn ?? null);
|
|
317
|
+
// Both scopes, because the token decides which one is valid: a `cfut_*` token reads `/user/*`, a
|
|
318
|
+
// `cfat_*` token reads `/accounts/{id}/tokens/*` and is rejected by every user endpoint.
|
|
319
|
+
//
|
|
320
|
+
// No token means nothing to read either scope with, and the event is written unattributed rather than
|
|
321
|
+
// dropped: an unnamed record of a real change beats no record of it.
|
|
322
|
+
const clients = options.clients;
|
|
323
|
+
const apiToken = options.apiToken;
|
|
324
|
+
const resolveActor =
|
|
325
|
+
clients && apiToken
|
|
326
|
+
? createCachedActorResolver(apiToken, { user: clients.user(), accountTokens: clients.accountTokens() })
|
|
327
|
+
: async () => UNATTRIBUTED;
|
|
328
|
+
// Surface a dropped audit write through the CLI logger. Without this a lost record is invisible —
|
|
329
|
+
// and an audit trail you cannot tell is broken is worse than none.
|
|
330
|
+
const log = createCliLogger().child("audit");
|
|
331
|
+
|
|
332
|
+
return async (event) => {
|
|
333
|
+
try {
|
|
334
|
+
await emitFromCLI(
|
|
335
|
+
database,
|
|
336
|
+
{
|
|
337
|
+
action: event.action,
|
|
338
|
+
outcome: event.outcome,
|
|
339
|
+
severity: event.severity ?? "info",
|
|
340
|
+
...(event.resourceType !== undefined ? { resourceType: event.resourceType } : {}),
|
|
341
|
+
...(event.resourceId !== undefined ? { resourceId: event.resourceId } : {}),
|
|
342
|
+
// A per-event environment beats the emitter-wide one, for the commands that span several in
|
|
343
|
+
// one run. Passed as a first-class origin field, never back into `metadata` — that bag is
|
|
344
|
+
// where this information used to hide, unqueryable and set by only some emitters.
|
|
345
|
+
...(event.environment !== undefined ? { environment: event.environment } : {}),
|
|
346
|
+
metadata: { ...(event.metadata ?? {}) },
|
|
347
|
+
},
|
|
348
|
+
await resolveActor(),
|
|
349
|
+
{ origin, onError: (error: unknown) => log.error("audit event dropped", { error }) },
|
|
350
|
+
);
|
|
351
|
+
} catch (error) {
|
|
352
|
+
// Resolving the actor can fail too (a revoked token, a network blip). Auditing must never break
|
|
353
|
+
// the command it records, so this is the outer belt to emitFromCLI's own braces.
|
|
354
|
+
log.error("audit event dropped", { error });
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Everything {@link createProjectCliAudit} needs: where the project is, and who is acting. */
|
|
360
|
+
export interface ProjectCliAuditOptions {
|
|
361
|
+
/** The project root — the parent of `apps/`. */
|
|
362
|
+
projectDir: string;
|
|
363
|
+
/** The Cloudflare account id. Absent or empty leaves the emitter inert — there is nowhere to write. */
|
|
364
|
+
accountId: string | undefined;
|
|
365
|
+
/** The active CF API token — the actor's identity. Absent or empty leaves the emitter inert. */
|
|
366
|
+
apiToken: string | undefined;
|
|
367
|
+
/**
|
|
368
|
+
* Which database rows land in — a routing choice, not a statement about the action. Defaults to `"dev"`,
|
|
369
|
+
* the convention for a command that spans every managed environment in one run and so has no single
|
|
370
|
+
* environment to key on. See {@link CliAuditContext.env}.
|
|
371
|
+
*/
|
|
372
|
+
env?: string;
|
|
373
|
+
/** The environment acted on, when one invocation names exactly one. See {@link CliAuditContext.actedOn}. */
|
|
374
|
+
actedOn?: string | null;
|
|
375
|
+
/** Restrict the audit-database lookup to this Worker (a command's `--worker`). */
|
|
376
|
+
worker?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The audit emitter for a command that records against the **project as a whole** — `deploy`, and the
|
|
381
|
+
* provisioning commands (`email`, `media`, `payments`, `storage`, `support`, `testers`, `turnstile`).
|
|
382
|
+
*
|
|
383
|
+
* One helper rather than eight copies, which is the point (#455). Each of those commands carried its own
|
|
384
|
+
* three-line `buildAudit` ending in `.catch(() => [])`, and every copy folded "a Worker config will not
|
|
385
|
+
* load" into "this project composed nothing" — so a project that audits shipped unaudited, exited 0, and
|
|
386
|
+
* said nothing. Threading the third state is a property of the *set* of call sites, exactly like a
|
|
387
|
+
* migration order or a table prefix: it is only true if no copy is left behind, so there is one copy.
|
|
388
|
+
*
|
|
389
|
+
* Capabilities come from {@link projectCapabilitySet} — `audit` composed by any Worker means the project
|
|
390
|
+
* has a trail, and an unknowable set reaches {@link createCliAudit} carrying the reason it is unknowable.
|
|
391
|
+
*/
|
|
392
|
+
export async function createProjectCliAudit(options: ProjectCliAuditOptions): Promise<CliAuditEmit> {
|
|
393
|
+
const { accountId, apiToken } = options;
|
|
394
|
+
// No credentials is not the same fact and is not this module's to shout about: there is no REST client
|
|
395
|
+
// to write a row with, and every one of these commands refuses on the same absence a moment later.
|
|
396
|
+
if (!accountId || !apiToken) return NO_OP;
|
|
397
|
+
return createCliAudit({
|
|
398
|
+
projectDir: options.projectDir,
|
|
399
|
+
env: options.env ?? AUDIT_DESTINATION_ENV,
|
|
400
|
+
...(options.actedOn !== undefined ? { actedOn: options.actedOn } : {}),
|
|
401
|
+
capabilities: await projectCapabilitySet(options.projectDir),
|
|
402
|
+
...(options.worker !== undefined ? { worker: options.worker } : {}),
|
|
403
|
+
clients: new CloudflareClients({ accountId, apiToken }),
|
|
404
|
+
apiToken,
|
|
405
|
+
});
|
|
406
|
+
}
|
package/src/bin.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
// Type-only, so it is erased and does not reach citty at runtime — the import discipline below the
|
|
7
|
+
// `NO_COLOR` line is about evaluation order, and an erased import has none.
|
|
8
|
+
import type { ArgsDef, CommandDef } from "citty";
|
|
9
|
+
import { wantsVersion } from "./rootFlags";
|
|
10
|
+
import { colorEnabled } from "./terminal/style";
|
|
11
|
+
|
|
12
|
+
const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
13
|
+
version: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const argv = process.argv.slice(2);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hand citty the one color lever it reads, before it is loaded.
|
|
20
|
+
*
|
|
21
|
+
* citty renders its own help and consults none of Pithy's color rule — not `isTTY`, not `NO_COLOR` set to
|
|
22
|
+
* any value, not `FORCE_COLOR`. It latches a single private flag at import time from
|
|
23
|
+
* `NO_COLOR === "1" || TERM === "dumb" || TEST || CI`. So `pithy --help | cat` used to write escape codes
|
|
24
|
+
* into a pipe while every other Pithy surface went plain (docs/CLI.md §3.4).
|
|
25
|
+
*
|
|
26
|
+
* `terminal/style` stays the single authority: it has already latched its own decision by the time this
|
|
27
|
+
* line runs, so mutating the environment now cannot change Pithy's own output — it only translates that
|
|
28
|
+
* decision into the vocabulary citty understands.
|
|
29
|
+
*
|
|
30
|
+
* **One direction only.** When Pithy says color is off, citty is told so. The reverse — Pithy says on
|
|
31
|
+
* (a TTY, or `FORCE_COLOR`) while citty suppresses because `CI` or `TERM=dumb` is set — is left alone.
|
|
32
|
+
* Turning it back on would mean deleting `CI` from the environment of this process and every child
|
|
33
|
+
* wrangler/bun it spawns, which is a far larger lie than plain help in a CI log. Leaking ANSI into a pipe
|
|
34
|
+
* corrupts output someone is parsing; plain text never does.
|
|
35
|
+
*
|
|
36
|
+
* The variable is inherited by every child the CLI spawns (wrangler, bun), which is the right answer for
|
|
37
|
+
* the same reason: if this run's output is being piped, so is theirs.
|
|
38
|
+
*
|
|
39
|
+
* Every import that reaches citty is therefore dynamic and below this line. Static imports are hoisted
|
|
40
|
+
* and evaluated first, which would set this after citty had already decided.
|
|
41
|
+
*/
|
|
42
|
+
if (!colorEnabled()) process.env.NO_COLOR = "1";
|
|
43
|
+
|
|
44
|
+
if (wantsVersion(argv)) {
|
|
45
|
+
// citty answers its version builtin only when it is the sole argument, so `pithy add --version` would
|
|
46
|
+
// run `add`. docs/CLI.md §1.2 promises the flag works on any command; see `rootFlags.ts` for the rule.
|
|
47
|
+
process.stdout.write(`${version}\n`);
|
|
48
|
+
} else {
|
|
49
|
+
const { handleHiddenFlags } = await import("./commands/alias");
|
|
50
|
+
/**
|
|
51
|
+
* The hidden root flags (`--pithier`, `--pithiest`) are handled before citty parses, since they are not
|
|
52
|
+
* subcommand flags. When one is handled, the command is done — skip both the dispatch and the notifier.
|
|
53
|
+
*/
|
|
54
|
+
if (await handleHiddenFlags(argv)) {
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const { runMain, showUsage } = await import("citty");
|
|
59
|
+
const { main } = await import("./main");
|
|
60
|
+
const { ownNamesOnly, usageTarget } = await import("./dispatch");
|
|
61
|
+
const { showRootUsage } = await import("./help/rootUsage");
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One tree, for the walk and for citty, answering only to the names it declares.
|
|
65
|
+
*
|
|
66
|
+
* citty resolves a subcommand with `name in subCommands`, so an object literal answered `valueOf`,
|
|
67
|
+
* `constructor` and every other `Object.prototype` member — with a raw `TypeError` for one and a
|
|
68
|
+
* silent exit 0 for another. Hardened here rather than at each `defineCommand`, and hardened *before*
|
|
69
|
+
* the walk so both readers see the same tree. See `dispatch.ts`.
|
|
70
|
+
*/
|
|
71
|
+
const root = ownNamesOnly(main);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Ours at the root, citty's everywhere below it — and handed to *both* places a root screen comes from.
|
|
75
|
+
*
|
|
76
|
+
* `usageTarget` is one of them. The other is citty itself: `runMain` catches every `CLIError` into
|
|
77
|
+
* `showUsage(...await resolveSubCommand(cmd, rawArgs))`, and `resolveSubCommand` answers `[root,
|
|
78
|
+
* undefined]` for a name it cannot resolve — so `pithy nonsense` prints the *root* screen. Wire only
|
|
79
|
+
* the first and the CLI ships two root screens that drift, one of them reachable only by making a
|
|
80
|
+
* mistake. `parent === undefined` is exactly the root at both call sites (`dispatch.ts`).
|
|
81
|
+
*/
|
|
82
|
+
const usageFor = async <T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<void> =>
|
|
83
|
+
parent === undefined ? showRootUsage(cmd) : showUsage(cmd, parent);
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A command that names no action is asking what it can do, so it is answered and the run succeeds.
|
|
87
|
+
*
|
|
88
|
+
* Before citty, because citty cannot be told otherwise: `runCommand` throws `E_NO_COMMAND` for the
|
|
89
|
+
* root and for every group, and `runMain` catches it into usage + the message + `process.exit(1)`.
|
|
90
|
+
* So bare `pithy` printed a complete command list and then said `No command specified.` under it,
|
|
91
|
+
* exiting non-zero — which fails `pithy && next`, fails a CI step, and under `bun run` adds a line
|
|
92
|
+
* naming a script rather than anything the user did (#319). `pithy nonsense` still reaches citty and
|
|
93
|
+
* is still refused: an unrecognised name is a mistake, not a question. See `dispatch.ts`.
|
|
94
|
+
*/
|
|
95
|
+
const usage = await usageTarget(root, argv);
|
|
96
|
+
if (usage) await usageFor(usage.cmd, usage.parent);
|
|
97
|
+
else await runMain(root, { showUsage: usageFor });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The end-of-command update check (docs/CLI.md §5). Gated to an interactive terminal at the call site: a
|
|
102
|
+
* piped/CI run (non-TTY) — every test that spawns this bin, and every scripted invocation — never touches
|
|
103
|
+
* the network or the state file. `doctor` runs its own fresh check, so it does not also get the background
|
|
104
|
+
* one. It is fire-and-forget and never delays exit. Imported dynamically like everything else below the
|
|
105
|
+
* color line: the notifier does not reach citty today, and a later edit that made it should not silently
|
|
106
|
+
* put citty's import back above the one line that has to run first.
|
|
107
|
+
*/
|
|
108
|
+
if (process.stderr.isTTY && !process.env.PITHY_NO_UPDATE_NOTIFIER && argv[0] !== "doctor") {
|
|
109
|
+
const { runUpdateNotifier } = await import("./notifier/notify");
|
|
110
|
+
runUpdateNotifier({ installedVersion: version });
|
|
111
|
+
}
|