@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,155 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
7
|
+
import { loadProject, requireProjectName } from "../project/config";
|
|
8
|
+
import { devPreferencesPath } from "../seed/prepare";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whether this project has a dev-login preference file, and whether it says anything a seed can use.
|
|
12
|
+
*
|
|
13
|
+
* **Why a diagnostic owns this at all.** `dev.json` is machine-local, outside the repo, and named by
|
|
14
|
+
* nothing in the checkout — so a developer whose dev login is not working has no file to grep for and no
|
|
15
|
+
* command that will tell them where it should be. `pithy doctor` already reports one config directory; this
|
|
16
|
+
* makes it report the one file inside it that is per project. See {@link describeDevPreferences} for the
|
|
17
|
+
* line, and `docs/SEED.md` for what the file is.
|
|
18
|
+
*
|
|
19
|
+
* **It never claims the named user is seeded.** Doctor runs no seed, composes no sets, and opens no
|
|
20
|
+
* database, so whether the email in the file is a user any run creates is a fact it has not established. The
|
|
21
|
+
* seed already refuses loudly on an unknown user and lists the ones it does create — that is the check that
|
|
22
|
+
* has the inventory, and this one stays quiet rather than guessing alongside it.
|
|
23
|
+
*/
|
|
24
|
+
export type DevPreferencesState =
|
|
25
|
+
/**
|
|
26
|
+
* No file. **The documented default, and never a fault.** "There is no way in but a magic link" is the
|
|
27
|
+
* behavior auth ships, and opting out of it is a choice a developer makes per machine. Reported anyway,
|
|
28
|
+
* because the path is the whole answer to "where do I put one".
|
|
29
|
+
*/
|
|
30
|
+
| "absent"
|
|
31
|
+
/** The file parses and names a user. Whether that user is seeded is the seed's question, not this one. */
|
|
32
|
+
| "ok"
|
|
33
|
+
/**
|
|
34
|
+
* The file is there and is not JSON. **A fault, and the quiet kind doctor exists to name.**
|
|
35
|
+
* `readDevPreferences` treats an unparseable file exactly like an absent one — deliberately, so a
|
|
36
|
+
* half-typed preference cannot fail a whole seed run — which means a developer who typo'd their `dev.json`
|
|
37
|
+
* gets no session, no error, and no clue. Nothing else in the toolchain will ever mention it.
|
|
38
|
+
*/
|
|
39
|
+
| "unparseable"
|
|
40
|
+
/**
|
|
41
|
+
* The file parses and names no user. A fault too, though a louder one: `authDevSessionSeed` throws on it
|
|
42
|
+
* the next time anyone seeds. Doctor says it first, offline, without a run.
|
|
43
|
+
*
|
|
44
|
+
* `user` is the only key any composed set reads today, so "names no user" and "says nothing usable" are
|
|
45
|
+
* the same sentence. When a second set starts reading `dev.json`, this check has to widen with it — a file
|
|
46
|
+
* written for that set would otherwise be reported as a fault by a check that had never heard of it.
|
|
47
|
+
*/
|
|
48
|
+
| "no-user"
|
|
49
|
+
/**
|
|
50
|
+
* The check itself threw, so nothing about the file was established (#371).
|
|
51
|
+
*
|
|
52
|
+
* **Not `absent`.** That is the documented default and reads as "everything is as it should be", which
|
|
53
|
+
* is the one thing this state must not say. It never fails the exit, on the same rule every other
|
|
54
|
+
* establishes-nothing state in this report follows.
|
|
55
|
+
*/
|
|
56
|
+
| "could-not-check";
|
|
57
|
+
|
|
58
|
+
/** What `doctor` learned about this project's dev-login preference file. */
|
|
59
|
+
export interface DevPreferencesCheck {
|
|
60
|
+
state: DevPreferencesState;
|
|
61
|
+
/**
|
|
62
|
+
* The resolved absolute path — the same one `pithy seed` reads, on this platform. Always set, including
|
|
63
|
+
* under `absent`: telling a developer where the file *would* go is most of what this check is for. The
|
|
64
|
+
* text renderer abbreviates it against `$HOME`; `--json` carries it whole.
|
|
65
|
+
*/
|
|
66
|
+
path: string;
|
|
67
|
+
/** The email the file names, or `null` when it names none. Never asserted to be a user any run seeds. */
|
|
68
|
+
user: string | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The shape doctor reads out of `dev.json` — auth's `DevPreferences`, narrowed to the one question here.
|
|
73
|
+
*
|
|
74
|
+
* Restated rather than imported: `@pithy-sh/auth` is not a CLI dependency, and making it one so a
|
|
75
|
+
* diagnostic could name a key would invert the dependency the whole capability model rests on. Held to the
|
|
76
|
+
* same rule the file itself is — extra keys pass, because a `dev.json` carrying another set's preference is
|
|
77
|
+
* a valid file, not a malformed one.
|
|
78
|
+
*/
|
|
79
|
+
const NamedUser = z
|
|
80
|
+
.object({
|
|
81
|
+
user: z
|
|
82
|
+
.string()
|
|
83
|
+
.min(1)
|
|
84
|
+
.describe("The email `dev.json` names. Non-empty, because an empty string names nobody to sign in as."),
|
|
85
|
+
})
|
|
86
|
+
.describe("The one key a dev-login preference file must carry for any seed to do anything with it.");
|
|
87
|
+
|
|
88
|
+
/** The project name seed itself would resolve, or `null` when there is none to resolve. */
|
|
89
|
+
async function preferenceProject(projectDir: string): Promise<string | null> {
|
|
90
|
+
try {
|
|
91
|
+
// `requireProjectName` rather than `config.name`, because that is the exact call `pithy seed` makes —
|
|
92
|
+
// kebabing included. Two resolutions would let doctor report a path seed never reads. It throws on an
|
|
93
|
+
// absent name and on an illegal one, and both are `null` here: `checkProjectName` owns the illegal-name
|
|
94
|
+
// verdict, and two blocks reporting one fault is how a report starts contradicting itself.
|
|
95
|
+
return requireProjectName(await loadProject(projectDir));
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolve and read this project's `dev.json`.
|
|
103
|
+
*
|
|
104
|
+
* Never throws — a diagnostic has to work in the broken environment it exists to diagnose. `null` means the
|
|
105
|
+
* question does not arise: no readable root config, or one with no usable `name`, so there is no per-project
|
|
106
|
+
* path to resolve and nothing to report. The `Project:` block above it has already said which.
|
|
107
|
+
*/
|
|
108
|
+
export async function checkDevPreferences(
|
|
109
|
+
projectDir: string,
|
|
110
|
+
options: StatePathOptions = {},
|
|
111
|
+
): Promise<DevPreferencesCheck | null> {
|
|
112
|
+
const project = await preferenceProject(projectDir);
|
|
113
|
+
if (project === null) return null;
|
|
114
|
+
|
|
115
|
+
const path = devPreferencesPath(project, options);
|
|
116
|
+
let raw: string;
|
|
117
|
+
try {
|
|
118
|
+
raw = await readFile(path, "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return { state: "absent", path, user: null };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let parsed: unknown;
|
|
124
|
+
try {
|
|
125
|
+
parsed = JSON.parse(raw);
|
|
126
|
+
} catch {
|
|
127
|
+
return { state: "unparseable", path, user: null };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const named = NamedUser.safeParse(parsed);
|
|
131
|
+
if (!named.success) return { state: "no-user", path, user: null };
|
|
132
|
+
return { state: "ok", path, user: named.data.user };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The verdict half of the `Dev login:` line — the path is the caller's to render, because the text report
|
|
137
|
+
* abbreviates it against `$HOME` and `--json` does not.
|
|
138
|
+
*/
|
|
139
|
+
export function describeDevPreferences(check: DevPreferencesCheck): string {
|
|
140
|
+
switch (check.state) {
|
|
141
|
+
case "absent":
|
|
142
|
+
return "none yet; sign-in stays magic-link only";
|
|
143
|
+
// What the file says, and nothing about whether it is true. See {@link DevPreferencesState}.
|
|
144
|
+
case "ok":
|
|
145
|
+
return `names ${check.user}`;
|
|
146
|
+
case "unparseable":
|
|
147
|
+
return "will not parse; seed reads nothing from it";
|
|
148
|
+
case "no-user":
|
|
149
|
+
return 'no "user"; seed has nobody to sign in as';
|
|
150
|
+
case "could-not-check":
|
|
151
|
+
// Not "none yet". Nothing was read, so nothing is known — and the path is still the answer to the
|
|
152
|
+
// question this line exists for.
|
|
153
|
+
return "couldn't be checked";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parseDevVars } from "@pithy-sh/cloudflare/src/env/devVars";
|
|
7
|
+
import { sentenceOf } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
import { type DevSecretsFile, ENVELOPE_SHAPE } from "@pithy-sh/secrets/src/dev/devSecretsFile";
|
|
9
|
+
import { loadDevSecrets } from "@pithy-sh/secrets/src/dev/loadDevSecrets";
|
|
10
|
+
import { devSecretPayload, keyedSecretRefusal } from "@pithy-sh/secrets/src/dev/seedDevSecrets";
|
|
11
|
+
import type { SecretRegistryEntry } from "@pithy-sh/secrets/src/registry";
|
|
12
|
+
import { DEV_SECRETS_FILE_NAME, resolveDevSecretsFile } from "../devSecrets/location";
|
|
13
|
+
import { type DevSecretsTarget, resolveDevSecretsTargets, type UnresolvableWorker } from "../devSecrets/targets";
|
|
14
|
+
import { type StatePathOptions, stateDir } from "../notifier/state";
|
|
15
|
+
import { isCloudflareEnvKey } from "./devVars";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Whether this project's secrets are in the file they belong in — the migration notice for every project
|
|
19
|
+
* that predates the dev secrets file (#149).
|
|
20
|
+
*
|
|
21
|
+
* **Every line names the absolute path.** The file is outside the checkout since #156, so
|
|
22
|
+
* "your secrets file will not parse" named a file the reader could not find — see
|
|
23
|
+
* {@link checkDevSecretsLocation}, which prints the path on every run whether or not anything is wrong.
|
|
24
|
+
*
|
|
25
|
+
* **Reported, never fixed.** `pithy add` does not move an adopter's `.dev.vars` line and neither does
|
|
26
|
+
* this: rewriting a file someone hand-maintains, over a convention they have not read about yet, is how
|
|
27
|
+
* a toolchain loses trust. Doctor names each one and says where it goes, every run, until they move it.
|
|
28
|
+
*
|
|
29
|
+
* **A misplaced secret does not fail the exit either.** An existing project has these by definition, and
|
|
30
|
+
* an upgrade that turns a green `pithy doctor` red in CI over a file that still works is a surprise, not
|
|
31
|
+
* a diagnosis. The rule the block is held to: say it clearly, cost nothing.
|
|
32
|
+
*
|
|
33
|
+
* **Every backend, and only the project root's file (#178).** This reads `<root>/.dev.vars` — the
|
|
34
|
+
* hand-written one, which since #154 nothing but the CLI reads, and which the CLI reads for
|
|
35
|
+
* {@link CLOUDFLARE_ENV_KEYS} alone. A registry secret sitting there is inert whatever its backend, and
|
|
36
|
+
* that list is what says so. It used to be inferred from `backend: "d1"` instead, on the grounds that
|
|
37
|
+
* `CLOUDFLARE_API_TOKEN` has no local Secrets Store to live in — true of that one name, not of the
|
|
38
|
+
* backend, so the dashboard's own `cf-secrets-store` secrets sat stranded there unreported.
|
|
39
|
+
*
|
|
40
|
+
* The generated `apps/<w>/.dev.vars` is a different file and is not read here. A `cf-secrets-store`
|
|
41
|
+
* secret belongs in *that* one — it is the only place a Worker can read one from, and putting it there
|
|
42
|
+
* is what `pithy seed` is for.
|
|
43
|
+
*
|
|
44
|
+
* **And every copy it names is now inert (#153).** Through the transition the seeder wrote each `d1`
|
|
45
|
+
* value into `.dev.vars` as well, because that binding was where dev read it — so this block had to tell
|
|
46
|
+
* pithy's own current copy apart from an adopter's, or it would have told every project to do the one
|
|
47
|
+
* thing that broke dev. Dev reads the seeded row now and pithy writes no such copy, so what is left in
|
|
48
|
+
* `.dev.vars` is a line nothing reads, in either of the two ways it can get there.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/** Why a declared `d1` secret is sitting in `.dev.vars`. Two states, two different fixes. */
|
|
52
|
+
export type MisplacedDevSecretState =
|
|
53
|
+
/**
|
|
54
|
+
* Both files carry it. The move is done and the old line was never deleted — or it is the copy the
|
|
55
|
+
* #149 transition injected, which pithy no longer writes. Nothing reads it; it can go.
|
|
56
|
+
*/
|
|
57
|
+
| "duplicate"
|
|
58
|
+
/**
|
|
59
|
+
* Only `.dev.vars` has it: the pre-#149 project, with nothing moved yet. The migration notice — and
|
|
60
|
+
* since #153 the value is not resolving either, so the move is the fix rather than tidiness.
|
|
61
|
+
*/
|
|
62
|
+
| "unmoved";
|
|
63
|
+
|
|
64
|
+
/** One declared `d1` secret found in `.dev.vars`, and what its being there means. */
|
|
65
|
+
export interface MisplacedDevSecret {
|
|
66
|
+
/** The registry secret name, as it appears in both files. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Which of the two situations this is — delete the line, or move the value into the file. */
|
|
69
|
+
state: MisplacedDevSecretState;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One declared secret the file states, and the sentence saying why its value will not read. */
|
|
73
|
+
export interface MalformedDevSecret {
|
|
74
|
+
/** The registry secret name, exactly as the file and the registry both spell it. */
|
|
75
|
+
name: string;
|
|
76
|
+
/** Why it will not read — the seeder's own sentence. A shape and a version key, never a value. */
|
|
77
|
+
reason: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** What doctor learned about this project's dev secrets. */
|
|
81
|
+
export interface DevSecretsCheck {
|
|
82
|
+
/** The resolved absolute secrets-file path — the answer to "where does this go", file or no file. */
|
|
83
|
+
path: string;
|
|
84
|
+
/**
|
|
85
|
+
* Declared `d1`-backed secrets sitting in `.dev.vars`. Empty is the healthy state — and also the
|
|
86
|
+
* honest answer when {@link unreadable}, because the state of each one is decided against a file that
|
|
87
|
+
* would not parse.
|
|
88
|
+
*/
|
|
89
|
+
misplaced: MisplacedDevSecret[];
|
|
90
|
+
/**
|
|
91
|
+
* Declared secrets with no value in either file and nothing honest to mint — an OAuth client secret, a
|
|
92
|
+
* Stripe key. **Not a fault.** Almost every project sets none of auth's four provider pairs, and the
|
|
93
|
+
* capability that reads one fails with its own `secrets/not_found` naming it. It is here because this
|
|
94
|
+
* is the only place that can list them without running anything, and nowhere else says them once
|
|
95
|
+
* instead of on every `pithy dev`.
|
|
96
|
+
*/
|
|
97
|
+
missing: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Declared **bootstrap** secrets with no value — `SECRETS_ENCRYPTION_KEYS` above all. Kept apart from
|
|
100
|
+
* {@link missing} because the answer is a different one: nothing outside the project issues a master
|
|
101
|
+
* key, `pithy add secrets` mints it, and until it does the local `SECRETS` store cannot be opened at
|
|
102
|
+
* all. Telling somebody it is "issued by somebody else, fine to leave until you need it" is the one
|
|
103
|
+
* sentence that would send them past the thing actually stopping them.
|
|
104
|
+
*/
|
|
105
|
+
bootstrapMissing: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Declared secrets the file **states** and whose stated value will not read — a `json` value that
|
|
108
|
+
* violates its registry schema, a `text` one written as a number (#323).
|
|
109
|
+
*
|
|
110
|
+
* **A fault, unlike {@link missing}.** Presence was the entire check, so one of these passed doctor
|
|
111
|
+
* and then failed the next `pithy seed` — and doctor is the command whose whole job is catching that
|
|
112
|
+
* first. Judged through the seeder's own `storedSecretValue`, so the two cannot come to disagree
|
|
113
|
+
* about what a readable value is.
|
|
114
|
+
*
|
|
115
|
+
* Empty when {@link unreadable}: a file that will not parse states nothing to judge.
|
|
116
|
+
*/
|
|
117
|
+
malformed: MalformedDevSecret[];
|
|
118
|
+
/**
|
|
119
|
+
* Names in the secrets file that no capability declares — the residue of a removed capability, or a
|
|
120
|
+
* typo. **Not a fault either**, and never fatal to a seed: a stale line must not brick dev. Reported so
|
|
121
|
+
* a value nobody reads can be deleted rather than maintained.
|
|
122
|
+
*
|
|
123
|
+
* Empty when {@link unreadable}, and empty when anything is {@link unresolvable}: both are the same
|
|
124
|
+
* rule, which is that "no capability declares this" is a *negative* claim and a registry nobody could
|
|
125
|
+
* read is exactly what might have declared it.
|
|
126
|
+
*/
|
|
127
|
+
undeclared: string[];
|
|
128
|
+
/** The file's permission bits, or `null` when there is no file. Anything wider than `0o600` is a finding. */
|
|
129
|
+
mode: number | null;
|
|
130
|
+
/**
|
|
131
|
+
* Why the file is there and will not parse, or `null` when it parses (or is absent) — the one state
|
|
132
|
+
* that hides everything else.
|
|
133
|
+
*
|
|
134
|
+
* **The sentence, not a flag (#323).** The loader already names the secret it choked on and the shape
|
|
135
|
+
* it expected; a boolean threw that away and left doctor saying "run pithy seed to see which secret
|
|
136
|
+
* and why" — a second command to learn what this run already knew.
|
|
137
|
+
*/
|
|
138
|
+
unreadable: string | null;
|
|
139
|
+
/**
|
|
140
|
+
* Every Worker with a `pithy.config.ts` that would not import, and why (#208). Empty on an ordinary run.
|
|
141
|
+
*
|
|
142
|
+
* **This is what makes `null` mean one thing again.** `checkDevSecrets` returned `null` whenever the
|
|
143
|
+
* target list was empty, over a comment reading "`null` means no Worker composes `secrets`" — and it
|
|
144
|
+
* also meant every Worker's config failed to import, because the lossy target list answered both with
|
|
145
|
+
* `[]`. So the whole `Dev secrets:` block vanished in the one state it was written for. The sentence
|
|
146
|
+
* naming the Worker is {@link ./devVars}'s, which prints first in that block; this field is what stops
|
|
147
|
+
* *this* check from claiming a registry it never read.
|
|
148
|
+
*/
|
|
149
|
+
unresolvable: UnresolvableWorker[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What {@link checkDevSecrets} needs. `targets` is the seam; it defaults to the real project's Workers. */
|
|
153
|
+
export interface CheckDevSecretsOptions {
|
|
154
|
+
/** The project root. */
|
|
155
|
+
projectDir: string;
|
|
156
|
+
/** The Workers whose registries declare the secrets. Defaults to every one composing `secrets`. */
|
|
157
|
+
targets?: DevSecretsTarget[];
|
|
158
|
+
/**
|
|
159
|
+
* The Workers whose `pithy.config.ts` would not import. Read only when {@link targets} is supplied —
|
|
160
|
+
* both halves of one resolution, so a seam cannot state one and let the other default to a lie.
|
|
161
|
+
*/
|
|
162
|
+
unresolvable?: UnresolvableWorker[];
|
|
163
|
+
/** Where the Pithy config directory is. Defaults to the real one; a seam so a test reads its own. */
|
|
164
|
+
paths?: StatePathOptions;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Read the two files and compare them against the registry. Never throws — a diagnostic has to work in
|
|
169
|
+
* the broken environment it exists to diagnose.
|
|
170
|
+
*
|
|
171
|
+
* **`null` means no Worker composes `secrets`, and now only that** (#208). It used to mean that *or* that
|
|
172
|
+
* every Worker's config failed to import, because the lossy target list answered both states with `[]` —
|
|
173
|
+
* so the block that had the most to say was the one that said nothing. A resolution that failed is
|
|
174
|
+
* carried in {@link DevSecretsCheck.unresolvable} and reported.
|
|
175
|
+
*/
|
|
176
|
+
export async function checkDevSecrets(options: CheckDevSecretsOptions): Promise<DevSecretsCheck | null> {
|
|
177
|
+
const { targets, unresolvable } =
|
|
178
|
+
options.targets === undefined
|
|
179
|
+
? await resolveDevSecretsTargets(options.projectDir)
|
|
180
|
+
: { targets: options.targets, unresolvable: options.unresolvable ?? [] };
|
|
181
|
+
if (targets.length === 0 && unresolvable.length === 0) return null;
|
|
182
|
+
|
|
183
|
+
const path = await resolveDevSecretsFile(options.projectDir, options.paths ?? {});
|
|
184
|
+
const inDevVars = parseDevVars(await readFile(join(options.projectDir, ".dev.vars"), "utf8").catch(() => ""));
|
|
185
|
+
|
|
186
|
+
const source = await readFile(path, "utf8").catch(() => null);
|
|
187
|
+
let stated: DevSecretsFile = {};
|
|
188
|
+
let unreadable: string | null = null;
|
|
189
|
+
if (source !== null) {
|
|
190
|
+
try {
|
|
191
|
+
stated = loadDevSecrets(source, { path });
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// The loader's own sentence, kept. It names the secret, the file, and the shape it expected —
|
|
194
|
+
// everything the reader needs — and a boolean was throwing all three away (#323).
|
|
195
|
+
unreadable = sentenceOf(error);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const misplaced: MisplacedDevSecret[] = [];
|
|
200
|
+
const malformed: MalformedDevSecret[] = [];
|
|
201
|
+
const missing = new Set<string>();
|
|
202
|
+
const bootstrapMissing = new Set<string>();
|
|
203
|
+
const seen = new Set<string>();
|
|
204
|
+
for (const target of targets) {
|
|
205
|
+
for (const [name, entry] of Object.entries(target.registry)) {
|
|
206
|
+
if (seen.has(name)) continue;
|
|
207
|
+
seen.add(name);
|
|
208
|
+
// What the file states for this name, read before anything is decided about it. Hoisted above the
|
|
209
|
+
// keyspace branch because that branch is a judgment now rather than a skip, and the file is what
|
|
210
|
+
// decides which (#325). `Object.hasOwn` for the reason spelled out below.
|
|
211
|
+
const envelope = Object.hasOwn(stated, name) ? stated[name] : undefined;
|
|
212
|
+
// A keyspace has no single value: its members are written by the app at runtime, one per key.
|
|
213
|
+
// Nothing about it can be missing from a file that was never meant to carry it — so it is neither
|
|
214
|
+
// missing nor undeclared, and `seen` above is what keeps it out of the latter.
|
|
215
|
+
//
|
|
216
|
+
// **But a value stated for one is a fault, and it was the counterexample to this check's whole
|
|
217
|
+
// promise (#325).** `pithy seed` throws `Secret '<name>' … is a keyspace, not a single value.` on
|
|
218
|
+
// exactly this input; doctor skipped it before the file was consulted at all, so the one file the
|
|
219
|
+
// seeder hard-fails on was a file doctor called green. Through the seeder's own refusal, so the
|
|
220
|
+
// two cannot come to two wordings of one rule.
|
|
221
|
+
if (entry.keyed) {
|
|
222
|
+
if (envelope) malformed.push({ name, reason: sentenceOf(keyedSecretRefusal(name, path)) });
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
// `Object.hasOwn`, never `in`, and an own-property read of both maps. `in` walks the prototype
|
|
226
|
+
// chain, so a secret named `constructor` or `toString` read as stated in an empty file — and this
|
|
227
|
+
// is the module that judges adopter-supplied names against a registry, so it is where a name
|
|
228
|
+
// chosen to look like an `Object.prototype` key would be aimed.
|
|
229
|
+
const stranded = Object.hasOwn(inDevVars, name) ? inDevVars[name] : undefined;
|
|
230
|
+
if (stranded !== undefined && stranded !== "") {
|
|
231
|
+
// Whatever the backend. `backend` says where a *seeded* value lands — a D1 row, or a binding —
|
|
232
|
+
// and it was standing in for a different question: is the root `.dev.vars` a file anything
|
|
233
|
+
// reads this name out of. {@link CLOUDFLARE_ENV_KEYS} is the whole answer, and it is the list
|
|
234
|
+
// the readers themselves use. Which of the two states it is depends only on whether the
|
|
235
|
+
// secrets file states it too.
|
|
236
|
+
if (!isCloudflareEnvKey(name))
|
|
237
|
+
misplaced.push({ name, state: Object.hasOwn(stated, name) ? "duplicate" : "unmoved" });
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
// Stated is not the same as sound. `Object.hasOwn` was the whole check, so a value violating its
|
|
241
|
+
// own registry schema passed here and failed the next seed — and both `stated` and `entry.schema`
|
|
242
|
+
// were already in hand on this line (#323). Judged through the seeder's own function, so doctor
|
|
243
|
+
// and seed cannot come to two answers about what a readable value is.
|
|
244
|
+
if (envelope) {
|
|
245
|
+
const reason = whyUnreadable(entry, name, envelope, path);
|
|
246
|
+
if (reason) malformed.push({ name, reason });
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// Mintable means the next seed supplies it. Only a value that has to come from somewhere real is
|
|
250
|
+
// something the adopter has to do — and the file, not the store, is dev's source of truth for it.
|
|
251
|
+
if (entry.devValue) continue;
|
|
252
|
+
if (entry.bootstrap) bootstrapMissing.add(name);
|
|
253
|
+
else missing.add(name);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
misplaced.sort((a, b) => a.name.localeCompare(b.name));
|
|
257
|
+
malformed.sort((a, b) => a.name.localeCompare(b.name));
|
|
258
|
+
|
|
259
|
+
const mode = source === null ? null : ((await stat(path).catch(() => null))?.mode ?? 0) & 0o777;
|
|
260
|
+
// A file that will not parse tells us nothing about what is in it, so every declared secret would read
|
|
261
|
+
// as missing and nothing in it could be judged declared. Saying "your file is broken" once beats saying
|
|
262
|
+
// it again eleven times in other words.
|
|
263
|
+
// The same rule once more for a registry nobody could read (#208): the stated name may be the broken
|
|
264
|
+
// Worker's own declaration, and this is the negative claim of the four.
|
|
265
|
+
const undeclared =
|
|
266
|
+
unreadable || unresolvable.length > 0
|
|
267
|
+
? []
|
|
268
|
+
: Object.keys(stated)
|
|
269
|
+
.filter((name) => !seen.has(name))
|
|
270
|
+
.sort();
|
|
271
|
+
return {
|
|
272
|
+
path,
|
|
273
|
+
// Nor can it say anything true about `.dev.vars`. Both states are decided against what the secrets
|
|
274
|
+
// file states, and an unparseable file states nothing — so every copy would read as `unmoved` and the
|
|
275
|
+
// adopter would be told to move a value that is already there. Say the file is broken, once.
|
|
276
|
+
misplaced: unreadable ? [] : misplaced,
|
|
277
|
+
missing: unreadable ? [] : [...missing].sort(),
|
|
278
|
+
bootstrapMissing: unreadable ? [] : [...bootstrapMissing].sort(),
|
|
279
|
+
// And nothing about a stated value's shape either: a file that will not parse stated nothing.
|
|
280
|
+
malformed: unreadable ? [] : malformed,
|
|
281
|
+
undeclared,
|
|
282
|
+
mode,
|
|
283
|
+
unreadable,
|
|
284
|
+
unresolvable,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Why one stated value will not read, or `null` when it reads.
|
|
290
|
+
*
|
|
291
|
+
* **Through {@link devSecretPayload}, the seeder's own function.** Doctor's promise is that a green
|
|
292
|
+
* report means the next `pithy seed` works; a second implementation of "is this value sound" is exactly
|
|
293
|
+
* how that promise stops being true without anybody noticing.
|
|
294
|
+
*/
|
|
295
|
+
function whyUnreadable(entry: SecretRegistryEntry, name: string, stated: unknown, path: string): string | null {
|
|
296
|
+
try {
|
|
297
|
+
devSecretPayload(entry, name, stated, path);
|
|
298
|
+
return null;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
return sentenceOf(error);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whether the file is readable by anyone but its owner. `null` (no file) is not wide. */
|
|
305
|
+
function wideOpen(mode: number | null): boolean {
|
|
306
|
+
return mode !== null && (mode & 0o077) !== 0;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Whether there is anything here to *fix* — as opposed to anything to say.
|
|
311
|
+
*
|
|
312
|
+
* The distinction is the whole reason this is separate from {@link describeDevSecrets}. A missing OAuth
|
|
313
|
+
* client secret is worth listing and is not a fault: almost every project has four of them and will
|
|
314
|
+
* never set one. Counting it as a fault would drag the entire doctor report verbose for every project
|
|
315
|
+
* in the world, which is how a report stops being read.
|
|
316
|
+
*/
|
|
317
|
+
export function devSecretsHealthy(check: DevSecretsCheck): boolean {
|
|
318
|
+
// Every misplaced secret counts now. Through the transition one state did not — pithy wrote that copy
|
|
319
|
+
// itself, every run, and calling the toolchain's own bookkeeping a fault would have dragged every
|
|
320
|
+
// project on the branch verbose forever. It writes none, so there is nothing left to excuse.
|
|
321
|
+
//
|
|
322
|
+
// A malformed value counts too, and is the one addition that is not a matter of taste: the adopter
|
|
323
|
+
// wrote it, meant it to work, and the next seed will refuse it. That is a fault by every definition
|
|
324
|
+
// this function has ever used.
|
|
325
|
+
return check.misplaced.length === 0 && check.malformed.length === 0 && !check.unreadable && !wideOpen(check.mode);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* The lines the report prints for this check, or none at all when there is nothing to say. Silence is
|
|
330
|
+
* the healthy answer: a project with its secrets in the right file does not need a line telling it so.
|
|
331
|
+
*/
|
|
332
|
+
export function describeDevSecrets(check: DevSecretsCheck): string[] {
|
|
333
|
+
const lines: string[] = [];
|
|
334
|
+
if (check.unreadable) {
|
|
335
|
+
// The loader's sentence, not a pointer at another command. It already names the secret and the
|
|
336
|
+
// shape; "run pithy seed to see which secret and why" spent a round trip re-deriving it (#323).
|
|
337
|
+
lines.push(check.unreadable);
|
|
338
|
+
}
|
|
339
|
+
for (const { name, state } of check.misplaced) {
|
|
340
|
+
lines.push(describeMisplaced(name, state, check.path));
|
|
341
|
+
}
|
|
342
|
+
for (const { reason } of check.malformed) {
|
|
343
|
+
lines.push(reason);
|
|
344
|
+
}
|
|
345
|
+
if (check.undeclared.length > 0) {
|
|
346
|
+
lines.push(
|
|
347
|
+
`${check.path} carries ${check.undeclared.join(", ")}, which no capability declares. Nothing reads them.`,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
if (check.bootstrapMissing.length > 0) {
|
|
351
|
+
lines.push(
|
|
352
|
+
`No dev value for ${check.bootstrapMissing.join(", ")}. Run pithy add secrets — it mints one into ${check.path}. Until then the local SECRETS store cannot be opened.`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (check.missing.length > 0) {
|
|
356
|
+
lines.push(
|
|
357
|
+
`No dev value for ${check.missing.join(", ")}. Each is issued by somebody else, so nothing mints one. Fine to leave until you need it.`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
if (wideOpen(check.mode)) {
|
|
361
|
+
lines.push(
|
|
362
|
+
`${check.path} is mode ${(check.mode ?? 0).toString(8)}. It holds OAuth client secrets. Run chmod 600 on it.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return lines;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* The one sentence for each state. Both name the file the value belongs in, because it is outside the
|
|
370
|
+
* checkout and nothing in the project points at it.
|
|
371
|
+
*
|
|
372
|
+
* Neither says the value is at risk, and that is the honest reading: `.dev.vars` is not read for a `d1`
|
|
373
|
+
* secret any more, so a line there is inert rather than competing. Saying "dev reads that one" was true
|
|
374
|
+
* before #153 and is the sentence that would send an adopter the wrong way now.
|
|
375
|
+
*/
|
|
376
|
+
function describeMisplaced(name: string, state: MisplacedDevSecretState, path: string): string {
|
|
377
|
+
switch (state) {
|
|
378
|
+
case "duplicate":
|
|
379
|
+
return `${name} is in .dev.vars as well as ${path}. Nothing reads the .dev.vars one — delete that line.`;
|
|
380
|
+
case "unmoved":
|
|
381
|
+
return `${name} is in .dev.vars, which dev no longer reads. Run pithy secrets edit, and write it into ${path} as ${ENVELOPE_SHAPE}.`;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Where this project's secrets file is, whether it is there, and which sibling project directories
|
|
387
|
+
* hold one — the block that exists because the file no longer does (#156).
|
|
388
|
+
*
|
|
389
|
+
* **The path prints on every run, healthy or not — terse report included (#166).** Everything else in
|
|
390
|
+
* `pithy doctor` reports a fault; this reports a location, because a file outside the checkout is
|
|
391
|
+
* invisible otherwise. Nothing in the project names it, `ls` will not find it, and "where are my dev
|
|
392
|
+
* secrets" has no other answer. Suppressing it when nothing is wrong hid it from the one developer with
|
|
393
|
+
* no other symptom to search on.
|
|
394
|
+
*
|
|
395
|
+
* **The orphan list is the rename trail.** The directory is keyed on the project's `name`, so renaming
|
|
396
|
+
* a project — or scaffolding a second one that happens to share a name — silently changes which file
|
|
397
|
+
* every command reads. The old one is still there with every value in it, and nothing would ever
|
|
398
|
+
* mention it again. Listed **only when this project has no file of its own**, which is exactly the
|
|
399
|
+
* shape a rename leaves and is silence for every developer whose project is working: a machine with
|
|
400
|
+
* six projects on it should not hear about five of them on every run.
|
|
401
|
+
*/
|
|
402
|
+
export interface DevSecretsLocationCheck {
|
|
403
|
+
/** The resolved absolute path — `<config>/<project>/secrets.jsonc`. Always set, file or no file. */
|
|
404
|
+
path: string;
|
|
405
|
+
/** Whether anything is at {@link path}. `false` is the ordinary state of a project with no secrets. */
|
|
406
|
+
present: boolean;
|
|
407
|
+
/**
|
|
408
|
+
* Other project names under the config directory that do have a secrets file, sorted. Empty unless
|
|
409
|
+
* this project has none of its own — see above. Names, never paths and never values.
|
|
410
|
+
*/
|
|
411
|
+
orphans: string[];
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Resolve the path and look around it. Never throws — a diagnostic has to work in the broken
|
|
416
|
+
* environment it exists to diagnose. `null` means there is no project name to key on, which is the
|
|
417
|
+
* same condition {@link checkDevPreferences} declines to answer under, and the `Project:` block above
|
|
418
|
+
* has already said which of the two it is.
|
|
419
|
+
*/
|
|
420
|
+
export async function checkDevSecretsLocation(
|
|
421
|
+
projectDir: string,
|
|
422
|
+
options: StatePathOptions = {},
|
|
423
|
+
): Promise<DevSecretsLocationCheck | null> {
|
|
424
|
+
let path: string;
|
|
425
|
+
try {
|
|
426
|
+
path = await resolveDevSecretsFile(projectDir, options);
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const present = (await stat(path).catch(() => null)) !== null;
|
|
431
|
+
return { path, present, orphans: present ? [] : await siblingsWithSecrets(stateDir(options), path) };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Project directories under the config root that carry a secrets file, minus this project's own.
|
|
436
|
+
*
|
|
437
|
+
* `readdir` and one `stat` each — no config is loaded, because these are other checkouts' projects and
|
|
438
|
+
* this machine may no longer have any of them. That is the whole point: a directory whose project is
|
|
439
|
+
* gone is exactly the one nothing else can report.
|
|
440
|
+
*/
|
|
441
|
+
async function siblingsWithSecrets(configDir: string, ours: string): Promise<string[]> {
|
|
442
|
+
const entries = await readdir(configDir, { withFileTypes: true }).catch(() => []);
|
|
443
|
+
const found: string[] = [];
|
|
444
|
+
for (const entry of entries) {
|
|
445
|
+
if (!entry.isDirectory()) continue;
|
|
446
|
+
const candidate = join(configDir, entry.name, DEV_SECRETS_FILE_NAME);
|
|
447
|
+
if (candidate === ours) continue;
|
|
448
|
+
if ((await stat(candidate).catch(() => null)) !== null) found.push(entry.name);
|
|
449
|
+
}
|
|
450
|
+
return found.sort();
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The verdict half of the `Secrets:` line — the path is the caller's to render, because the text report
|
|
455
|
+
* abbreviates it against `$HOME` and `--json` does not. `null` when there is nothing to add: a file
|
|
456
|
+
* that is there and a config directory with nothing else in it need no sentence at all.
|
|
457
|
+
*/
|
|
458
|
+
export function describeDevSecretsLocation(check: DevSecretsLocationCheck): string | null {
|
|
459
|
+
if (check.present) return null;
|
|
460
|
+
if (check.orphans.length === 0) return "no file yet; pithy add mints one when a capability needs it";
|
|
461
|
+
// Named rather than diagnosed. This cannot tell a rename from two unrelated projects on one machine,
|
|
462
|
+
// and guessing which would be worse than listing what is there and letting the reader recognize it.
|
|
463
|
+
return `no file yet; secrets exist for ${check.orphans.join(", ")} — a renamed project leaves its old name here`;
|
|
464
|
+
}
|