@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
package/src/seed/plan.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { ResolvedSeedSet } from "@pithy-sh/core/src/seed/compose";
|
|
5
|
+
import type { MediaSeedItem } from "@pithy-sh/core/src/seed/seed";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The `--dry-run` write plan: what `pithy seed` **would** write, computed from the composed sets alone
|
|
9
|
+
* with no mutation. Emitted as `--json` so a human or an agent can review a run — especially against
|
|
10
|
+
* staging/prod — before authorizing it. Row and entry counts come straight from the fixtures;
|
|
11
|
+
* per-asset media actions come from an optional resolver (the CLI passes one that reads each item's
|
|
12
|
+
* sidecar), defaulting to the action a first run would take.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One table's line in the plan: which table in which database, and how many rows would be written. */
|
|
16
|
+
export interface SeedPlanD1Entry {
|
|
17
|
+
/** The named database the table lives in. */
|
|
18
|
+
database: string;
|
|
19
|
+
/** The table that would be seeded. */
|
|
20
|
+
table: string;
|
|
21
|
+
/** The number of rows the fixture would write (idempotent — existing rows are ignored). */
|
|
22
|
+
rows: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One store's line in the plan: which store in which namespace, and how many entries would be written. */
|
|
26
|
+
export interface SeedPlanKvEntry {
|
|
27
|
+
/** The named KV namespace the store lives in. */
|
|
28
|
+
namespace: string;
|
|
29
|
+
/** The store that would be seeded. */
|
|
30
|
+
store: string;
|
|
31
|
+
/** The number of entries the fixture would write. */
|
|
32
|
+
entries: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One object's line in the plan: the bucket binding and key that would be written. */
|
|
36
|
+
export interface SeedPlanR2Entry {
|
|
37
|
+
/** The R2 binding the object would be written to. */
|
|
38
|
+
binding: string;
|
|
39
|
+
/** The object key. */
|
|
40
|
+
key: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* What a media item would do on this run: `upload` (a `once` asset with no recorded UUID yet),
|
|
45
|
+
* `skip` (a `once` asset already uploaded — its UUID is on record), or `reupload` (an `always` asset,
|
|
46
|
+
* re-sent every run).
|
|
47
|
+
*/
|
|
48
|
+
export type SeedPlanMediaAction = "upload" | "skip" | "reupload";
|
|
49
|
+
|
|
50
|
+
/** One asset's line in the plan: the store, its mode, the action, and the recorded UUID if any. */
|
|
51
|
+
export interface SeedPlanMediaEntry {
|
|
52
|
+
/** The shared asset store the item targets. */
|
|
53
|
+
store: MediaSeedItem["store"];
|
|
54
|
+
/** The item's upload policy. */
|
|
55
|
+
mode: MediaSeedItem["mode"];
|
|
56
|
+
/** What this run would do for the item. */
|
|
57
|
+
action: SeedPlanMediaAction;
|
|
58
|
+
/** The already-minted asset UUID, when the sidecar records one (a `skip`). */
|
|
59
|
+
id?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One set's slice of the plan: its namespaced name and the writes it would make, per backend. */
|
|
63
|
+
export interface SeedPlanSet {
|
|
64
|
+
/** The set's namespaced, order-prefixed key (`NNNN_<capability>_<name>`). */
|
|
65
|
+
name: string;
|
|
66
|
+
/** The D1 tables this set would write. */
|
|
67
|
+
d1: SeedPlanD1Entry[];
|
|
68
|
+
/** The KV stores this set would write. */
|
|
69
|
+
kv: SeedPlanKvEntry[];
|
|
70
|
+
/** The R2 objects this set would write. */
|
|
71
|
+
r2: SeedPlanR2Entry[];
|
|
72
|
+
/** The media assets this set would upload. */
|
|
73
|
+
media: SeedPlanMediaEntry[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The complete dry-run plan for one environment. Serialized verbatim as the `--json` output. */
|
|
77
|
+
export interface SeedPlan {
|
|
78
|
+
/** The command that produced the plan. */
|
|
79
|
+
command: "seed";
|
|
80
|
+
/** The environment the plan targets. */
|
|
81
|
+
env: string;
|
|
82
|
+
/** Always `true` — a plan never writes. */
|
|
83
|
+
dryRun: true;
|
|
84
|
+
/** The per-set plan, in run order. */
|
|
85
|
+
sets: SeedPlanSet[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve what a media item would do this run. The CLI passes an implementation that reads the item's
|
|
90
|
+
* `ref` sidecar; the default assumes a first run (a `once` item uploads, an `always` item re-uploads).
|
|
91
|
+
*/
|
|
92
|
+
export type MediaStateResolver = (item: MediaSeedItem) => { action: SeedPlanMediaAction; id?: string };
|
|
93
|
+
|
|
94
|
+
/** The default media state: a first run — `once` uploads, `always` re-uploads, nothing recorded yet. */
|
|
95
|
+
const firstRunMediaState: MediaStateResolver = (item) => ({
|
|
96
|
+
action: item.mode === "always" ? "reupload" : "upload",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Assemble the dry-run write plan from the composed, ordered sets — pure, with no backend access. Row
|
|
101
|
+
* and entry counts are read from the fixtures; media actions come from `resolveMedia` (default: a
|
|
102
|
+
* first-run state). The result is the exact shape `pithy seed --dry-run --json` prints.
|
|
103
|
+
*/
|
|
104
|
+
export function buildDryRunPlan(
|
|
105
|
+
env: string,
|
|
106
|
+
sets: readonly ResolvedSeedSet[],
|
|
107
|
+
resolveMedia: MediaStateResolver = firstRunMediaState,
|
|
108
|
+
): SeedPlan {
|
|
109
|
+
return {
|
|
110
|
+
command: "seed",
|
|
111
|
+
env,
|
|
112
|
+
dryRun: true,
|
|
113
|
+
sets: sets.map((resolved) => ({
|
|
114
|
+
name: resolved.key,
|
|
115
|
+
d1: (resolved.set.d1 ?? []).map((group) => ({
|
|
116
|
+
database: group.database,
|
|
117
|
+
table: group.table,
|
|
118
|
+
rows: group.rows.length,
|
|
119
|
+
})),
|
|
120
|
+
kv: (resolved.set.kv ?? []).map((group) => ({
|
|
121
|
+
namespace: group.namespace,
|
|
122
|
+
store: group.store,
|
|
123
|
+
entries: group.entries.length,
|
|
124
|
+
})),
|
|
125
|
+
r2: (resolved.set.r2 ?? []).map((item) => ({ binding: item.binding, key: item.key })),
|
|
126
|
+
media: (resolved.set.media ?? []).map((item) => {
|
|
127
|
+
const state = resolveMedia(item);
|
|
128
|
+
return {
|
|
129
|
+
store: item.store,
|
|
130
|
+
mode: item.mode,
|
|
131
|
+
action: state.action,
|
|
132
|
+
...(state.id !== undefined ? { id: state.id } : {}),
|
|
133
|
+
};
|
|
134
|
+
}),
|
|
135
|
+
})),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
5
|
+
import { basename, join } from "node:path";
|
|
6
|
+
import { ConflictError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
7
|
+
import { SEED_ARTIFACT_DIR } from "@pithy-sh/core/src/seed/devLogin";
|
|
8
|
+
import type { SeedArtifact } from "@pithy-sh/core/src/seed/seed";
|
|
9
|
+
import { currentValue } from "@pithy-sh/secrets/src/crypto/versionedValue";
|
|
10
|
+
import type { DevSecretsFile } from "@pithy-sh/secrets/src/dev/devSecretsFile";
|
|
11
|
+
import { storedSecretValue } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
|
|
12
|
+
import type { SecretRegistry } from "@pithy-sh/secrets/src/registry";
|
|
13
|
+
import { readDevSecrets } from "../devSecrets/file";
|
|
14
|
+
import { devSecretsFile } from "../devSecrets/location";
|
|
15
|
+
import { projectConfigDir, type StatePathOptions, stateDir } from "../notifier/state";
|
|
16
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The CLI half of the prepared-set seam: everything a `SeedSet.prepare` hook needs from the machine, kept
|
|
20
|
+
* here because a capability module is bundled into the Worker and cannot reach the filesystem at all.
|
|
21
|
+
* The hook gets values and callbacks; the disk stays on this side.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The file's name inside the project's config directory. Undotted: nothing here is hidden from anything. */
|
|
25
|
+
export const DEV_PREFERENCES_FILE_NAME = "dev.json";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a developer states their machine-local preferences for one project: `<stateDir()>/<project>/dev.json`
|
|
29
|
+
* — `%APPDATA%\pithy\<project>\dev.json` on Windows, `$XDG_CONFIG_HOME/pithy/<project>/dev.json` when that
|
|
30
|
+
* is set, else `~/.config/pithy/<project>/dev.json`.
|
|
31
|
+
*
|
|
32
|
+
* Outside the repo on purpose. A machine opts itself in with no commit and no per-run flag, and two
|
|
33
|
+
* developers sharing one checkout can be different people. The project segment stays for the same reason
|
|
34
|
+
* one level down: a developer with two Pithy projects checked out wants to be a different user in each.
|
|
35
|
+
*
|
|
36
|
+
* **Under Pithy's own config directory, not straight under the config root.** This used to resolve
|
|
37
|
+
* `$XDG_CONFIG_HOME/<project>/dev.json`, which squatted on a namespace the CLI does not own: a Pithy project
|
|
38
|
+
* name is short and generic by design — `dash`, `api`, `web` — so `~/.config/dash/` is a plausible collision
|
|
39
|
+
* with an unrelated program, and the loser is whichever wrote second.
|
|
40
|
+
*
|
|
41
|
+
* **Resolved through {@link stateDir} rather than by hand.** The hand-rolled version had no `win32` branch
|
|
42
|
+
* at all, so a Windows developer's file landed where nothing reads it while the resolver three files away
|
|
43
|
+
* got it right. Delegating deletes that logic instead of adding a third copy of it, and it makes this the
|
|
44
|
+
* directory `pithy doctor` already reports — which is what lets doctor name this file at all.
|
|
45
|
+
*
|
|
46
|
+
* One behavioral consequence, taken deliberately: `stateDir` reads the home directory from `os.homedir()`,
|
|
47
|
+
* not from `$HOME`, so exporting `HOME` no longer relocates the preference file. That is the same rule the
|
|
48
|
+
* state file has always followed, and one rule is the point.
|
|
49
|
+
*
|
|
50
|
+
* **The project segment is {@link projectConfigDir}'s to join, and its rule to state (#212).** This
|
|
51
|
+
* function used to join the name itself, with the rule that it was safe to put in a path living at every
|
|
52
|
+
* call site — safe every time, because each caller had been through `requireProjectName`, and safe by a
|
|
53
|
+
* property of the call graph rather than of the value.
|
|
54
|
+
*/
|
|
55
|
+
export function devPreferencesPath(project: string, options: StatePathOptions = {}): string {
|
|
56
|
+
return join(projectConfigDir(project, options), DEV_PREFERENCES_FILE_NAME);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Read {@link devPreferencesPath}, parsed but unvalidated — the set that consumes it owns its shape.
|
|
61
|
+
*
|
|
62
|
+
* An absent file is the default (and means "seed nothing extra"), so it is `undefined`, not an error. So is
|
|
63
|
+
* an unparseable one: this file is hand-edited, and a half-typed preference should not fail a whole seed
|
|
64
|
+
* run. A file that parses but says the wrong thing is a different matter, and the set rejects it loudly.
|
|
65
|
+
*/
|
|
66
|
+
export async function readDevPreferences(project: string, options: StatePathOptions = {}): Promise<unknown> {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(await readFile(devPreferencesPath(project, options), "utf8"));
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The one environment whose secrets are on the operator's disk. Everything else is a managed environment
|
|
76
|
+
* and gets its secrets from its own store — see {@link devSecretReader}.
|
|
77
|
+
*/
|
|
78
|
+
const LOCAL_ENVIRONMENT = "dev";
|
|
79
|
+
|
|
80
|
+
/** What a prepared set needs to build a dev-only reader: the project, its registry, and the environment. */
|
|
81
|
+
export interface DevSecretReaderOptions {
|
|
82
|
+
/**
|
|
83
|
+
* The project's **name** — what resolves the dev secrets file, exactly as it resolves `dev.json`
|
|
84
|
+
* one directory over. Not the project root: since #156 the file is at `<config>/<project>/`, so a
|
|
85
|
+
* directory is no longer an answer to where a secret is.
|
|
86
|
+
*/
|
|
87
|
+
project: string;
|
|
88
|
+
/**
|
|
89
|
+
* The environment this seed run is writing to. **Required, and that is the structural half of the rule
|
|
90
|
+
* (#159).** A caller cannot build a dev-secrets reader without stating where the rows are going, so
|
|
91
|
+
* there is no signature left that reads dev secrets for an environment nobody named.
|
|
92
|
+
*/
|
|
93
|
+
env: string;
|
|
94
|
+
/**
|
|
95
|
+
* The project's aggregate secret registry — the authority on whether a name is a secret at all, and
|
|
96
|
+
* on the shape its value must have. Required for the same reason `env` is: a reader that guesses
|
|
97
|
+
* either one is a reader nobody can audit.
|
|
98
|
+
*/
|
|
99
|
+
registry: SecretRegistry;
|
|
100
|
+
/** Where the Pithy config directory is. Defaults to the real one — a seam, so a test reads its own. */
|
|
101
|
+
paths?: StatePathOptions;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read a named secret out of the project's **dev secrets file** — **in `dev`, and in no other environment.**
|
|
106
|
+
*
|
|
107
|
+
* That is where local dev's secrets live: `<config>/<project>/secrets.jsonc`, the same file `pithy seed`
|
|
108
|
+
* has just minted into and seeded from, two steps before a prepared set runs. It needs no D1 and no
|
|
109
|
+
* master key, so it answers at the point in the pipeline where seeds happen.
|
|
110
|
+
*
|
|
111
|
+
* **This read `.dev.vars` until #176, and that was the whole defect.** #153 moved every `d1` secret out
|
|
112
|
+
* of that file and #154 made each Worker's copy a generated artifact; the reader was not moved with
|
|
113
|
+
* them. So `pithy seed` on a project composing auth's dev-session seed printed two lines that
|
|
114
|
+
* contradicted each other — the secret was seeded, and the seed that needs it could not see it — and
|
|
115
|
+
* the fix it suggested was to undo #153.
|
|
116
|
+
*
|
|
117
|
+
* **One source, whatever the backend, and so no branch.** `.dev.vars` is not the other half of this
|
|
118
|
+
* question any more: it is a *destination* now, generated per Worker from this same file, and a
|
|
119
|
+
* `cf-secrets-store` value written there is this file's value encoded for a binding. Reading it back
|
|
120
|
+
* would be reading a copy — and a run-wide reader cannot even say *which* Worker's copy, because since
|
|
121
|
+
* #154 there are as many as there are Workers. A `cf-secrets-store` secret therefore resolves here too,
|
|
122
|
+
* from the one place its dev value is stated. What is given up is narrow and deliberate: a value
|
|
123
|
+
* supplied only through a hand-written `.dev.vars.local` override is not visible to a prepared set. It
|
|
124
|
+
* is an override of what the Worker reads, not a statement of what the secret is.
|
|
125
|
+
*
|
|
126
|
+
* **Through the registry, and through `storedSecretValue`, so this is the value the Worker gets.** The
|
|
127
|
+
* registry says whether a `text` secret's version really is a string and validates a `json` one against
|
|
128
|
+
* its schema; the conversion is the same one the seeder uses to fill the store. So a prepared set is
|
|
129
|
+
* handed exactly the bytes `secretsStore` will resolve at runtime — not a near-miss parsed a second way
|
|
130
|
+
* by a second reader. A name the registry does not declare answers `undefined`: nothing declares it, so
|
|
131
|
+
* the running Worker cannot resolve it either. A keyspace answers `undefined` for the same reason
|
|
132
|
+
* `get(name)` refuses one — it has no single value.
|
|
133
|
+
*
|
|
134
|
+
* **The environment gate is structural, not a caller's courtesy (#159).** An older doc said a deployed
|
|
135
|
+
* environment's secrets are not on the operator's disk, so a read would answer `undefined` there. That
|
|
136
|
+
* was never true of the operator's *own* machine: the dev secrets file sits under `~/.config` whatever
|
|
137
|
+
* `--env` says, so `pithy seed --env prod` handed a prepared set a live local dev secret and wrote it
|
|
138
|
+
* into production rows. #159's rule is absolute and the adopter cannot opt out — dev secrets never reach
|
|
139
|
+
* a managed environment — and a reader that leaks them is the same hole as a writer that plants them. So
|
|
140
|
+
* outside `dev` the reading closure is never built: the caller gets a reader that refuses, and the file
|
|
141
|
+
* is never opened at all.
|
|
142
|
+
*
|
|
143
|
+
* **Provably dev, not merely not-prod.** An unknown, misspelled, or empty environment refuses too. The
|
|
144
|
+
* permissive default is the entire bug.
|
|
145
|
+
*
|
|
146
|
+
* **Read at most once per run.** The file is hand-edited, and two Workers in one fan-out must not observe
|
|
147
|
+
* it in two states — the same rule, for the same reason, that `preparedRun` applies to `dev.json`. A run
|
|
148
|
+
* with no prepared set never opens it at all. And an absent file is `{}` while an unreadable one throws:
|
|
149
|
+
* `readDevSecrets` owns that distinction, because answering "empty" for `EACCES` is how a set writes a
|
|
150
|
+
* row against a secret it never got.
|
|
151
|
+
*/
|
|
152
|
+
export function devSecretReader(options: DevSecretReaderOptions): (name: string) => Promise<string | undefined> {
|
|
153
|
+
const path = devSecretsFile(options.project, options.paths ?? {});
|
|
154
|
+
if (options.env !== LOCAL_ENVIRONMENT) return refuseOutsideDev(options.env, path);
|
|
155
|
+
let pending: Promise<DevSecretsFile> | undefined;
|
|
156
|
+
return async (name: string) => {
|
|
157
|
+
// `Object.hasOwn`, never a bare index: a prepared set asking for `constructor` or `toString` would
|
|
158
|
+
// otherwise reach an `Object.prototype` member and be handed something that is not a secret at all.
|
|
159
|
+
if (!Object.hasOwn(options.registry, name)) return undefined;
|
|
160
|
+
const entry = options.registry[name];
|
|
161
|
+
if (!entry || entry.keyed) return undefined;
|
|
162
|
+
if (pending === undefined) pending = readDevSecrets(path);
|
|
163
|
+
const file = await pending;
|
|
164
|
+
const envelope = file[name];
|
|
165
|
+
if (!envelope) return undefined;
|
|
166
|
+
return currentValue(storedSecretValue(entry, name, envelope, path));
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The reader a managed environment gets: one that refuses by name and reads nothing.
|
|
172
|
+
*
|
|
173
|
+
* It refuses when a set *asks*, not when the run starts — `pithy seed --env prod` is a legitimate command,
|
|
174
|
+
* and a set that never wants a secret is none of this rule's business. A set that does want one is a
|
|
175
|
+
* `dev`-only set, and this is where it finds that out, loudly, before a row is written.
|
|
176
|
+
*
|
|
177
|
+
* The secret's name is in the message because it is a registry key an adopter wrote; its value never is,
|
|
178
|
+
* in `message` or in `detail`, because this function has not read one and never will.
|
|
179
|
+
*/
|
|
180
|
+
function refuseOutsideDev(env: string, path: string): (name: string) => Promise<string | undefined> {
|
|
181
|
+
return async (name: string) => {
|
|
182
|
+
throw new ConflictError({
|
|
183
|
+
message: `Refusing to read the dev secret "${name}" while seeding ${env}.`,
|
|
184
|
+
action:
|
|
185
|
+
"Dev secrets are local only. Mark this set dev-only, or set the value for that environment with pithy secrets set.",
|
|
186
|
+
detail: `devSecretReader refused ${path} for env "${env}"; only "${LOCAL_ENVIRONMENT}" resolves secrets from the dev secrets file`,
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The mode a freshly written artifact lands with. `logs/dev-login.json` holds a **live session cookie** —
|
|
193
|
+
* one `cat` from being anybody's login — and the umask is not a permission policy.
|
|
194
|
+
*/
|
|
195
|
+
const ARTIFACT_MODE = 0o600;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Write one prepared artifact under the project's `logs/`, returning the path written.
|
|
199
|
+
*
|
|
200
|
+
* The directory is not the fixture's to choose: `logs/` is gitignored by the starter template, and the one
|
|
201
|
+
* artifact that exists holds a live session cookie. A `file` carrying any directory part is refused rather
|
|
202
|
+
* than normalized — a fixture that tried it is a bug, and silently relocating it would hide the bug.
|
|
203
|
+
*
|
|
204
|
+
* **Through {@link writeFileAtomic}, for the same two reasons `.dev.vars` is.** A plain `writeFile` follows
|
|
205
|
+
* a symlink at the target wherever it points, so a foreign-owned link left at `logs/dev-login.json` carried
|
|
206
|
+
* a live session cookie out of the project; and it lands the file at whatever the umask allows, which for
|
|
207
|
+
* a credential is a decision nobody made. The primitive owns both rules — an ownership check on every link
|
|
208
|
+
* it follows, and a mode the file is *born* with rather than widened from. A file already there keeps its
|
|
209
|
+
* own mode: those permissions are the adopter's.
|
|
210
|
+
*/
|
|
211
|
+
export async function writeSeedArtifact(projectDir: string, artifact: SeedArtifact): Promise<string> {
|
|
212
|
+
if (artifact.file !== basename(artifact.file) || artifact.file.startsWith(".")) {
|
|
213
|
+
throw new ValidationError({
|
|
214
|
+
message: "A seed artifact must be a plain file name.",
|
|
215
|
+
action: `Name the file itself; it is always written into ${SEED_ARTIFACT_DIR}/.`,
|
|
216
|
+
detail: `artifact file "${artifact.file}" is not a plain basename`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const dir = join(projectDir, SEED_ARTIFACT_DIR);
|
|
220
|
+
await mkdir(dir, { recursive: true });
|
|
221
|
+
const path = join(dir, artifact.file);
|
|
222
|
+
await writeFileAtomic(path, artifact.contents, { mode: ARTIFACT_MODE });
|
|
223
|
+
return path;
|
|
224
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import type { Capability } from "@pithy-sh/core/src/capability/capability";
|
|
5
|
+
import { type ComposedSeeds, composeSeeds } from "@pithy-sh/core/src/seed/compose";
|
|
6
|
+
|
|
7
|
+
/** Options controlling how {@link buildSeedPlan} composes the project's seed sets. */
|
|
8
|
+
export interface BuildSeedPlanOptions {
|
|
9
|
+
/** The environment being seeded. A set runs only if its `environments` lists this value. */
|
|
10
|
+
env: string;
|
|
11
|
+
/** Whether `example` sets are composed in (the project's `seed.includeExamples`; default off). */
|
|
12
|
+
includeExamples: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compose every capability's `seeds` into one ordered, env-filtered registry — the CLI's thin seam
|
|
17
|
+
* over core's `composeSeeds`, the peer of `buildRegistryFromCapabilities` for migrations. Returns the
|
|
18
|
+
* runnable sets (library-before-app by `order`, then namespaced key) plus the keys of sets present but
|
|
19
|
+
* disallowed for `env` (`skippedByEnv`), so the command can report a set it refused to run rather than
|
|
20
|
+
* silently doing nothing. Ordering, namespacing, example filtering, and the env allowlist all live in
|
|
21
|
+
* core; this wrapper is the single import site `pithy seed` composes through.
|
|
22
|
+
*/
|
|
23
|
+
export function buildSeedPlan(capabilities: Capability[], options: BuildSeedPlanOptions): ComposedSeeds {
|
|
24
|
+
return composeSeeds(capabilities, { env: options.env, includeExamples: options.includeExamples });
|
|
25
|
+
}
|