@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.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +72 -0
  3. package/scripts/templateManifest.ts +49 -0
  4. package/scripts/tsconfig.json +26 -0
  5. package/scripts/vendorTemplate.ts +84 -0
  6. package/scripts/verifyPack.ts +88 -0
  7. package/src/audit/cliAudit.ts +406 -0
  8. package/src/bin.ts +111 -0
  9. package/src/capabilities/add.ts +288 -0
  10. package/src/capabilities/addBootstrap.ts +275 -0
  11. package/src/capabilities/catalog.ts +175 -0
  12. package/src/capabilities/compose.ts +39 -0
  13. package/src/capabilities/configConstants.ts +74 -0
  14. package/src/capabilities/configImports.ts +397 -0
  15. package/src/capabilities/eject.ts +331 -0
  16. package/src/capabilities/emailProvisioner.ts +346 -0
  17. package/src/capabilities/entitlementGap.ts +70 -0
  18. package/src/capabilities/entryExports.ts +162 -0
  19. package/src/capabilities/flow.ts +550 -0
  20. package/src/capabilities/hostRegistry.ts +368 -0
  21. package/src/capabilities/loadFailure.ts +208 -0
  22. package/src/capabilities/manifests.ts +238 -0
  23. package/src/capabilities/mediaProvisioner.ts +471 -0
  24. package/src/capabilities/mintSecrets.ts +306 -0
  25. package/src/capabilities/paymentsProvisioner.ts +207 -0
  26. package/src/capabilities/prerequisites.ts +168 -0
  27. package/src/capabilities/r2Bucket.ts +113 -0
  28. package/src/capabilities/reconcile.ts +1483 -0
  29. package/src/capabilities/remove.ts +597 -0
  30. package/src/capabilities/requiredOptions.ts +92 -0
  31. package/src/capabilities/rotateSecrets.ts +305 -0
  32. package/src/capabilities/secrets.ts +178 -0
  33. package/src/capabilities/secretsDispatcher.ts +29 -0
  34. package/src/capabilities/secretsProvisioner.ts +389 -0
  35. package/src/capabilities/storageProvisioner.ts +414 -0
  36. package/src/capabilities/supportProvisioner.ts +515 -0
  37. package/src/capabilities/testersLoader.ts +52 -0
  38. package/src/capabilities/testersProvisioner.ts +236 -0
  39. package/src/capabilities/turnstileProvisioner.ts +347 -0
  40. package/src/capabilities/vectorProvisioner.ts +260 -0
  41. package/src/ci/fileModes.ts +223 -0
  42. package/src/ci/sourceFiles.ts +200 -0
  43. package/src/ci/workflowDrivers.ts +524 -0
  44. package/src/cloudflare/accountAnswer.ts +110 -0
  45. package/src/cloudflare/config.ts +685 -0
  46. package/src/cloudflare/storeId.ts +129 -0
  47. package/src/commands/add.ts +372 -0
  48. package/src/commands/alias.ts +205 -0
  49. package/src/commands/dashboard.ts +651 -0
  50. package/src/commands/deploy.ts +150 -0
  51. package/src/commands/dev.ts +37 -0
  52. package/src/commands/doctor.ts +2059 -0
  53. package/src/commands/email.ts +425 -0
  54. package/src/commands/env.ts +155 -0
  55. package/src/commands/feature.ts +359 -0
  56. package/src/commands/init.ts +538 -0
  57. package/src/commands/media.ts +303 -0
  58. package/src/commands/migrate.ts +129 -0
  59. package/src/commands/payments.ts +336 -0
  60. package/src/commands/provision.ts +368 -0
  61. package/src/commands/remove.ts +151 -0
  62. package/src/commands/secrets.ts +652 -0
  63. package/src/commands/seed.ts +229 -0
  64. package/src/commands/storage.ts +309 -0
  65. package/src/commands/support.ts +331 -0
  66. package/src/commands/testers.ts +1020 -0
  67. package/src/commands/token.ts +364 -0
  68. package/src/commands/turnstile.ts +271 -0
  69. package/src/commands/ui.ts +222 -0
  70. package/src/commands/upgrade.ts +517 -0
  71. package/src/commands/vector.ts +390 -0
  72. package/src/commands/worker.ts +295 -0
  73. package/src/dashboard/api.ts +323 -0
  74. package/src/dashboard/connect.ts +758 -0
  75. package/src/dashboard/contract.ts +289 -0
  76. package/src/dashboard/grant.ts +124 -0
  77. package/src/dashboard/registry.ts +519 -0
  78. package/src/dashboard/resolveTarget.ts +119 -0
  79. package/src/dev/delivery.ts +174 -0
  80. package/src/dev/devLogin.ts +155 -0
  81. package/src/dev/devLoginTargets.ts +91 -0
  82. package/src/dev/env.ts +206 -0
  83. package/src/dev/hostWorkers.ts +290 -0
  84. package/src/dev/keys.ts +111 -0
  85. package/src/dev/logging.ts +87 -0
  86. package/src/dev/openUrl.ts +75 -0
  87. package/src/dev/orchestrator.ts +1014 -0
  88. package/src/dev/ports.ts +220 -0
  89. package/src/dev/readyWatch.ts +142 -0
  90. package/src/dev/state.ts +90 -0
  91. package/src/devSecrets/bootstrapVars.ts +265 -0
  92. package/src/devSecrets/devVars.ts +240 -0
  93. package/src/devSecrets/edit.ts +256 -0
  94. package/src/devSecrets/file.ts +277 -0
  95. package/src/devSecrets/generate.ts +428 -0
  96. package/src/devSecrets/location.ts +80 -0
  97. package/src/devSecrets/mode.ts +71 -0
  98. package/src/devSecrets/records.ts +30 -0
  99. package/src/devSecrets/report.ts +99 -0
  100. package/src/devSecrets/seed.ts +344 -0
  101. package/src/devSecrets/store.ts +262 -0
  102. package/src/devSecrets/targets.ts +204 -0
  103. package/src/dispatch.ts +147 -0
  104. package/src/docs/catalog.ts +246 -0
  105. package/src/docs/writeCatalog.ts +45 -0
  106. package/src/doctor/cloudflare.ts +287 -0
  107. package/src/doctor/devPreferences.ts +155 -0
  108. package/src/doctor/devSecrets.ts +464 -0
  109. package/src/doctor/devVars.ts +414 -0
  110. package/src/doctor/devVarsLocal.ts +138 -0
  111. package/src/doctor/environments.ts +155 -0
  112. package/src/doctor/health.ts +354 -0
  113. package/src/doctor/localDelivery.ts +91 -0
  114. package/src/doctor/portsRegistry.ts +252 -0
  115. package/src/doctor/projectName.ts +584 -0
  116. package/src/doctor/secretBindings.ts +166 -0
  117. package/src/doctor/settings.ts +274 -0
  118. package/src/doctor/settingsSources.ts +202 -0
  119. package/src/doctor/workerName.ts +174 -0
  120. package/src/doctor/wranglerVars.ts +33 -0
  121. package/src/feature/bindings.ts +93 -0
  122. package/src/feature/create.ts +179 -0
  123. package/src/feature/destroy.ts +160 -0
  124. package/src/feature/devConfig.ts +201 -0
  125. package/src/feature/identity.ts +100 -0
  126. package/src/feature/manifest.ts +132 -0
  127. package/src/feature/ports.ts +615 -0
  128. package/src/feature/provision.ts +362 -0
  129. package/src/feature/sync.ts +148 -0
  130. package/src/feature/worktree.ts +282 -0
  131. package/src/help/groups.ts +47 -0
  132. package/src/help/rootUsage.ts +135 -0
  133. package/src/main.ts +73 -0
  134. package/src/migrations/ledger.ts +129 -0
  135. package/src/migrations/registry.ts +47 -0
  136. package/src/migrations/run.ts +1066 -0
  137. package/src/notifier/check.ts +129 -0
  138. package/src/notifier/installer.ts +48 -0
  139. package/src/notifier/notify.ts +152 -0
  140. package/src/notifier/state.ts +248 -0
  141. package/src/notifier/version.ts +59 -0
  142. package/src/platform/editor.ts +333 -0
  143. package/src/platform/rc.ts +118 -0
  144. package/src/platform/shell.ts +83 -0
  145. package/src/project/appBindings.ts +184 -0
  146. package/src/project/appWorkflows.ts +266 -0
  147. package/src/project/applyDomains.ts +166 -0
  148. package/src/project/askDomains.ts +220 -0
  149. package/src/project/atomic.ts +466 -0
  150. package/src/project/bindingEntries.ts +425 -0
  151. package/src/project/config.ts +701 -0
  152. package/src/project/dashboard.ts +118 -0
  153. package/src/project/deploy.ts +364 -0
  154. package/src/project/devVars.ts +113 -0
  155. package/src/project/domainPrompt.ts +191 -0
  156. package/src/project/domains.ts +386 -0
  157. package/src/project/envInventory.ts +356 -0
  158. package/src/project/environment.ts +125 -0
  159. package/src/project/extensions.ts +69 -0
  160. package/src/project/jsonc.ts +289 -0
  161. package/src/project/packageManager.ts +238 -0
  162. package/src/project/readOptionalFile.ts +342 -0
  163. package/src/project/rollback.ts +145 -0
  164. package/src/project/scaffold.ts +1088 -0
  165. package/src/project/templateFiles.ts +53 -0
  166. package/src/project/verifyDeploy.ts +230 -0
  167. package/src/project/versionMetadata.ts +77 -0
  168. package/src/project/workerAddress.ts +176 -0
  169. package/src/project/workerCommand.ts +564 -0
  170. package/src/project/workerIdentity.ts +50 -0
  171. package/src/project/workerManifest.ts +135 -0
  172. package/src/project/workerScaffold.ts +289 -0
  173. package/src/project/workerScope.ts +394 -0
  174. package/src/project/workers.ts +86 -0
  175. package/src/project/workflows.ts +281 -0
  176. package/src/project/wrangler.ts +168 -0
  177. package/src/provision/confirm.ts +86 -0
  178. package/src/provision/environment.ts +407 -0
  179. package/src/provision/featureConfig.ts +98 -0
  180. package/src/provision/mode.ts +62 -0
  181. package/src/provision/pendingSecrets.ts +96 -0
  182. package/src/provision/resources.ts +126 -0
  183. package/src/provision/secretBindings.ts +149 -0
  184. package/src/provision/store.ts +33 -0
  185. package/src/provision/unprovisioned.ts +114 -0
  186. package/src/provision/wranglerEnv.ts +220 -0
  187. package/src/rootFlags.ts +48 -0
  188. package/src/seed/drivers.ts +423 -0
  189. package/src/seed/media.ts +187 -0
  190. package/src/seed/plan.ts +137 -0
  191. package/src/seed/prepare.ts +224 -0
  192. package/src/seed/registry.ts +25 -0
  193. package/src/seed/run.ts +793 -0
  194. package/src/seed/safety.ts +206 -0
  195. package/src/terminal/logger.ts +42 -0
  196. package/src/terminal/output.ts +64 -0
  197. package/src/terminal/style.ts +132 -0
  198. package/src/test-utils/doctorHarness.ts +190 -0
  199. package/src/test-utils/migrateHarness.ts +126 -0
  200. package/src/test-utils/seedHarness.ts +173 -0
  201. package/src/test-utils/tempRepo.ts +45 -0
  202. package/src/tokens/config.ts +16 -0
  203. package/src/tokens/engine.ts +345 -0
  204. package/src/tokens/mintedTokens.ts +233 -0
  205. package/src/tokens/sinks.ts +84 -0
  206. package/src/ui/flow.ts +451 -0
  207. package/src/ui/react.ts +112 -0
  208. package/src/ui/routeAllowlist.ts +208 -0
  209. package/src/ui/scaffold.ts +113 -0
  210. package/src/ui/screenStyles.ts +127 -0
  211. package/src/ui/stubs.ts +135 -0
  212. package/src/ui/templates.ts +52 -0
  213. package/src/ui/wire.ts +311 -0
  214. package/src/ui/workerUi.ts +172 -0
  215. package/templates/starter/.dev.secrets.example.jsonc +43 -0
  216. package/templates/starter/.dev.vars.example +30 -0
  217. package/templates/starter/apps/api/package.json +22 -0
  218. package/templates/starter/apps/api/pithy.config.ts +65 -0
  219. package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
  220. package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
  221. package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
  222. package/templates/starter/apps/api/src/index.ts +8 -0
  223. package/templates/starter/apps/api/tsconfig.json +26 -0
  224. package/templates/starter/apps/api/wrangler.jsonc +68 -0
  225. package/templates/starter/biome.template.jsonc +75 -0
  226. package/templates/starter/gitignore +37 -0
  227. package/templates/starter/package.json +28 -0
  228. package/templates/starter/pithy.config.ts +67 -0
  229. package/templates/starter/plugins/no-console.grit +25 -0
  230. package/templates/starter/plugins/no-process-io.grit +25 -0
  231. package/templates/starter/tsconfig.json +14 -0
  232. package/templates/starter/tsconfig.tools.json +30 -0
  233. package/templates/starter/vitest.config.ts +124 -0
  234. package/templates/starter/vitest.workers.config.ts +26 -0
@@ -0,0 +1,129 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { detectInstaller } from "./installer";
5
+ import type { NotifierState } from "./state";
6
+
7
+ /**
8
+ * The background update check (docs/CLI.md §5.1): a 24-hour-gated query of the npm registry for the latest
9
+ * `@pithy-sh/cli` version, plus the per-capability version lookups `pithy doctor` runs. Every network path
10
+ * is silent on failure — offline, DNS, a non-200, or a timeout all leave the cached value in place and
11
+ * never surface an error. Update checks are non-essential; they must never break the actual CLI.
12
+ */
13
+
14
+ /** A minimal `fetch` shape — the real `globalThis.fetch` satisfies it, and tests inject a fake. */
15
+ export type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<FetchResponse>;
16
+
17
+ /** The slice of a `fetch` `Response` the notifier reads. */
18
+ export interface FetchResponse {
19
+ ok: boolean;
20
+ status: number;
21
+ json(): Promise<unknown>;
22
+ }
23
+
24
+ /** The npm scope every Pithy package ships under. */
25
+ const SCOPE = "@pithy-sh";
26
+ /** How long a stale check waits before giving up on the registry, so a hung request never delays exit. */
27
+ const DEFAULT_TIMEOUT_MS = 3_000;
28
+ /** The cache window: at most one background check per 24 hours. */
29
+ export const CACHE_MS = 24 * 60 * 60 * 1000;
30
+
31
+ /** The registry `latest`-manifest URL for an unscoped `@pithy-sh/<name>` package (matches docs/CLI.md §5.1). */
32
+ export function registryUrl(unscopedName: string): string {
33
+ return `https://registry.npmjs.org/${SCOPE}%2F${unscopedName}/latest`;
34
+ }
35
+
36
+ /** The latest published version of a package, plus whether the release is security-flagged. */
37
+ export interface LatestInfo {
38
+ version: string;
39
+ securityFlagged: boolean;
40
+ }
41
+
42
+ /** Read the `pithy:security` marker off a registry manifest: a top-level flag or a nested `pithy.security`. */
43
+ function readSecurityFlag(manifest: Record<string, unknown>): boolean {
44
+ if (manifest["pithy:security"] === true) return true;
45
+ const pithy = manifest.pithy;
46
+ return typeof pithy === "object" && pithy !== null && (pithy as { security?: unknown }).security === true;
47
+ }
48
+
49
+ /**
50
+ * Query the registry for a package's latest version. Returns `null` on any failure — a rejected fetch, a
51
+ * non-200, a timeout, or an unparseable body — so callers keep using their cached value. A short timeout
52
+ * bounds a hung registry. The security flag defaults to not-flagged when it can't be determined.
53
+ */
54
+ export async function fetchLatestVersion(
55
+ unscopedName: string,
56
+ options: { fetch?: FetchLike; timeoutMs?: number } = {},
57
+ ): Promise<LatestInfo | null> {
58
+ const doFetch = options.fetch ?? (globalThis.fetch as unknown as FetchLike);
59
+ const controller = new AbortController();
60
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
61
+ // Don't let the abort timer itself hold the event loop open — the update check must never delay the CLI's
62
+ // exit past its own work. (Node timers expose unref; guard for non-Node timers.)
63
+ timer.unref?.();
64
+ try {
65
+ const response = await doFetch(registryUrl(unscopedName), { signal: controller.signal });
66
+ if (!response.ok) return null;
67
+ const body = (await response.json()) as Record<string, unknown>;
68
+ const version = body.version;
69
+ if (typeof version !== "string") return null;
70
+ return { version, securityFlagged: readSecurityFlag(body) };
71
+ } catch {
72
+ return null;
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+
78
+ /** Options for {@link refreshCliState} — the version-check inputs, all injectable for deterministic tests. */
79
+ export interface RefreshOptions {
80
+ /** Injected `fetch`; defaults to the global. */
81
+ fetch?: FetchLike;
82
+ /** The clock; defaults to `Date.now`. */
83
+ now?: () => number;
84
+ /** Bypass the 24-hour cache and always query (what `pithy doctor` passes). */
85
+ bypassCache?: boolean;
86
+ /** The cache window in ms; defaults to 24 hours. */
87
+ cacheMs?: number;
88
+ /** `process.argv[1]`, used to detect the installer once when it is still `unknown`. */
89
+ argv1?: string;
90
+ /** Registry request timeout in ms. */
91
+ timeoutMs?: number;
92
+ }
93
+
94
+ /** The outcome of a refresh: the (possibly-updated) state and whether a successful network check changed it. */
95
+ export interface RefreshResult {
96
+ state: NotifierState;
97
+ /** True only when a network query succeeded and updated the state — the signal to persist it. */
98
+ updated: boolean;
99
+ }
100
+
101
+ /**
102
+ * Apply the 24-hour cache gate to the CLI version check and, when due, refresh the state from the registry.
103
+ *
104
+ * - Within the cache window (and not bypassing): no network request; the cached state is returned unchanged.
105
+ * - Stale (or bypassing): one registry query. On success, `lastCheck`, `latestVersion`, `securityFlagged`,
106
+ * and — if still `unknown` — `installer` are updated, and `updated` is true (persist it).
107
+ * - On any network failure: the cached state is returned unchanged with `updated` false, so no partial
108
+ * state is ever written and the previously-known `latestVersion` continues to drive the notification.
109
+ */
110
+ export async function refreshCliState(state: NotifierState, options: RefreshOptions = {}): Promise<RefreshResult> {
111
+ const now = options.now ?? Date.now;
112
+ const cacheMs = options.cacheMs ?? CACHE_MS;
113
+ const fresh = now() - state.lastCheck < cacheMs;
114
+ if (fresh && !options.bypassCache) return { state, updated: false };
115
+
116
+ const latest = await fetchLatestVersion("cli", { fetch: options.fetch, timeoutMs: options.timeoutMs });
117
+ if (!latest) return { state, updated: false };
118
+
119
+ return {
120
+ state: {
121
+ ...state,
122
+ lastCheck: now(),
123
+ latestVersion: latest.version,
124
+ securityFlagged: latest.securityFlagged,
125
+ installer: state.installer === "unknown" ? detectInstaller(options.argv1) : state.installer,
126
+ },
127
+ updated: true,
128
+ };
129
+ }
@@ -0,0 +1,48 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Detect which package manager installed the `pithy` binary and, from that, the exact upgrade command to
6
+ * offer. Ships docs/CLI.md §5.3 verbatim: path-based detection over `process.argv[1]`, order-sensitive so
7
+ * the `.bun`/`.deno` install roots win before the generic `node_modules` npm test. Detection runs once and
8
+ * is cached in the notifier state file — a binary's install location doesn't change under it.
9
+ */
10
+
11
+ /** The package managers Pithy recognizes, plus `unknown` for anything unmatched (which falls back to npm). */
12
+ export type Installer = "npm" | "pnpm" | "yarn" | "bun" | "deno" | "brew" | "unknown";
13
+
14
+ /**
15
+ * Detect the installer from a binary path (defaults to `process.argv[1]`). Backslashes are normalized to
16
+ * forward slashes first so a Windows path matches the same tests. Order matters: `.bun`/`.deno` roots are
17
+ * tested before the generic npm `node_modules` catch, and Homebrew before yarn.
18
+ */
19
+ export function detectInstaller(argv1: string = process.argv[1] ?? ""): Installer {
20
+ const binPath = argv1.replace(/\\/g, "/");
21
+
22
+ if (binPath.includes("/.bun/")) return "bun";
23
+ if (binPath.includes("/.deno/")) return "deno";
24
+ if (/\/pnpm\/|\/\.pnpm\//.test(binPath)) return "pnpm";
25
+ if (/\/(?:home|linux)brew\/|\/Cellar\//.test(binPath)) return "brew";
26
+ if (/\/\.yarn\/|\/yarn\/global\//.test(binPath)) return "yarn";
27
+ if (/\/npm\/|\/node_modules\//.test(binPath)) return "npm";
28
+
29
+ return "unknown";
30
+ }
31
+
32
+ /** The upgrade command for an installer. `npm`/`unknown` fall back to a global npm install — anyone with Node has npm. */
33
+ export function upgradeCommandFor(installer: Installer): string {
34
+ switch (installer) {
35
+ case "bun":
36
+ return "bun update -g @pithy-sh/cli";
37
+ case "pnpm":
38
+ return "pnpm update -g @pithy-sh/cli";
39
+ case "yarn":
40
+ return "yarn global upgrade @pithy-sh/cli";
41
+ case "deno":
42
+ return "deno install --reload -g -A -n pithy npm:@pithy-sh/cli";
43
+ case "brew":
44
+ return "brew upgrade pithy";
45
+ default:
46
+ return "npm i -g @pithy-sh/cli";
47
+ }
48
+ }
@@ -0,0 +1,152 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { saffron } from "../terminal/style";
5
+ import { type FetchLike, refreshCliState } from "./check";
6
+ import { type Installer, upgradeCommandFor } from "./installer";
7
+ import { readState, stateFilePath, writeState } from "./state";
8
+ import { type Bump, classifyBump, parseVersion } from "./version";
9
+
10
+ /**
11
+ * The end-of-command update notice (docs/CLI.md §5.2): its suppression rules, its exact stderr strings, and
12
+ * {@link runUpdateNotifier} — the fire-and-forget entry point `bin.ts` calls after a command's primary work.
13
+ * The check is `setImmediate`-scheduled so it never blocks the command, silent on every failure, and the
14
+ * notice prints to stderr only when every suppression rule holds.
15
+ */
16
+
17
+ /** The inputs the four suppression rules read. All must hold for the notice to print. */
18
+ export interface SuppressionInput {
19
+ /** `process.stderr.isTTY` — piped/CI output suppresses the whole notice. */
20
+ isTTY: boolean;
21
+ /** Environment map — `PITHY_NO_UPDATE_NOTIFIER` (any value) suppresses. */
22
+ env: NodeJS.ProcessEnv;
23
+ /** The state opt-out flag: `false` suppresses. */
24
+ notifierEnabled: boolean;
25
+ /** The version gap. */
26
+ bump: Bump;
27
+ /** Whether the latest release is security-flagged (lets a patch bump through). */
28
+ securityFlagged: boolean;
29
+ }
30
+
31
+ /**
32
+ * Whether the update notice may print. Every rule must hold: stderr is a TTY, `PITHY_NO_UPDATE_NOTIFIER`
33
+ * is unset, the state flag is enabled, and the bump is minor/major (or a security-flagged patch). A plain
34
+ * patch is suppressed — patch noise trains users to ignore the surface. `NO_COLOR` is deliberately not a
35
+ * rule here: it only drops the accent (handled by the `saffron` seam), never suppresses.
36
+ */
37
+ export function shouldNotify(input: SuppressionInput): boolean {
38
+ if (!input.isTTY) return false;
39
+ if (input.env.PITHY_NO_UPDATE_NOTIFIER) return false;
40
+ if (!input.notifierEnabled) return false;
41
+ if (input.bump === "minor" || input.bump === "major") return true;
42
+ if (input.bump === "patch" && input.securityFlagged) return true;
43
+ return false;
44
+ }
45
+
46
+ /** Parameters for the notice string. `accent` is the saffron seam, injectable so tests can assert placement. */
47
+ export interface NoticeInput {
48
+ installed: string;
49
+ latest: string;
50
+ installer: Installer;
51
+ bump: Bump;
52
+ accent?: (text: string) => string;
53
+ }
54
+
55
+ /**
56
+ * Build the stderr notice. Starts with a blank line (it follows the command's `Done.`). The new version and
57
+ * the word `available` carry the accent; a major bump adds the `(Major release — see changelog.)` note and a
58
+ * `Changelog:` line pointing at `https://pithy.sh/changelog/<newMajor>.0`.
59
+ */
60
+ export function formatUpdateNotice(input: NoticeInput): string {
61
+ const accent = input.accent ?? saffron;
62
+ const command = upgradeCommandFor(input.installer);
63
+ const major = parseVersion(input.latest)?.major ?? 0;
64
+
65
+ const lead = `pithy ${accent(input.latest)} ${accent("available")}. You have ${input.installed}.`;
66
+ const lines = [""];
67
+ if (input.bump === "major") {
68
+ lines.push(`${lead} (Major release — see changelog.)`);
69
+ lines.push(`Update: ${command}`);
70
+ lines.push(`Changelog: https://pithy.sh/changelog/${major}.0`);
71
+ } else {
72
+ lines.push(lead);
73
+ lines.push(`Update: ${command}`);
74
+ }
75
+ return lines.join("\n");
76
+ }
77
+
78
+ /** Options for {@link runUpdateNotifier} — every side-effecting dependency is injectable for tests. */
79
+ export interface RunUpdateNotifierOptions {
80
+ /** The currently-running CLI version (from the CLI's own package.json). */
81
+ installedVersion: string;
82
+ /** Injected `fetch`; defaults to the global. */
83
+ fetch?: FetchLike;
84
+ /** The clock; defaults to `Date.now`. */
85
+ now?: () => number;
86
+ /** Override the state file path (defaults to the resolved `~/.config/pithy/state.json`). */
87
+ stateFile?: string;
88
+ /** `process.argv[1]`, for one-time installer detection. */
89
+ argv1?: string;
90
+ /** Environment map; defaults to `process.env`. */
91
+ env?: NodeJS.ProcessEnv;
92
+ /** Whether stderr is a TTY; defaults to `process.stderr.isTTY`. */
93
+ isTTY?: boolean;
94
+ /** stderr writer; defaults to writing to `process.stderr`. */
95
+ stderr?: (text: string) => void;
96
+ /** Scheduler; defaults to `setImmediate` — the notice fires after the command's work. */
97
+ schedule?: (fn: () => void) => void;
98
+ /** The accent seam; defaults to `saffron`. */
99
+ accent?: (text: string) => string;
100
+ }
101
+
102
+ /**
103
+ * The background job: refresh the state within the 24-hour gate, persist it when a check succeeded, then
104
+ * print the notice if every suppression rule holds. Every failure is swallowed by the caller's `.catch`.
105
+ */
106
+ async function notifierJob(options: RunUpdateNotifierOptions): Promise<void> {
107
+ const file = options.stateFile ?? stateFilePath();
108
+ const now = options.now ?? Date.now;
109
+ const env = options.env ?? process.env;
110
+ const isTTY = options.isTTY ?? Boolean(process.stderr.isTTY);
111
+ const write = options.stderr ?? ((text: string) => void process.stderr.write(text));
112
+
113
+ const current = await readState(file);
114
+ const { state, updated } = await refreshCliState(current, {
115
+ fetch: options.fetch,
116
+ now,
117
+ argv1: options.argv1,
118
+ });
119
+ if (updated) await writeState(file, state);
120
+
121
+ if (!state.latestVersion) return;
122
+ const bump = classifyBump(options.installedVersion, state.latestVersion);
123
+ const eligible = shouldNotify({
124
+ isTTY,
125
+ env,
126
+ notifierEnabled: state.notifier,
127
+ bump,
128
+ securityFlagged: state.securityFlagged ?? false,
129
+ });
130
+ if (!eligible) return;
131
+
132
+ const notice = formatUpdateNotice({
133
+ installed: options.installedVersion,
134
+ latest: state.latestVersion,
135
+ installer: state.installer,
136
+ bump,
137
+ accent: options.accent,
138
+ });
139
+ write(`${notice}\n`);
140
+ }
141
+
142
+ /**
143
+ * Fire the update notifier — the entry point `bin.ts` calls after the command's primary work. Returns
144
+ * immediately: the check is scheduled via `setImmediate` (so the command's output, including `Done.`, is
145
+ * already flushed) and runs fire-and-forget, with every error swallowed. It never blocks or delays exit.
146
+ */
147
+ export function runUpdateNotifier(options: RunUpdateNotifierOptions): void {
148
+ const schedule = options.schedule ?? setImmediate;
149
+ schedule(() => {
150
+ void notifierJob(options).catch(() => {});
151
+ });
152
+ }
@@ -0,0 +1,248 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { mkdir, readFile } from "node:fs/promises";
5
+ import { homedir as osHomedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { ConflictError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
8
+ import { assertValidProjectName, kebab } from "@pithy-sh/core/src/naming/resource";
9
+ import { z } from "zod";
10
+ import { writeFileAtomic } from "../project/atomic";
11
+
12
+ /**
13
+ * The update-notifier state file (docs/CLI.md §5.1). One small JSON document under the user's config dir
14
+ * that caches the last registry check so the notifier stays within its 24-hour cadence, remembers the
15
+ * detected installer (so it isn't re-derived every run), and carries the opt-out flag. It is advisory,
16
+ * never load-bearing: a missing or corrupt file resolves to a safe default rather than breaking the CLI.
17
+ */
18
+ export const NotifierState = z
19
+ .object({
20
+ lastCheck: z
21
+ .number()
22
+ .int()
23
+ .nonnegative()
24
+ .describe("Epoch-ms timestamp of the last registry check — the 24-hour cache gate compares against it."),
25
+ latestVersion: z
26
+ .string()
27
+ .nullable()
28
+ .describe("The latest CLI version the last successful check saw, or null before any check succeeded."),
29
+ installer: z
30
+ .enum(["npm", "pnpm", "yarn", "bun", "deno", "brew", "unknown"])
31
+ .describe("The package manager that installed the binary — detected once, cached, drives the upgrade command."),
32
+ notifier: z
33
+ .boolean()
34
+ .describe("Notifier opt-out state: true is enabled, false suppresses the update notice (set via pithy doctor)."),
35
+ securityFlagged: z
36
+ .boolean()
37
+ .optional()
38
+ .describe("Whether the latest release carries a `pithy:security` marker — lets a patch bump notify anyway."),
39
+ })
40
+ .describe(
41
+ "Cached update-notifier state (~/.config/pithy/state.json): last check, latest version, installer, opt-out.",
42
+ );
43
+ export type NotifierState = z.output<typeof NotifierState>;
44
+
45
+ /** The safe default returned when the state file is missing, malformed, or fails validation. Never throws. */
46
+ export function defaultState(): NotifierState {
47
+ return { lastCheck: 0, latestVersion: null, installer: "unknown", notifier: true };
48
+ }
49
+
50
+ /** Injectable environment seams so path resolution is testable without touching the real HOME/APPDATA. */
51
+ export interface StatePathOptions {
52
+ /** The platform, defaulting to `process.platform`. */
53
+ platform?: NodeJS.Platform;
54
+ /** Environment map, defaulting to `process.env` (read for `PITHY_CONFIG_DIR` / `XDG_CONFIG_HOME` / `APPDATA`). */
55
+ env?: NodeJS.ProcessEnv;
56
+ /** The user's home directory, defaulting to `os.homedir()`. */
57
+ homedir?: string;
58
+ }
59
+
60
+ /**
61
+ * The one environment variable that relocates everything Pithy keeps per machine — the notifier state,
62
+ * `<project>/dev.json`, and `<project>/secrets.jsonc`.
63
+ *
64
+ * **Required, not a convenience (#156).** Dev secrets live under this directory now, so without an
65
+ * override every test that scaffolds a project called `replay` writes to the operator's real file, and
66
+ * CI — which has no home directory worth writing to — writes to whatever `$HOME` happens to be. One
67
+ * variable moves the whole tree, which is also what makes per-worktree isolation a later configuration
68
+ * change rather than a redesign.
69
+ */
70
+ const CONFIG_DIR_ENV = "PITHY_CONFIG_DIR";
71
+
72
+ /**
73
+ * The one way a test may resolve the operator's real config directory: say so, in the environment, and
74
+ * mean it. Any non-blank value.
75
+ *
76
+ * It exists so {@link stateDir}'s refusal is a rule rather than a wall — a suite that genuinely needs the
77
+ * real location has an answer, and it is one line a reviewer can see. Nothing in this repository sets it,
78
+ * and `packages/cli/src/ci/testIsolation.test.ts` fails if a vitest config ever does: granting the
79
+ * exemption to one suite is a decision, granting it to a whole package is an accident waiting.
80
+ */
81
+ const ALLOW_REAL_ENV = "PITHY_ALLOW_REAL_CONFIG_DIR";
82
+
83
+ /**
84
+ * Whether this process is a vitest run.
85
+ *
86
+ * Two signals, because either alone has a gap. `VITEST` is set in every worker and is inherited by a CLI
87
+ * this suite spawns — which is the point, since a spawned `pithy` inherits `PITHY_CONFIG_DIR` with it.
88
+ * `__vitest_worker__` is on the runner's `globalThis` and survives a test that hands product code a
89
+ * curated environment, which the variable does not.
90
+ */
91
+ function underVitest(): boolean {
92
+ if ((process.env.VITEST ?? "").length > 0) return true;
93
+ return "__vitest_worker__" in globalThis;
94
+ }
95
+
96
+ /**
97
+ * Refuse to answer with the operator's own machine.
98
+ *
99
+ * Called at each point where the resolution is about to read something the caller did not choose:
100
+ * `process.env` is the operator's shell, `os.homedir()` is their home directory. Outside vitest both are
101
+ * exactly the right answer, so this returns and the resolver carries on.
102
+ */
103
+ function refuseRealDirectory(env: NodeJS.ProcessEnv, detail: string): void {
104
+ if (!underVitest()) return;
105
+ const allowed = env[ALLOW_REAL_ENV];
106
+ if (allowed !== undefined && allowed.trim().length > 0) return;
107
+ throw new ConflictError({
108
+ message: `A test asked for the real Pithy config directory. Set ${CONFIG_DIR_ENV} to a throwaway directory instead.`,
109
+ action: `Pass \`paths\`/\`StatePathOptions\` at the call site, or let the repo-root \`vitest.setup.ts\` set ${CONFIG_DIR_ENV}. A suite that means the operator's own directory sets ${ALLOW_REAL_ENV}=1.`,
110
+ detail: `stateDir() refused under vitest: ${detail}. That directory holds minted dev master keys and account credentials — #200.`,
111
+ });
112
+ }
113
+
114
+ /** The home directory to resolve against. Refuses to hand back the operator's under vitest. */
115
+ function homeDirectory(options: StatePathOptions, env: NodeJS.ProcessEnv): string {
116
+ if (options.homedir !== undefined) return options.homedir;
117
+ refuseRealDirectory(env, "no injected homedir, so the answer would be the operator's home directory");
118
+ return osHomedir();
119
+ }
120
+
121
+ /**
122
+ * The Pithy config directory: `$PITHY_CONFIG_DIR` when set, else `%APPDATA%\pithy` on Windows,
123
+ * `$XDG_CONFIG_HOME/pithy` when that is set, else `~/.config/pithy`. This is the exact directory
124
+ * `pithy doctor` reports (tilde-abbreviated).
125
+ *
126
+ * **The override is the directory itself, with no `pithy` segment appended.** `XDG_CONFIG_HOME` names a
127
+ * config *root* shared with every other program, so Pithy has to claim a subdirectory of it; this names
128
+ * Pithy's own directory, so appending would make `PITHY_CONFIG_DIR=/tmp/x` resolve to `/tmp/x/pithy` and
129
+ * every harness that reads `$PITHY_CONFIG_DIR/<project>/secrets.jsonc` back would find nothing.
130
+ *
131
+ * **Absolute, always.** A relative value is resolved against the cwd once, here — every error raised
132
+ * about a file under this directory names its absolute path, and a relative root would make that path
133
+ * mean a different place in each command that resolves its own working directory. Blank is no override:
134
+ * an unset variable and one exported empty by a shell script are the same intention.
135
+ *
136
+ * **Under vitest it refuses rather than resolve the operator's own machine (#200).** This is the single
137
+ * resolver — dev secrets, dev preferences, `cloudflare.json` and the notifier state all come through
138
+ * here — which is what makes the invariant checkable in one place. A test may answer with
139
+ * `PITHY_CONFIG_DIR`, or with seams that supply whatever the resolution needs; it may not answer with
140
+ * `process.env` or `os.homedir()`, because a test chose neither. A suite that means the real directory
141
+ * sets {@link ALLOW_REAL_ENV}. Outside vitest nothing changes, and a real `pithy` run never sees this.
142
+ */
143
+ export function stateDir(options: StatePathOptions = {}): string {
144
+ const platform = options.platform ?? process.platform;
145
+ const env = options.env ?? process.env;
146
+
147
+ const override = env[CONFIG_DIR_ENV];
148
+ if (override !== undefined && override.trim().length > 0) return resolve(override);
149
+
150
+ // No override, and the environment about to decide the answer is the operator's shell rather than one
151
+ // the caller built. Everything below this line would be their machine.
152
+ if (options.env === undefined) {
153
+ refuseRealDirectory(env, `${CONFIG_DIR_ENV} is unset and no environment was injected`);
154
+ }
155
+
156
+ if (platform === "win32") {
157
+ const appData = env.APPDATA ?? join(homeDirectory(options, env), "AppData", "Roaming");
158
+ return join(appData, "pithy");
159
+ }
160
+ const xdg = env.XDG_CONFIG_HOME;
161
+ return xdg ? join(xdg, "pithy") : join(homeDirectory(options, env), ".config", "pithy");
162
+ }
163
+
164
+ /** The state file path: `<stateDir>/state.json`. */
165
+ export function stateFilePath(options: StatePathOptions = {}): string {
166
+ return join(stateDir(options), "state.json");
167
+ }
168
+
169
+ /**
170
+ * `<config>/<project>/` — **the one door a project name goes through to become a directory**, and the
171
+ * one place the rule that it may is stated (#212).
172
+ *
173
+ * That directory holds every dev secret a project has: `secrets.jsonc`, `dev.json`, `tokens.json`, and
174
+ * since #206 the account-scoped credentials sit beside it. The gates that guard project writes do not
175
+ * reach it — `ensureScaffoldPath` guards writes *inside a project*, and this path is in the config
176
+ * directory, outside every checkout. There is no second line of defense, so the join states its own.
177
+ *
178
+ * **It was safe before this, and that is the point.** Every caller passes a name already through
179
+ * `requireProjectName` or `kebab`. What was missing is where the rule *lived*: at each call site, which
180
+ * is the #183 shape — #171 narrowed a manifest's default values, #174 an option's key and describe, #183
181
+ * the capability's own name, three rounds for one rule that was never stated where it belonged. A caller
182
+ * that is safe because it happens to have normalized earlier is safe by a property of the call graph,
183
+ * and #206 added a caller to this family within a day of the last one.
184
+ *
185
+ * **Two halves, because a name arrives two ways (#206).** {@link assertValidProjectName} is read *after*
186
+ * kebabbing, deliberately: `Acme Corp` is a legal project name because it *becomes* `acme-corp`. So it
187
+ * is a statement about the slug, not about the string in hand — `My/Project` passes it whole, and joined
188
+ * verbatim it is two path segments. The second half is that the value **is** its own normalized form, so
189
+ * the typed name and the slug are held to one rule rather than the rule being true of only one of them.
190
+ */
191
+ export function projectConfigDir(project: string, options: StatePathOptions = {}): string {
192
+ return join(stateDir(options), projectConfigSegment(project));
193
+ }
194
+
195
+ /**
196
+ * The validator every config string passes before it is joined into the config directory.
197
+ *
198
+ * Separate from the join because that is what the gate in `./state.test.ts` can see: the invariant is
199
+ * "no config string is joined into the config directory without passing a validator", stated that way
200
+ * rather than as a list of the joiners known today — enumerating is what produced the second and third
201
+ * instance of every other class of this in the tree.
202
+ *
203
+ * A `ValidationError`, and it names the value: a project name is something a human typed into
204
+ * `pithy.config.ts`, and it is not a secret.
205
+ */
206
+ export function projectConfigSegment(project: string): string {
207
+ assertValidProjectName(project);
208
+ if (project !== kebab(project)) {
209
+ throw new ValidationError({
210
+ message: `"${project}" can't be a directory name.`,
211
+ action: "Use the normalized project name — lowercase letters, digits, and single hyphens.",
212
+ detail: `"${project}" is a valid project name only after kebabbing to "${kebab(project)}", and it is joined into the Pithy config directory verbatim. Pass the name requireProjectName returns.`,
213
+ });
214
+ }
215
+ return project;
216
+ }
217
+
218
+ /**
219
+ * Read and validate the state file. A missing file, malformed JSON, or a payload that fails validation all
220
+ * resolve to {@link defaultState} — a corrupt state file must never break the CLI (docs/CLI.md §5.1). A
221
+ * hand-edited `"notifier": false` is honored, since it round-trips through the same schema.
222
+ */
223
+ export async function readState(file: string): Promise<NotifierState> {
224
+ let raw: string;
225
+ try {
226
+ raw = await readFile(file, "utf8");
227
+ } catch {
228
+ return defaultState();
229
+ }
230
+ try {
231
+ return NotifierState.parse(JSON.parse(raw));
232
+ } catch {
233
+ return defaultState();
234
+ }
235
+ }
236
+
237
+ /** Write the state file atomically, creating its directory on first write. Validates before writing. */
238
+ export async function writeState(file: string, state: NotifierState): Promise<void> {
239
+ const validated = NotifierState.parse(state);
240
+ await mkdir(dirname(file), { recursive: true });
241
+ await writeFileAtomic(file, `${JSON.stringify(validated, null, 2)}\n`);
242
+ }
243
+
244
+ /** Set the notifier opt-out flag in the state file, preserving every other field. Used by `pithy doctor`. */
245
+ export async function setNotifierFlag(file: string, enabled: boolean): Promise<void> {
246
+ const state = await readState(file);
247
+ await writeState(file, { ...state, notifier: enabled });
248
+ }
@@ -0,0 +1,59 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Version comparison for the update notifier — the core semver primitive, deliberately narrowed.
6
+ *
7
+ * `@pithy-sh/core/src/semver/semver` parses and orders full semver, prereleases included. This module
8
+ * keeps only `major.minor.patch`, because the notifier decides between "notify" and "stay quiet", not
9
+ * which prerelease channel somebody is on: nagging a user on the stable channel about an `rc.1` is the
10
+ * defect, and dropping the prerelease field is how it is prevented (docs/CLI.md §5).
11
+ *
12
+ * The narrowing is here rather than in the primitive, so the one place that wants it is the one place
13
+ * that has it.
14
+ */
15
+
16
+ import { compareSemver, parseSemver } from "@pithy-sh/core/src/semver/semver";
17
+
18
+ /** The kind of version gap between the installed CLI and the latest published one. */
19
+ export type Bump = "none" | "patch" | "minor" | "major";
20
+
21
+ /** A parsed semver triple; `null` when the string isn't a recognizable `x.y.z`. */
22
+ interface Parsed {
23
+ major: number;
24
+ minor: number;
25
+ patch: number;
26
+ }
27
+
28
+ /** Parse `x.y.z` (leading `v` and any `-prerelease`/`+build` suffix ignored). `null` when unparseable. */
29
+ export function parseVersion(version: string): Parsed | null {
30
+ const parsed = parseSemver(version);
31
+ if (!parsed) return null;
32
+ // The prerelease is dropped here rather than carried and ignored downstream. A `Parsed` that held it
33
+ // would be a `Parsed` some later caller compared, which is the nagging this module exists to avoid.
34
+ return { major: parsed.major, minor: parsed.minor, patch: parsed.patch };
35
+ }
36
+
37
+ /** `-1 | 0 | 1` for `a` vs `b`. Unparseable versions sort as equal (the safe, quiet default). */
38
+ export function compareVersions(a: string, b: string): -1 | 0 | 1 {
39
+ const pa = parseVersion(a);
40
+ const pb = parseVersion(b);
41
+ if (!pa || !pb) return 0;
42
+ const order = compareSemver({ ...pa, prerelease: null }, { ...pb, prerelease: null });
43
+ return order === 0 ? 0 : order > 0 ? 1 : -1;
44
+ }
45
+
46
+ /**
47
+ * Classify the gap from `installed` to `latest`. `none` when the installed version is at or ahead of the
48
+ * latest (nothing to offer). Otherwise the highest-order component that changed: a new major is `major`,
49
+ * a new minor within the same major is `minor`, anything else is `patch`. An unparseable pair is `none`.
50
+ */
51
+ export function classifyBump(installed: string, latest: string): Bump {
52
+ const from = parseVersion(installed);
53
+ const to = parseVersion(latest);
54
+ if (!from || !to) return "none";
55
+ if (compareVersions(latest, installed) <= 0) return "none";
56
+ if (to.major > from.major) return "major";
57
+ if (to.minor > from.minor) return "minor";
58
+ return "patch";
59
+ }