@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,260 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import type { CloudflareClients } from "@pithy-sh/cloudflare/src/client/clients";
|
|
8
|
+
import type { CloudflareWorkflowsClient } from "@pithy-sh/cloudflare/src/workflows/workflowsClient";
|
|
9
|
+
import { InternalError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
10
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
11
|
+
import type { WorkflowHostTemplate } from "@pithy-sh/core/src/workflow/host";
|
|
12
|
+
import { parse } from "comment-json";
|
|
13
|
+
import { runWrangler } from "../project/wrangler";
|
|
14
|
+
import { capabilityLoadError } from "./loadFailure";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The live vector provisioner — the Cloudflare + wrangler implementation behind `@pithy-sh/vector`'s
|
|
18
|
+
* `VectorProvisioner` seam. Control-plane calls go through `@pithy-sh/cloudflare` (CLAUDE.md: the CF API
|
|
19
|
+
* only via that client); the worker deploy shells out to wrangler with the bootstrap token.
|
|
20
|
+
*
|
|
21
|
+
* `@pithy-sh/vector` is an **optional** capability, so the CLI must not hard-depend on it. Types arrive
|
|
22
|
+
* through type-only imports (erased at build) and every runtime value through {@link loadVector} — a guarded
|
|
23
|
+
* dynamic import that turns "the package isn't installed" into an actionable error rather than an
|
|
24
|
+
* unresolved-module crash.
|
|
25
|
+
*
|
|
26
|
+
* **Vectorize is eventually consistent, and this file never assumes otherwise.** Creating an index or a
|
|
27
|
+
* metadata index is *accepted*, not applied. Where the ordering matters — a metadata index must exist before
|
|
28
|
+
* any vector is written, or that vector is permanently unfilterable — the provisioner polls the list endpoint
|
|
29
|
+
* with a bounded retry rather than reading back what it just wrote.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
type VectorProvisionModule = typeof import("@pithy-sh/vector/src/provision/provisionVector");
|
|
33
|
+
type VectorResolveModule = typeof import("@pithy-sh/vector/src/provision/resolveVectorConfig");
|
|
34
|
+
type VectorCapabilityModule = typeof import("@pithy-sh/vector/src/capability");
|
|
35
|
+
type VectorDriftModule = typeof import("@pithy-sh/vector/src/index/drift");
|
|
36
|
+
type VectorProvisionedModule = typeof import("@pithy-sh/vector/src/index/provisioned");
|
|
37
|
+
type VectorSpecsModule = typeof import("@pithy-sh/vector/src/workflows/specs");
|
|
38
|
+
|
|
39
|
+
/** The seams, referenced by type only so the CLI gains no dependency on the package. */
|
|
40
|
+
type VectorProvisioner = import("@pithy-sh/vector/src/provision/provisionVector").VectorProvisioner;
|
|
41
|
+
type VectorIndexShape = import("@pithy-sh/vector/src/provision/provisionVector").VectorIndexShape;
|
|
42
|
+
type MetadataIndexDescriptor = import("@pithy-sh/vector/src/index/metadata").MetadataIndexDescriptor;
|
|
43
|
+
type MetadataIndexReport = import("@pithy-sh/vector/src/index/drift").MetadataIndexReport;
|
|
44
|
+
type VectorConfig = import("@pithy-sh/vector/src/config/config").VectorConfig;
|
|
45
|
+
|
|
46
|
+
/** Everything `pithy vector` loads out of the optional package, in one guarded import. */
|
|
47
|
+
export type VectorModule = VectorProvisionModule &
|
|
48
|
+
VectorResolveModule &
|
|
49
|
+
VectorCapabilityModule &
|
|
50
|
+
VectorDriftModule &
|
|
51
|
+
VectorProvisionedModule &
|
|
52
|
+
VectorSpecsModule;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load `@pithy-sh/vector` from the project's own install. The one place the optional dependency is resolved,
|
|
56
|
+
* so a project that has not added vector gets one clear instruction instead of a module error from whichever
|
|
57
|
+
* call site happened to run first.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadVector(): Promise<VectorModule> {
|
|
60
|
+
try {
|
|
61
|
+
const [provision, resolve, capability, drift, provisioned, specs] = await Promise.all([
|
|
62
|
+
import("@pithy-sh/vector/src/provision/provisionVector"),
|
|
63
|
+
import("@pithy-sh/vector/src/provision/resolveVectorConfig"),
|
|
64
|
+
import("@pithy-sh/vector/src/capability"),
|
|
65
|
+
import("@pithy-sh/vector/src/index/drift"),
|
|
66
|
+
import("@pithy-sh/vector/src/index/provisioned"),
|
|
67
|
+
import("@pithy-sh/vector/src/workflows/specs"),
|
|
68
|
+
]);
|
|
69
|
+
return { ...provision, ...resolve, ...capability, ...drift, ...provisioned, ...specs };
|
|
70
|
+
} catch (error) {
|
|
71
|
+
throw capabilityLoadError("vector", "@pithy-sh/vector", error);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The directory of the prebuilt vector worker inside the installed package (holds `wrangler.jsonc`). */
|
|
76
|
+
async function vectorWorkerDir(): Promise<string> {
|
|
77
|
+
try {
|
|
78
|
+
return dirname(fileURLToPath(import.meta.resolve("@pithy-sh/vector/src/workflows/worker")));
|
|
79
|
+
} catch (error) {
|
|
80
|
+
throw capabilityLoadError("vector", "@pithy-sh/vector/src/workflows/worker", error);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Resolve the app database id for an environment — where the document corpus lives. */
|
|
85
|
+
export type ResolveVectorEnv = (env: string) => Promise<{ appDatabaseId: string }>;
|
|
86
|
+
|
|
87
|
+
/** How long to wait for an accepted metadata index to become visible, and how often to look. */
|
|
88
|
+
const METADATA_POLL_ATTEMPTS = 10;
|
|
89
|
+
const METADATA_POLL_INTERVAL_MS = 1_000;
|
|
90
|
+
|
|
91
|
+
export interface CloudflareVectorProvisionerOptions {
|
|
92
|
+
cf: CloudflareClients;
|
|
93
|
+
accountId: string;
|
|
94
|
+
/**
|
|
95
|
+
* The project name, from `requireProjectName(await loadProject(projectDir))` — never
|
|
96
|
+
* `resolveProjectName`. The deployed worker and the reprocess Workflow both lead with it, and a
|
|
97
|
+
* guessed value dispatches into a Workflow name nothing deployed.
|
|
98
|
+
*/
|
|
99
|
+
project: string;
|
|
100
|
+
/** The bootstrap token (`.dev.vars` `CLOUDFLARE_API_TOKEN`) that authenticates the worker deploy. */
|
|
101
|
+
apiToken: string;
|
|
102
|
+
/** The app's resolved vector config — the indexes to create and the config the worker is deployed with. */
|
|
103
|
+
config: VectorConfig;
|
|
104
|
+
/** Resolve the per-env app database id. Injected so it is testable and decoupled from wrangler parsing. */
|
|
105
|
+
resolveEnv: ResolveVectorEnv;
|
|
106
|
+
/** The Workflows REST client, for triggering a reprocess run in a deployed environment. */
|
|
107
|
+
workflows: CloudflareWorkflowsClient;
|
|
108
|
+
/** Sleep between metadata-index polls. Injected so tests run with no real delay. */
|
|
109
|
+
sleep?: (ms: number) => Promise<void>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The live {@link VectorProvisioner}. Every step is idempotent, so provisioning is safe to re-run. */
|
|
113
|
+
export class CloudflareVectorProvisioner implements VectorProvisioner {
|
|
114
|
+
readonly #cf: CloudflareClients;
|
|
115
|
+
readonly #accountId: string;
|
|
116
|
+
readonly #project: string;
|
|
117
|
+
readonly #apiToken: string;
|
|
118
|
+
readonly #config: VectorConfig;
|
|
119
|
+
readonly #resolveEnv: ResolveVectorEnv;
|
|
120
|
+
readonly #workflows: CloudflareWorkflowsClient;
|
|
121
|
+
readonly #sleep: (ms: number) => Promise<void>;
|
|
122
|
+
|
|
123
|
+
constructor(options: CloudflareVectorProvisionerOptions) {
|
|
124
|
+
this.#cf = options.cf;
|
|
125
|
+
this.#accountId = options.accountId;
|
|
126
|
+
this.#project = options.project;
|
|
127
|
+
this.#apiToken = options.apiToken;
|
|
128
|
+
this.#config = options.config;
|
|
129
|
+
this.#resolveEnv = options.resolveEnv;
|
|
130
|
+
this.#workflows = options.workflows;
|
|
131
|
+
this.#sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Require Vectorize access and a workers.dev subdomain — Cloudflare needs one to deploy a Workflow host. */
|
|
135
|
+
async preflight(): Promise<void> {
|
|
136
|
+
if (!(await this.#cf.vectorizeProvisioner().validateServiceAccess())) {
|
|
137
|
+
throw new ValidationError({
|
|
138
|
+
message: "This Cloudflare account cannot reach Vectorize.",
|
|
139
|
+
action: "Vectorize needs a paid Workers plan, and the API token needs Vectorize Read and Write.",
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (!(await this.#cf.workers().accountSubdomain())) {
|
|
143
|
+
throw new ValidationError({
|
|
144
|
+
message: "This Cloudflare account has no workers.dev subdomain, which Workflows require.",
|
|
145
|
+
action: "Open Workers & Pages in the dashboard once to create one, then re-run.",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Reuse the index if it exists, otherwise create it. An index's shape is fixed, so a mismatch is fatal. */
|
|
151
|
+
async ensureIndex(indexName: string, shape: VectorIndexShape): Promise<{ name: string }> {
|
|
152
|
+
const existing = await this.#cf.vectorizeProvisioner().findIndexByName(indexName);
|
|
153
|
+
if (existing) {
|
|
154
|
+
if (existing.config.dimensions !== shape.dimensions || existing.config.metric !== shape.metric) {
|
|
155
|
+
throw new ValidationError({
|
|
156
|
+
message: `The \`${indexName}\` index already exists with a different shape.`,
|
|
157
|
+
action:
|
|
158
|
+
"Dimensions and metric are fixed when an index is created. Change the config back, or run `pithy vector reset` to rebuild the index from the document corpus.",
|
|
159
|
+
detail: `live ${existing.config.dimensions}/${existing.config.metric}, config ${shape.dimensions}/${shape.metric}`,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return { name: existing.name };
|
|
163
|
+
}
|
|
164
|
+
const created = await this.#cf.vectorizeProvisioner().createIndex(indexName, shape);
|
|
165
|
+
return { name: created.name };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Create every declared metadata index that is missing, then wait until each one is visible.
|
|
170
|
+
*
|
|
171
|
+
* The wait is the point. Cloudflare applies a metadata index asynchronously, and a vector written before it
|
|
172
|
+
* lands is never covered by it — silently, with no error and no way back but a full re-embed. Returning
|
|
173
|
+
* before the index is live would put the caller's very next step (deploying the worker that writes vectors)
|
|
174
|
+
* inside that window.
|
|
175
|
+
*/
|
|
176
|
+
async ensureMetadataIndexes(
|
|
177
|
+
indexName: string,
|
|
178
|
+
declared: readonly MetadataIndexDescriptor[],
|
|
179
|
+
): Promise<MetadataIndexReport> {
|
|
180
|
+
const { compareMetadataIndexes } = await loadVector();
|
|
181
|
+
const provisioner = this.#cf.vectorizeProvisioner();
|
|
182
|
+
|
|
183
|
+
const before = compareMetadataIndexes(declared, await provisioner.listMetadataIndexes(indexName));
|
|
184
|
+
if (before.mismatched.length > 0) {
|
|
185
|
+
throw new ValidationError({
|
|
186
|
+
message: `A metadata index on \`${indexName}\` was created with a different type.`,
|
|
187
|
+
action:
|
|
188
|
+
"A metadata index's type is fixed. Change the field's type back in the index's metadata schema, or run `pithy vector reset` to rebuild the index.",
|
|
189
|
+
detail: before.mismatched
|
|
190
|
+
.map((entry) => `${entry.propertyName}: declared ${entry.declared}, indexed as ${entry.live}`)
|
|
191
|
+
.join("; "),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
if (before.missing.length === 0) return before;
|
|
195
|
+
|
|
196
|
+
for (const descriptor of before.missing) {
|
|
197
|
+
await provisioner.createMetadataIndex(indexName, descriptor.propertyName, descriptor.indexType);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
for (let attempt = 0; attempt < METADATA_POLL_ATTEMPTS; attempt += 1) {
|
|
201
|
+
await this.#sleep(METADATA_POLL_INTERVAL_MS);
|
|
202
|
+
const now = compareMetadataIndexes(declared, await provisioner.listMetadataIndexes(indexName));
|
|
203
|
+
if (now.missing.length === 0) return { ...now, missing: [...before.missing] };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
throw new InternalError({
|
|
207
|
+
message: `The metadata indexes on \`${indexName}\` were accepted but are not live yet.`,
|
|
208
|
+
action: "Re-run `pithy vector provision` in a minute. Do not write vectors until it reports them ready.",
|
|
209
|
+
detail: `waited ${(METADATA_POLL_ATTEMPTS * METADATA_POLL_INTERVAL_MS) / 1000}s for ${before.missing
|
|
210
|
+
.map((entry) => entry.propertyName)
|
|
211
|
+
.join(", ")}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Resolve the env's wrangler config from the committed template + provisioned ids, then `wrangler deploy`. */
|
|
216
|
+
async deployWorker(env: string, indexNames: Record<string, string>): Promise<void> {
|
|
217
|
+
const { resolveVectorConfig } = await loadVector();
|
|
218
|
+
const { appDatabaseId } = await this.#resolveEnv(env);
|
|
219
|
+
const dir = await vectorWorkerDir();
|
|
220
|
+
const template = parse(await readFile(join(dir, "wrangler.jsonc"), "utf8")) as unknown as WorkflowHostTemplate;
|
|
221
|
+
const config = resolveVectorConfig(template, {
|
|
222
|
+
project: this.#project,
|
|
223
|
+
env,
|
|
224
|
+
appDatabaseId,
|
|
225
|
+
indexNames,
|
|
226
|
+
config: this.#config,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const configPath = join(dir, `.wrangler.${env}.json`);
|
|
230
|
+
await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
|
231
|
+
try {
|
|
232
|
+
await runWrangler(["deploy", "--config", configPath], {
|
|
233
|
+
cwd: dir,
|
|
234
|
+
env: { CLOUDFLARE_API_TOKEN: this.#apiToken, CLOUDFLARE_ACCOUNT_ID: this.#accountId },
|
|
235
|
+
});
|
|
236
|
+
} finally {
|
|
237
|
+
await unlink(configPath).catch(() => {});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Delete an index. Destructive, and idempotent — a missing index is a no-op. */
|
|
242
|
+
async deleteIndex(indexName: string): Promise<void> {
|
|
243
|
+
await this.#cf.vectorizeProvisioner().deleteIndex(indexName);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Trigger the deployed reprocess Workflow for this environment and wait for it to finish. */
|
|
247
|
+
async reprocess(
|
|
248
|
+
env: string,
|
|
249
|
+
index: string,
|
|
250
|
+
options: { all?: boolean; filter?: Record<string, unknown> },
|
|
251
|
+
): Promise<unknown> {
|
|
252
|
+
const { VECTOR_CAPABILITY } = await loadVector();
|
|
253
|
+
const name = resourceNames(this.#project).env(env).workflow(VECTOR_CAPABILITY, "reprocess");
|
|
254
|
+
return this.#workflows.dispatchAndPoll(name, {
|
|
255
|
+
index,
|
|
256
|
+
...(options.all !== undefined ? { all: options.all } : {}),
|
|
257
|
+
...(options.filter ? { filter: options.filter } : {}),
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { chmodSync, lstatSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* **The permission bits of this repository's own files, as a rule rather than as a habit.**
|
|
10
|
+
*
|
|
11
|
+
* `bun install` chmods `packages/cli/src/bin.ts` to `0777`. It is the only `bin` declared anywhere in
|
|
12
|
+
* the workspace, `tooling/browser-scopes` depends on `@pithy-sh/cli`, and bun links a workspace bin by
|
|
13
|
+
* symlinking `node_modules/.bin/pithy` straight at the source file — then makes the target executable
|
|
14
|
+
* with a mode nobody chose. Not `0755`. `0777` (#345).
|
|
15
|
+
*
|
|
16
|
+
* Two harms, and they are not the same size.
|
|
17
|
+
*
|
|
18
|
+
* 1. **`rwxrwxrwx` on the program that reads an adopter's dev secrets and holds their Cloudflare
|
|
19
|
+
* credentials.** Any local account can rewrite it, and the next `pithy` the user runs is whatever
|
|
20
|
+
* that account wrote. This is the one that matters.
|
|
21
|
+
* 2. **Every worktree dirty at birth.** git records the file `100644`, so `git status` reported
|
|
22
|
+
* `mode change 100644 => 100755` in a tree nobody had touched — six of thirteen worktrees on one
|
|
23
|
+
* machine at once. A tree that is dirty by default is how an unrelated change gets swept into
|
|
24
|
+
* somebody's commit, and this repository already carries that defect class.
|
|
25
|
+
*
|
|
26
|
+
* **The exec bit is wanted; `0777` is not.** `bin.ts` opens `#!/usr/bin/env bun` and is declared as a
|
|
27
|
+
* `bin`, and bun's workspace link points at the source rather than at a shim, so
|
|
28
|
+
* `node_modules/.bin/pithy` is only runnable if the source file itself is executable. Clearing the bit
|
|
29
|
+
* would fix `git status` by breaking the link bun had just made. So git records `100755` and the rule
|
|
30
|
+
* below keeps the mode narrow, rather than the other way round.
|
|
31
|
+
*
|
|
32
|
+
* ## The rule, frozen
|
|
33
|
+
*
|
|
34
|
+
* For every path git tracks, or that is written and not ignored:
|
|
35
|
+
*
|
|
36
|
+
* - **No other-write.** `0777`'s `o+w` is the whole security finding, and no umask a person actually
|
|
37
|
+
* runs produces it.
|
|
38
|
+
* - **No setuid, setgid or sticky bit.** Nothing in a source tree has any use for one.
|
|
39
|
+
* - **The exec bits agree with what git records** — set for `100755`, clear for `100644`. This is the
|
|
40
|
+
* half that keeps `git status` clean, and it only applies to a path git has an opinion about.
|
|
41
|
+
* - **And a file git records executable is not group-writable either**, so the three programs in this
|
|
42
|
+
* tree that something else runs by path land at `0755` and no wider.
|
|
43
|
+
*
|
|
44
|
+
* **Group-write is permitted everywhere else, and that is a limit rather than a decision.** git checks a
|
|
45
|
+
* file out at `0666 & ~umask`, so under this repository's own reproduction umask of `0002` all 2,368
|
|
46
|
+
* ordinary tracked files land at `0664`. A rule banning `g+w` across the tree would be red on files git
|
|
47
|
+
* itself had just written, red again after every `git switch` and every `git pull`, and muted within a
|
|
48
|
+
* day — and a muted gate is the defect it was built to catch, shipping. So the tree-wide invariant is the
|
|
49
|
+
* one that survives any umask a person runs, and the tighter one is spent where it costs least and buys
|
|
50
|
+
* most: three files, rewritten by git only when they change, each of them a program.
|
|
51
|
+
*
|
|
52
|
+
* ## Where it runs
|
|
53
|
+
*
|
|
54
|
+
* Both halves, from one rule. {@link violations} is the gate in `./fileModes.test.ts`; {@link repair}
|
|
55
|
+
* is what the repo root's `postinstall` runs, so the tree is corrected by the same command that breaks
|
|
56
|
+
* it and an already-affected worktree is fixed by re-running `bun install` rather than by hand.
|
|
57
|
+
*
|
|
58
|
+
* **Builtins only, on purpose.** This module is executed by `bun install` itself, through
|
|
59
|
+
* `scripts/fileModes.ts`. A bare specifier here would be resolved through the very `node_modules` the
|
|
60
|
+
* install is still assembling, which is the one import graph a postinstall hook cannot assume. It is
|
|
61
|
+
* also why the errors below are plain — this is build-time tooling in the same class as
|
|
62
|
+
* `scripts/worktree.ts`, not the shipped runtime that owes a `PithyError`.
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
/** What `git ls-files -s` writes for a regular file. */
|
|
66
|
+
const RECORDED_REGULAR = "100644";
|
|
67
|
+
|
|
68
|
+
/** And for one git records executable. */
|
|
69
|
+
const RECORDED_EXECUTABLE = "100755";
|
|
70
|
+
|
|
71
|
+
/** The byte `-z` separates paths with. Never a literal one; a path may hold anything else. */
|
|
72
|
+
const NUL = String.fromCharCode(0);
|
|
73
|
+
|
|
74
|
+
/** Every permission bit a chmod can carry, `setuid` through `other-execute`. */
|
|
75
|
+
const PERMISSIONS = 0o7777;
|
|
76
|
+
|
|
77
|
+
/** setuid, setgid and sticky. Nothing in a source tree has any use for one. */
|
|
78
|
+
const SPECIAL = 0o7000;
|
|
79
|
+
|
|
80
|
+
/** Write for other. The whole of the security finding in #345, and no umask anyone runs produces it. */
|
|
81
|
+
const OTHER_WRITE = 0o002;
|
|
82
|
+
|
|
83
|
+
/** Write for group. Refused on a file git records executable, permitted on the rest. See above. */
|
|
84
|
+
const GROUP_WRITE = 0o020;
|
|
85
|
+
|
|
86
|
+
/** The three execute bits. */
|
|
87
|
+
const EXECUTE = 0o111;
|
|
88
|
+
|
|
89
|
+
/** The owner's, which is the one a file git records executable always gets back. */
|
|
90
|
+
const OWNER_EXECUTE = 0o100;
|
|
91
|
+
|
|
92
|
+
/** The three read bits — where an execute bit belongs, when one belongs at all. */
|
|
93
|
+
const READ = 0o444;
|
|
94
|
+
|
|
95
|
+
/** One path, what git records about it, and what the filesystem says. */
|
|
96
|
+
export interface TrackedFile {
|
|
97
|
+
/** Repository-relative, forward slashes, exactly as git wrote it. */
|
|
98
|
+
readonly path: string;
|
|
99
|
+
/**
|
|
100
|
+
* Whether git records the file executable, or `null` for a path git records nothing about — one
|
|
101
|
+
* that is written and not ignored, and so has no recorded mode to agree with yet.
|
|
102
|
+
*/
|
|
103
|
+
readonly recorded: boolean | null;
|
|
104
|
+
/** The on-disk permission bits, or `null` when the path was not there to stat. */
|
|
105
|
+
readonly mode: number | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Run git in `root` and hand back what it wrote. */
|
|
109
|
+
function git(root: string, args: string[]): string[] {
|
|
110
|
+
const written = execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
111
|
+
return written.split(NUL).filter((entry) => entry !== "");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* What git records for one index entry.
|
|
116
|
+
*
|
|
117
|
+
* **An unrecognised mode throws rather than being skipped.** git can record a symlink (`120000`) and a
|
|
118
|
+
* submodule (`160000`), and this repository has neither; the rule above has no sentence about either
|
|
119
|
+
* one, and a gate that quietly drops what it has no rule for is a gate that shrinks as the tree grows.
|
|
120
|
+
* Whoever adds the first one gets to decide what the rule is, once, out loud.
|
|
121
|
+
*/
|
|
122
|
+
export function recordedExecutable(mode: string, path: string): boolean {
|
|
123
|
+
if (mode === RECORDED_REGULAR) return false;
|
|
124
|
+
if (mode === RECORDED_EXECUTABLE) return true;
|
|
125
|
+
throw new Error(`git records ${path} as mode ${mode}, and packages/cli/src/ci/fileModes.ts has no rule for it.`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** `path`'s permission bits, or `null` if it is not there. `lstat`, so a link is never followed. */
|
|
129
|
+
function onDisk(path: string): number | null {
|
|
130
|
+
try {
|
|
131
|
+
return lstatSync(path).mode & PERMISSIONS;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Every path git carries: the index, **plus what is written and not ignored**.
|
|
139
|
+
*
|
|
140
|
+
* The index alone would make the world-writable half of the rule one run late by construction — a file
|
|
141
|
+
* exists before anything stages it, and the pre-commit hook runs Biome rather than this suite. The
|
|
142
|
+
* second listing closes that, and closes it without noise: `--exclude-standard` drops `node_modules`,
|
|
143
|
+
* `dist`, and the whole-project scaffolds this repository's own suites write into `packages/cli/.smoke-*`
|
|
144
|
+
* and `.e2e-*`. The same argument `sourceFiles.test.ts` makes about a NUL byte, about a mode.
|
|
145
|
+
*/
|
|
146
|
+
export function trackedFiles(root: string): TrackedFile[] {
|
|
147
|
+
const found: TrackedFile[] = [];
|
|
148
|
+
for (const entry of git(root, ["ls-files", "-s", "-z"])) {
|
|
149
|
+
// `<mode> <sha> <stage>\t<path>` — and a path may hold a tab, so the first one ends the header.
|
|
150
|
+
const tab = entry.indexOf("\t");
|
|
151
|
+
if (tab < 0) throw new Error(`git ls-files -s wrote an entry with no path: ${entry}`);
|
|
152
|
+
const path = entry.slice(tab + 1);
|
|
153
|
+
const record = recordedExecutable(entry.slice(0, entry.indexOf(" ")), path);
|
|
154
|
+
found.push({ path, recorded: record, mode: onDisk(join(root, path)) });
|
|
155
|
+
}
|
|
156
|
+
for (const path of git(root, ["ls-files", "-z", "--others", "--exclude-standard"])) {
|
|
157
|
+
found.push({ path, recorded: null, mode: onDisk(join(root, path)) });
|
|
158
|
+
}
|
|
159
|
+
return found.sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* What `file` should have been, given what git records about it.
|
|
164
|
+
*
|
|
165
|
+
* **It only ever narrows.** For an ordinary file it is not `chmod 644`: forcing an absolute mode would
|
|
166
|
+
* fight the umask of whoever ran it and then disagree with what `git checkout` writes on the next branch
|
|
167
|
+
* switch, so only the bits the rule refuses are cleared and `0664` stays `0664`. For a file git records
|
|
168
|
+
* executable there is an absolute answer, and it is `0755` — the exec bits go back wherever a read bit
|
|
169
|
+
* is, group-write comes off, and `0777` lands where the issue asked for it. Running it twice changes
|
|
170
|
+
* nothing either way.
|
|
171
|
+
*/
|
|
172
|
+
export function repairedMode(recordedExecutable: boolean, mode: number): number {
|
|
173
|
+
const forbidden = SPECIAL | OTHER_WRITE | (recordedExecutable ? GROUP_WRITE : 0);
|
|
174
|
+
const narrowed = mode & ~forbidden & ~EXECUTE;
|
|
175
|
+
if (!recordedExecutable) return narrowed;
|
|
176
|
+
// The owner's bit unconditionally, because a file git records executable and nobody can read — `0000`,
|
|
177
|
+
// and the rest of that corner — would otherwise come back still not executable, which is the one thing
|
|
178
|
+
// the repair was asked for. Everyone else gets one only where they already have a read bit.
|
|
179
|
+
return narrowed | OWNER_EXECUTE | ((narrowed & READ) >> 2);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Why one file breaks the rule, in the words the gate prints. Null when it does not. */
|
|
183
|
+
export function offense(file: TrackedFile): string | null {
|
|
184
|
+
if (file.mode === null) return null;
|
|
185
|
+
const found: string[] = [];
|
|
186
|
+
const octal = `0${file.mode.toString(8).padStart(3, "0")}`;
|
|
187
|
+
if ((file.mode & SPECIAL) !== 0) found.push(`${octal} carries a setuid, setgid or sticky bit`);
|
|
188
|
+
if ((file.mode & OTHER_WRITE) !== 0) found.push(`${octal} is world-writable`);
|
|
189
|
+
if (file.recorded === true && (file.mode & GROUP_WRITE) !== 0) {
|
|
190
|
+
found.push(`${octal} is group-writable and git records it executable`);
|
|
191
|
+
}
|
|
192
|
+
if (file.recorded !== null && ((file.mode & EXECUTE) !== 0) !== file.recorded) {
|
|
193
|
+
const records = file.recorded ? "100755" : "100644";
|
|
194
|
+
found.push(`${octal} is ${file.recorded ? "not " : ""}executable and git records it ${records}`);
|
|
195
|
+
}
|
|
196
|
+
return found.length === 0 ? null : found.join("; ");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Every file breaking the rule, path → why. Empty is what passing looks like. */
|
|
200
|
+
export function violations(files: readonly TrackedFile[]): Record<string, string> {
|
|
201
|
+
const found: Record<string, string> = {};
|
|
202
|
+
for (const file of files) {
|
|
203
|
+
const why = offense(file);
|
|
204
|
+
if (why !== null) found[file.path] = why;
|
|
205
|
+
}
|
|
206
|
+
return found;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Narrow every file that breaks the rule, and say which ones those were.
|
|
211
|
+
*
|
|
212
|
+
* Only the offenders are touched. Chmodding all ~2,371 tracked files on every install would churn a
|
|
213
|
+
* tree that is already correct and hide, in the noise, the one file that was not.
|
|
214
|
+
*/
|
|
215
|
+
export function repair(root: string): string[] {
|
|
216
|
+
const fixed: string[] = [];
|
|
217
|
+
for (const file of trackedFiles(root)) {
|
|
218
|
+
if (file.mode === null || offense(file) === null) continue;
|
|
219
|
+
chmodSync(join(root, file.path), repairedMode(file.recorded === true, file.mode));
|
|
220
|
+
fixed.push(file.path);
|
|
221
|
+
}
|
|
222
|
+
return fixed;
|
|
223
|
+
}
|