@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,289 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { stringify } from "comment-json";
|
|
5
|
+
import { type AtomicWriteOptions, writeFileAtomic } from "./atomic";
|
|
6
|
+
import { readFileOutcome } from "./readOptionalFile";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The one place Pithy turns a parsed JSONC document back into bytes.
|
|
10
|
+
*
|
|
11
|
+
* **The invariant: a file Pithy writes is a file the formatter it scaffolds would print unchanged.**
|
|
12
|
+
* The kit scaffolds Biome *and* the pre-commit hook that runs it, so it already knows the rules its own
|
|
13
|
+
* output has to satisfy — and until #249 it did not follow them. `comment-json`'s `stringify` puts every
|
|
14
|
+
* array element on its own line, Biome collapses a short array onto one, and so `pithy ui sync` wrote a
|
|
15
|
+
* `wrangler.jsonc` that failed the commit hook the CLI itself installed. The workaround was to exempt
|
|
16
|
+
* `wrangler.jsonc` and `pithy.worker.jsonc` from the scaffolded formatter, which is not a fix: it says
|
|
17
|
+
* the two files Pithy touches most are the two nothing formats.
|
|
18
|
+
*
|
|
19
|
+
* Two rules, which together are Biome's `expand: "auto"` for JSON:
|
|
20
|
+
*
|
|
21
|
+
* - **An array is one line when it fits, and one element per line when it does not.** No source to
|
|
22
|
+
* consult and no choice to preserve; the width decides it.
|
|
23
|
+
* - **An object keeps the shape it already had.** Biome preserves an object's expansion, so both forms
|
|
24
|
+
* pass — but only one of them leaves the diff alone. A `pithy ui sync` that changed two lines was
|
|
25
|
+
* producing 78 insertions, because re-printing expanded every object in the file, and a real edit
|
|
26
|
+
* invisible inside a reformat is an edit nobody reviewed. The previous bytes are the oracle;
|
|
27
|
+
* an object Pithy is adding has none, and collapses if it fits.
|
|
28
|
+
*
|
|
29
|
+
* A span holding a comment is never collapsed. Joining its lines would put everything after a `//`
|
|
30
|
+
* inside it, and comments are the reason this repo writes JSONC at all.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The width Biome prints JSON at, and the width the starter's `biome.jsonc` declares. */
|
|
34
|
+
export const JSONC_LINE_WIDTH = 120;
|
|
35
|
+
|
|
36
|
+
/** One bracketed span in a source text, and everything the printer has to know about it. */
|
|
37
|
+
interface Span {
|
|
38
|
+
/** `[` or `{`. */
|
|
39
|
+
readonly kind: "array" | "object";
|
|
40
|
+
/** Index of the opening bracket. */
|
|
41
|
+
readonly open: number;
|
|
42
|
+
/** Index of the closing bracket. */
|
|
43
|
+
readonly close: number;
|
|
44
|
+
/** Where the span sits in the document — `/assets/run_worker_first`. The oracle's key. */
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** True when a comment sits anywhere inside, at any depth. Such a span is never collapsed. */
|
|
47
|
+
readonly hasComment: boolean;
|
|
48
|
+
/** True when a newline separated the opening bracket from the first thing inside it. */
|
|
49
|
+
readonly expanded: boolean;
|
|
50
|
+
/** The spans directly inside this one, in source order. */
|
|
51
|
+
readonly children: Span[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A span being scanned, before its closing bracket has been found. */
|
|
55
|
+
interface Frame {
|
|
56
|
+
kind: "array" | "object";
|
|
57
|
+
open: number;
|
|
58
|
+
path: string;
|
|
59
|
+
/** The key the object is currently inside the value of, or `null` before the first `:`. */
|
|
60
|
+
key: string | null;
|
|
61
|
+
/** How many elements of an array have been passed. */
|
|
62
|
+
index: number;
|
|
63
|
+
hasComment: boolean;
|
|
64
|
+
expanded: boolean;
|
|
65
|
+
sawFirstToken: boolean;
|
|
66
|
+
sawNewline: boolean;
|
|
67
|
+
/** True while the next string literal in an object would be a key rather than a value. */
|
|
68
|
+
expectKey: boolean;
|
|
69
|
+
children: Span[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The path of the value a frame is currently reading — its key, or its index inside an array. */
|
|
73
|
+
function childPath(frame: Frame): string {
|
|
74
|
+
return `${frame.path}/${frame.kind === "object" ? (frame.key ?? "") : frame.index}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Where a string literal starting at `start` ends, escapes honored. Returns the index of its closing quote. */
|
|
78
|
+
function endOfString(text: string, start: number): number {
|
|
79
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
80
|
+
if (text[index] === "\\") {
|
|
81
|
+
index += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (text[index] === '"') return index;
|
|
85
|
+
}
|
|
86
|
+
return text.length - 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every bracketed span in `text`, as a forest in source order.
|
|
91
|
+
*
|
|
92
|
+
* One pass, because the printer and the oracle both need the same three facts about a span — where it
|
|
93
|
+
* is, what its path is, and whether anything inside it forbids a collapse — and two scanners would be
|
|
94
|
+
* two chances to disagree about them.
|
|
95
|
+
*/
|
|
96
|
+
function scanSpans(text: string): Span[] {
|
|
97
|
+
const roots: Span[] = [];
|
|
98
|
+
const stack: Frame[] = [];
|
|
99
|
+
let pendingKey: string | null = null;
|
|
100
|
+
|
|
101
|
+
/** Everything up the stack refuses to collapse: a joined line would swallow what follows a `//`. */
|
|
102
|
+
const markComment = (): void => {
|
|
103
|
+
for (const frame of stack) frame.hasComment = true;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** The first thing inside a span settles whether the source had it expanded. */
|
|
107
|
+
const markToken = (): void => {
|
|
108
|
+
const frame = stack.at(-1);
|
|
109
|
+
if (!frame || frame.sawFirstToken) return;
|
|
110
|
+
frame.sawFirstToken = true;
|
|
111
|
+
frame.expanded = frame.sawNewline;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
115
|
+
const char = text[index];
|
|
116
|
+
|
|
117
|
+
if (char === "\n") {
|
|
118
|
+
const frame = stack.at(-1);
|
|
119
|
+
if (frame && !frame.sawFirstToken) frame.sawNewline = true;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (char === " " || char === "\t" || char === "\r") continue;
|
|
123
|
+
|
|
124
|
+
if (char === "/" && text[index + 1] === "/") {
|
|
125
|
+
markComment();
|
|
126
|
+
const end = text.indexOf("\n", index);
|
|
127
|
+
index = end === -1 ? text.length : end - 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (char === "/" && text[index + 1] === "*") {
|
|
131
|
+
markComment();
|
|
132
|
+
const end = text.indexOf("*/", index + 2);
|
|
133
|
+
index = end === -1 ? text.length : end + 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (char === '"') {
|
|
138
|
+
markToken();
|
|
139
|
+
const end = endOfString(text, index);
|
|
140
|
+
const frame = stack.at(-1);
|
|
141
|
+
if (frame?.kind === "object" && frame.expectKey) pendingKey = text.slice(index + 1, end);
|
|
142
|
+
index = end;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (char === ":") {
|
|
147
|
+
const frame = stack.at(-1);
|
|
148
|
+
if (frame?.kind === "object") {
|
|
149
|
+
frame.key = pendingKey;
|
|
150
|
+
frame.expectKey = false;
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (char === ",") {
|
|
156
|
+
const frame = stack.at(-1);
|
|
157
|
+
if (frame?.kind === "object") frame.expectKey = true;
|
|
158
|
+
if (frame?.kind === "array") frame.index += 1;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (char === "{" || char === "[") {
|
|
163
|
+
markToken();
|
|
164
|
+
const parent = stack.at(-1);
|
|
165
|
+
stack.push({
|
|
166
|
+
kind: char === "{" ? "object" : "array",
|
|
167
|
+
open: index,
|
|
168
|
+
path: parent ? childPath(parent) : "",
|
|
169
|
+
key: null,
|
|
170
|
+
index: 0,
|
|
171
|
+
hasComment: false,
|
|
172
|
+
expanded: false,
|
|
173
|
+
sawFirstToken: false,
|
|
174
|
+
sawNewline: false,
|
|
175
|
+
expectKey: char === "{",
|
|
176
|
+
children: [],
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (char === "}" || char === "]") {
|
|
182
|
+
const frame = stack.pop();
|
|
183
|
+
if (!frame) continue;
|
|
184
|
+
const span: Span = {
|
|
185
|
+
kind: frame.kind,
|
|
186
|
+
open: frame.open,
|
|
187
|
+
close: index,
|
|
188
|
+
path: frame.path,
|
|
189
|
+
hasComment: frame.hasComment,
|
|
190
|
+
expanded: frame.expanded,
|
|
191
|
+
children: frame.children,
|
|
192
|
+
};
|
|
193
|
+
const parent = stack.at(-1);
|
|
194
|
+
if (parent) parent.children.push(span);
|
|
195
|
+
else roots.push(span);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
markToken();
|
|
200
|
+
}
|
|
201
|
+
return roots;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Which objects the previous bytes had expanded, by path.
|
|
206
|
+
*
|
|
207
|
+
* Objects only. An array's shape is decided by the width every time, so remembering one would only let a
|
|
208
|
+
* stale answer overrule the rule.
|
|
209
|
+
*/
|
|
210
|
+
function objectWrapping(previous: string): Map<string, boolean> {
|
|
211
|
+
const wrapping = new Map<string, boolean>();
|
|
212
|
+
const visit = (span: Span): void => {
|
|
213
|
+
if (span.kind === "object") wrapping.set(span.path, span.expanded);
|
|
214
|
+
for (const child of span.children) visit(child);
|
|
215
|
+
};
|
|
216
|
+
for (const root of scanSpans(previous)) visit(root);
|
|
217
|
+
return wrapping;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The span's text with every child already decided, then this span's own decision applied. */
|
|
221
|
+
function printSpan(text: string, span: Span, wrapping: Map<string, boolean>): string {
|
|
222
|
+
let printed = "";
|
|
223
|
+
let cursor = span.open;
|
|
224
|
+
for (const child of span.children) {
|
|
225
|
+
printed += text.slice(cursor, child.open) + printSpan(text, child, wrapping);
|
|
226
|
+
cursor = child.close + 1;
|
|
227
|
+
}
|
|
228
|
+
printed += text.slice(cursor, span.close + 1);
|
|
229
|
+
|
|
230
|
+
if (span.hasComment) return printed;
|
|
231
|
+
const inner = printed.slice(1, -1).trim();
|
|
232
|
+
if (inner.length === 0) return span.kind === "array" ? "[]" : "{}";
|
|
233
|
+
// The document's top level stays expanded whatever its width: no config file is one line, and an
|
|
234
|
+
// absent oracle is the ordinary case for a file Pithy is creating.
|
|
235
|
+
if (span.kind === "object" && (wrapping.get(span.path) ?? span.path.length === 0)) return printed;
|
|
236
|
+
|
|
237
|
+
const joined = printed
|
|
238
|
+
.slice(1, -1)
|
|
239
|
+
.split("\n")
|
|
240
|
+
.map((line) => line.trim())
|
|
241
|
+
.filter((line) => line.length > 0)
|
|
242
|
+
.join(" ");
|
|
243
|
+
const collapsed = span.kind === "array" ? `[${joined}]` : `{ ${joined} }`;
|
|
244
|
+
|
|
245
|
+
// What the line would measure once collapsed. Nothing but a comma and a comment ever follows a value
|
|
246
|
+
// on its own line, and neither moves, so the prefix and the suffix are read from the expanded text.
|
|
247
|
+
const lineStart = text.lastIndexOf("\n", span.open) + 1;
|
|
248
|
+
const lineEnd = text.indexOf("\n", span.close);
|
|
249
|
+
const prefix = text.slice(lineStart, span.open);
|
|
250
|
+
const suffix = text.slice(span.close + 1, lineEnd === -1 ? text.length : lineEnd);
|
|
251
|
+
if (prefix.length + collapsed.length + suffix.length > JSONC_LINE_WIDTH) return printed;
|
|
252
|
+
return collapsed;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* `value` as the bytes of a JSONC file — comments preserved, formatted the way the project's Biome
|
|
257
|
+
* would print them, and shaped like `previous` wherever `previous` had an opinion.
|
|
258
|
+
*
|
|
259
|
+
* Pass the file's current contents as `previous` whenever there are any. Without them every object
|
|
260
|
+
* collapses that fits, which is right for a file being created and wrong for one being edited: it would
|
|
261
|
+
* rewrap an adopter's whole file around a two-line change.
|
|
262
|
+
*/
|
|
263
|
+
export function formatJsonc(value: unknown, previous?: string | null): string {
|
|
264
|
+
const expanded = stringify(value, null, 2) ?? "";
|
|
265
|
+
const wrapping = previous ? objectWrapping(previous) : new Map<string, boolean>();
|
|
266
|
+
const roots = scanSpans(expanded);
|
|
267
|
+
let printed = "";
|
|
268
|
+
let cursor = 0;
|
|
269
|
+
for (const root of roots) {
|
|
270
|
+
printed += expanded.slice(cursor, root.open) + printSpan(expanded, root, wrapping);
|
|
271
|
+
cursor = root.close + 1;
|
|
272
|
+
}
|
|
273
|
+
printed += expanded.slice(cursor);
|
|
274
|
+
return `${printed}\n`;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Write `value` to `path` as JSONC, atomically, shaped like whatever is already there.
|
|
279
|
+
*
|
|
280
|
+
* The previous bytes are read here rather than passed in, so no caller can forget them. Most callers
|
|
281
|
+
* have already read the file, so this is a second read of something small — the price of an argument
|
|
282
|
+
* nobody has to remember, whose absence would show up only as a reformatted diff. A file that will not
|
|
283
|
+
* open is not a refusal: this is about formatting, and the write itself is about to answer for the path.
|
|
284
|
+
*/
|
|
285
|
+
export async function writeJsonc(path: string, value: unknown, options?: AtomicWriteOptions): Promise<void> {
|
|
286
|
+
const previous = await readFileOutcome(path);
|
|
287
|
+
const bytes = formatJsonc(value, previous.state === "read" ? previous.text : null);
|
|
288
|
+
await writeFileAtomic(path, bytes, options);
|
|
289
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { access, readFile, realpath } from "node:fs/promises";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { promisify } from "node:util";
|
|
8
|
+
import { InternalError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile);
|
|
11
|
+
|
|
12
|
+
/** The package managers a Pithy adopter might use. Adoption is never gated behind Bun. */
|
|
13
|
+
export type PackageManager = "npm" | "pnpm" | "yarn" | "bun";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lockfile → package manager, most specific first. `bun.lock` precedes
|
|
17
|
+
* `package-lock.json` so a project carrying both (a Bun project npm once touched)
|
|
18
|
+
* resolves to Bun. The fallback is npm — present on every Node install.
|
|
19
|
+
*/
|
|
20
|
+
const LOCKFILES: readonly [string, PackageManager][] = [
|
|
21
|
+
["bun.lock", "bun"],
|
|
22
|
+
["bun.lockb", "bun"],
|
|
23
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
24
|
+
["yarn.lock", "yarn"],
|
|
25
|
+
["package-lock.json", "npm"],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
async function exists(path: string): Promise<boolean> {
|
|
29
|
+
try {
|
|
30
|
+
await access(path);
|
|
31
|
+
return true;
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Detect the project's package manager from its lockfile; npm when none is found. */
|
|
38
|
+
export async function detectPackageManager(projectDir: string): Promise<PackageManager> {
|
|
39
|
+
for (const [lockfile, pm] of LOCKFILES) {
|
|
40
|
+
if (await exists(join(projectDir, lockfile))) return pm;
|
|
41
|
+
}
|
|
42
|
+
return "npm";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The install argv for a package manager: npm `install`, the rest `add`, for one or many packages. */
|
|
46
|
+
export function installArgs(pm: PackageManager, pkgs: string | string[]): string[] {
|
|
47
|
+
const list = Array.isArray(pkgs) ? pkgs : [pkgs];
|
|
48
|
+
return [pm === "npm" ? "install" : "add", ...list];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The uninstall argv for a package manager: npm `uninstall`, the rest `remove`. */
|
|
52
|
+
export function uninstallArgs(pm: PackageManager, pkg: string): string[] {
|
|
53
|
+
return [pm === "npm" ? "uninstall" : "remove", pkg];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a package binary through the project's package manager, so `pithy` never assumes a global
|
|
58
|
+
* install. Each manager has its own "run a workspace-local bin" invocation:
|
|
59
|
+
* `bun x <bin>`, `pnpm exec <bin>`, `yarn <bin>`, `npx <bin>`. The returned `{ command, args }` is
|
|
60
|
+
* ready to hand to `child_process.spawn`.
|
|
61
|
+
*/
|
|
62
|
+
export function execArgs(pm: PackageManager, bin: string, args: string[]): { command: string; args: string[] } {
|
|
63
|
+
switch (pm) {
|
|
64
|
+
case "bun":
|
|
65
|
+
return { command: "bun", args: ["x", bin, ...args] };
|
|
66
|
+
case "pnpm":
|
|
67
|
+
return { command: "pnpm", args: ["exec", bin, ...args] };
|
|
68
|
+
case "yarn":
|
|
69
|
+
return { command: "yarn", args: [bin, ...args] };
|
|
70
|
+
default:
|
|
71
|
+
return { command: "npx", args: [bin, ...args] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The scope every package this kit ships lives under. */
|
|
76
|
+
const PITHY_SCOPE = "@pithy-sh/";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Whether `projectDir/node_modules/<pkg>` is a **checkout linked in** rather than something a registry
|
|
80
|
+
* install put there.
|
|
81
|
+
*
|
|
82
|
+
* Two conditions, and both are load-bearing.
|
|
83
|
+
*
|
|
84
|
+
* *At the project root, and only there.* Node's own lookup walks `node_modules` up every ancestor to
|
|
85
|
+
* `/`; this deliberately does not. The rest of the CLI never walks — `loadManifest` reads
|
|
86
|
+
* `projectDir/node_modules/@pithy-sh/<pkg>` and nothing above it — so an ancestor hit made `pithy add`
|
|
87
|
+
* skip the install and then report the capability as not installed, telling the adopter to run the
|
|
88
|
+
* command that had just declined to run. And walking is a way in: `/tmp` and `$HOME` are ancestors
|
|
89
|
+
* anyone can leave a `node_modules` in, `pithy add` wires the resolved package into `pithy.config.ts`,
|
|
90
|
+
* and `pithy migrate` imports that config. Bounded, a planted copy is shadowed by a real install again.
|
|
91
|
+
*
|
|
92
|
+
* *Resolving out of `node_modules`.* A registry install is a real directory under `node_modules` — bun
|
|
93
|
+
* hoists transitive dependencies there too, and the scope depends on itself (`auth` pulls `email`,
|
|
94
|
+
* `secrets`, `turnstile`). Treating a hoisted copy as provided meant `pithy add email` after
|
|
95
|
+
* `pithy add auth` declared nothing, and `pithy remove auth` then pruned it out from under a config that
|
|
96
|
+
* still imported it. A link that lands *outside* `node_modules` is the thing that has no version to
|
|
97
|
+
* write: a workspace member, a `bun link`, a `file:` dependency. pnpm's store is inside `node_modules`,
|
|
98
|
+
* so its symlinks read as the registry installs they are.
|
|
99
|
+
*
|
|
100
|
+
* The manifest is read rather than the directory stat'ed, because a stat answers the wrong question. The
|
|
101
|
+
* checkout a link points at can be gone, and an `@pithy-sh` folder can be left behind empty by a
|
|
102
|
+
* half-finished remove. Reading `package.json` and checking the name it declares is what "importable"
|
|
103
|
+
* actually means.
|
|
104
|
+
*/
|
|
105
|
+
async function linkedFromCheckout(projectDir: string, pkg: string): Promise<boolean> {
|
|
106
|
+
const modules = join(projectDir, "node_modules");
|
|
107
|
+
const at = join(modules, ...pkg.split("/"));
|
|
108
|
+
let resolved: string;
|
|
109
|
+
let root: string;
|
|
110
|
+
try {
|
|
111
|
+
const raw = await readFile(join(at, "package.json"), "utf8");
|
|
112
|
+
if ((JSON.parse(raw) as { name?: unknown }).name !== pkg) return false;
|
|
113
|
+
// Both sides through `realpath`, or a `node_modules` reached by a symlinked path — a macOS tmpdir,
|
|
114
|
+
// a symlinked home — would read as outside itself and call every install a checkout.
|
|
115
|
+
[resolved, root] = await Promise.all([realpath(at), realpath(modules)]);
|
|
116
|
+
} catch {
|
|
117
|
+
// Missing, dangling, unreadable, or unparsable. None of them is a resolution.
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return relative(root, resolved).startsWith("..");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a `@pithy-sh/*` package is already provided by the project, so nothing should reach for the
|
|
125
|
+
* registry on its behalf — neither an install nor a version range written into a `package.json`.
|
|
126
|
+
*
|
|
127
|
+
* Nothing under `@pithy-sh/*` is published yet, so a project working against a local checkout links the
|
|
128
|
+
* packages in and every registry reference 404s: `pithy add secrets` failed on `bun add`, and
|
|
129
|
+
* `pithy ui add react` succeeded while writing a `"@pithy-sh/vite": "^0.0.0"` that broke the *next*
|
|
130
|
+
* install. One predicate, both call sites, no flag to remember. Once the packages publish it goes quiet:
|
|
131
|
+
* an installed copy is a real directory with a real version, so the normal path runs and declares it.
|
|
132
|
+
*
|
|
133
|
+
* The scope is part of the rule, not incidental. `react` linked into the project says only that someone
|
|
134
|
+
* is developing against a fork — its range is still correct and must still be written.
|
|
135
|
+
*/
|
|
136
|
+
export async function alreadyProvided(projectDir: string, pkg: string): Promise<boolean> {
|
|
137
|
+
return pkg.startsWith(PITHY_SCOPE) && (await linkedFromCheckout(projectDir, pkg));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Spawn a package manager. Injectable so the flow is testable without a real install. */
|
|
141
|
+
export type InstallRunner = (command: string, args: string[], cwd: string) => Promise<void>;
|
|
142
|
+
|
|
143
|
+
const spawnInstall: InstallRunner = async (command, args, cwd) => {
|
|
144
|
+
try {
|
|
145
|
+
await run(command, args, { cwd });
|
|
146
|
+
} catch (cause) {
|
|
147
|
+
throw new InternalError({
|
|
148
|
+
message: `${command} ${args.join(" ")} failed.`,
|
|
149
|
+
action: `Install the package by hand: ${command} ${args.join(" ")}.`,
|
|
150
|
+
detail: cause instanceof Error ? cause.message : String(cause),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export interface InstallPackageOptions {
|
|
156
|
+
/** The project root — where the lockfile lives and the install runs. */
|
|
157
|
+
projectDir: string;
|
|
158
|
+
/** The package to install, e.g. `@pithy-sh/auth`. */
|
|
159
|
+
pkg: string;
|
|
160
|
+
/** Override the spawner (tests inject a stub); defaults to the real install. */
|
|
161
|
+
run?: InstallRunner;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Install a package with the project's detected package manager. Returns the project's manager, so
|
|
166
|
+
* `pithy add` can report it. The adopter's PM is always used — Bun stays a dev-only signal, never a
|
|
167
|
+
* requirement of adoption. The manager is detected whether or not an install runs: it is a fact about
|
|
168
|
+
* the project, not a record of a spawn, and the caller only ever renders it as "run `<pm> install`".
|
|
169
|
+
*
|
|
170
|
+
* A package a linked checkout already provides is skipped ({@link alreadyProvided}), and the trade is
|
|
171
|
+
* deliberate: nothing then declares it in a `package.json`. That is the point. The only range there is
|
|
172
|
+
* to write names a version no registry has, and writing it breaks the adopter's next install — the
|
|
173
|
+
* failure `pithy ui add` used to plant on a command days away. A checkout is already outside the
|
|
174
|
+
* lockfile's world; the moment the scope publishes, the copy is a real directory and this stops firing.
|
|
175
|
+
*/
|
|
176
|
+
export async function installPackage(options: InstallPackageOptions): Promise<{ packageManager: PackageManager }> {
|
|
177
|
+
const packageManager = await detectPackageManager(options.projectDir);
|
|
178
|
+
if (await alreadyProvided(options.projectDir, options.pkg)) return { packageManager };
|
|
179
|
+
await (options.run ?? spawnInstall)(packageManager, installArgs(packageManager, options.pkg), options.projectDir);
|
|
180
|
+
return { packageManager };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface UninstallPackageOptions {
|
|
184
|
+
/** The project root — where the lockfile lives and the uninstall runs. */
|
|
185
|
+
projectDir: string;
|
|
186
|
+
/** The package to uninstall, e.g. `@pithy-sh/auth`. */
|
|
187
|
+
pkg: string;
|
|
188
|
+
/** Override the spawner (tests inject a stub); defaults to the real uninstall. */
|
|
189
|
+
run?: InstallRunner;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** What an uninstall did: the manager it would use, and whether it actually removed anything. */
|
|
193
|
+
export interface UninstallOutcome {
|
|
194
|
+
/** The project's package manager — reported whether or not an uninstall ran, like {@link installPackage}. */
|
|
195
|
+
packageManager: PackageManager;
|
|
196
|
+
/** False when the package was left alone because a linked checkout provides it. */
|
|
197
|
+
uninstalled: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Uninstall a package with the project's detected package manager — the inverse of {@link installPackage},
|
|
202
|
+
* behind `pithy remove`. Returns which manager ran, and whether anything was removed. The adopter's PM is
|
|
203
|
+
* always used.
|
|
204
|
+
*
|
|
205
|
+
* A package a linked checkout provides is **not** uninstalled ({@link alreadyProvided}), exactly as the
|
|
206
|
+
* install skips it, and the asymmetry was the bug: nothing declared the package, so there is no
|
|
207
|
+
* declaration for a remove to take out — but every manager is asked anyway, and npm answers by pruning
|
|
208
|
+
* the *whole* linked scope as extraneous. One `pithy remove auth` unlinked `secrets` and `core` out from
|
|
209
|
+
* under the same worker's config, which still imports them. `detectPackageManager` returns npm for a
|
|
210
|
+
* project with no lockfile, so that is the default path here, not an edge.
|
|
211
|
+
*
|
|
212
|
+
* The alternative was to make the caller check first. It is the same predicate the install already owns,
|
|
213
|
+
* and a guard every call site must remember is a guard one of them forgets.
|
|
214
|
+
*/
|
|
215
|
+
export async function uninstallPackage(options: UninstallPackageOptions): Promise<UninstallOutcome> {
|
|
216
|
+
const packageManager = await detectPackageManager(options.projectDir);
|
|
217
|
+
if (await alreadyProvided(options.projectDir, options.pkg)) return { packageManager, uninstalled: false };
|
|
218
|
+
await (options.run ?? spawnInstall)(packageManager, uninstallArgs(packageManager, options.pkg), options.projectDir);
|
|
219
|
+
return { packageManager, uninstalled: true };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Add several packages at once with the project's detected package manager — one install invocation.
|
|
224
|
+
* Used by `--eject` to promote a forked capability's runtime dependencies (`better-auth`, `zod`, …)
|
|
225
|
+
* into the project so the local copy builds without the `@pithy-sh/*` package. A no-op when the list
|
|
226
|
+
* is empty. Returns which manager ran.
|
|
227
|
+
*/
|
|
228
|
+
export async function promoteDependencies(
|
|
229
|
+
projectDir: string,
|
|
230
|
+
packages: string[],
|
|
231
|
+
runner?: InstallRunner,
|
|
232
|
+
): Promise<{ packageManager: PackageManager }> {
|
|
233
|
+
const packageManager = await detectPackageManager(projectDir);
|
|
234
|
+
if (packages.length > 0) {
|
|
235
|
+
await (runner ?? spawnInstall)(packageManager, installArgs(packageManager, packages), projectDir);
|
|
236
|
+
}
|
|
237
|
+
return { packageManager };
|
|
238
|
+
}
|