@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,333 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { delimiter, join } from "node:path";
|
|
7
|
+
import { NotFoundError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one place Pithy decides what "open this in your editor" means (#157).
|
|
11
|
+
*
|
|
12
|
+
* Three rules live here, and none of them looks like a rule at a call site — which is exactly why they
|
|
13
|
+
* belong at the thing being called rather than beside each caller:
|
|
14
|
+
*
|
|
15
|
+
* **The order is `$VISUAL`, then `$EDITOR`, then a platform default.** Both variables, in that order,
|
|
16
|
+
* because that is the convention every other tool an adopter uses already follows: `$EDITOR` is the
|
|
17
|
+
* line-editor fallback and `$VISUAL` is the full-screen one, so a developer whose `$EDITOR` is `ed` and
|
|
18
|
+
* whose `$VISUAL` is `nvim` means the second. The platform default is `notepad` on Windows and `nano`
|
|
19
|
+
* (else `vi`) elsewhere. Windows is half the reason this module exists: #131 was the same area, and the
|
|
20
|
+
* branch nobody has a host for is the branch nobody writes.
|
|
21
|
+
*
|
|
22
|
+
* **An editor that returns before the adopter has finished typing is refused, by name, with the flag to
|
|
23
|
+
* add.** `EDITOR=code` exits the moment the window opens. A caller that spawns it and waits gets
|
|
24
|
+
* control back in about forty milliseconds, validates a file nobody has touched yet, reports success,
|
|
25
|
+
* and — for a command that writes what came back — writes the unedited file over the edited one while
|
|
26
|
+
* the adopter is still typing into the window. There is no way to detect this after the fact: a
|
|
27
|
+
* fast-exiting editor and a fast adopter are indistinguishable. So it is caught before the spawn.
|
|
28
|
+
*
|
|
29
|
+
* The list below is an enumeration, and enumerations are the weaker kind of gate — but it is an
|
|
30
|
+
* enumeration of *fixes*, not of prohibitions. Each entry exists to name the flag, which is the only
|
|
31
|
+
* thing the adopter actually needs; an editor absent from it is not asserted to be safe, it is simply
|
|
32
|
+
* one Pithy has nothing helpful to say about. The invariant the gate states is in its name:
|
|
33
|
+
* {@link requireEditor} returns an editor that **blocks**.
|
|
34
|
+
*
|
|
35
|
+
* **A refusal, never a hang.** With no terminal there is no adopter to close the window, so a spawned
|
|
36
|
+
* editor in CI waits forever and takes the job's timeout with it. The refusal names the file's absolute
|
|
37
|
+
* path, in `message`: `detail` is stripped by the HTTP codec and never rendered to a terminal, and the
|
|
38
|
+
* path is the whole of what the operator can act on.
|
|
39
|
+
*
|
|
40
|
+
* A second command that opens an editor routes through here. `platform/editor.test.ts` has the gate
|
|
41
|
+
* that keeps that true — `$VISUAL` and `$EDITOR` are read in this file and nowhere else in the tree.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** Which environment variable named the editor, or that neither did. Named in every refusal. */
|
|
45
|
+
export type EditorSource = "VISUAL" | "EDITOR" | "default";
|
|
46
|
+
|
|
47
|
+
/** A resolved editor: what to spawn, what to pass it, and where it came from. */
|
|
48
|
+
export interface ResolvedEditor {
|
|
49
|
+
/** The executable, with no arguments and no quotes — as spawned. */
|
|
50
|
+
readonly command: string;
|
|
51
|
+
/** The arguments that came with it, in order. The file is appended after these. */
|
|
52
|
+
readonly args: readonly string[];
|
|
53
|
+
/** Which of the two variables named it, or `default` when neither did. */
|
|
54
|
+
readonly source: EditorSource;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Injectable seams, so every branch is testable from any host. */
|
|
58
|
+
export interface EditorEnvironment {
|
|
59
|
+
/** Environment map, defaulting to `process.env` — read for `VISUAL` and `EDITOR`. */
|
|
60
|
+
env?: NodeJS.ProcessEnv;
|
|
61
|
+
/** The platform, defaulting to `process.platform`. */
|
|
62
|
+
platform?: NodeJS.Platform;
|
|
63
|
+
/** Whether a command is on `PATH`, defaulting to a `PATH` walk. Only the POSIX default consults it. */
|
|
64
|
+
hasCommand?: (command: string) => boolean;
|
|
65
|
+
/** Whether a human is at the terminal, defaulting to stdin **and** stdout both being TTYs. */
|
|
66
|
+
interactive?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Editors that open a window and return immediately, mapped to the flags that make them wait.
|
|
71
|
+
*
|
|
72
|
+
* The **first** flag is the one the refusal recommends; the rest are accepted spellings of the same
|
|
73
|
+
* thing. Keys are matched against the command's base name, lowercased and with a Windows extension
|
|
74
|
+
* removed, so `/usr/local/bin/code`, `Code.EXE` and `code` are one editor.
|
|
75
|
+
*/
|
|
76
|
+
const WAIT_FLAGS: Record<string, readonly string[]> = {
|
|
77
|
+
atom: ["--wait", "-w"],
|
|
78
|
+
code: ["--wait", "-w"],
|
|
79
|
+
"code-insiders": ["--wait", "-w"],
|
|
80
|
+
codium: ["--wait", "-w"],
|
|
81
|
+
cursor: ["--wait", "-w"],
|
|
82
|
+
fleet: ["--wait", "-w"],
|
|
83
|
+
goland: ["--wait", "-w"],
|
|
84
|
+
gvim: ["-f", "--nofork"],
|
|
85
|
+
idea: ["--wait", "-w"],
|
|
86
|
+
mate: ["-w", "--wait"],
|
|
87
|
+
mvim: ["-f", "--nofork"],
|
|
88
|
+
notepad__: ["-multiInst", "-nosession"],
|
|
89
|
+
open: ["-W", "--wait-apps"],
|
|
90
|
+
phpstorm: ["--wait", "-w"],
|
|
91
|
+
pycharm: ["--wait", "-w"],
|
|
92
|
+
rider: ["--wait", "-w"],
|
|
93
|
+
rubymine: ["--wait", "-w"],
|
|
94
|
+
subl: ["--wait", "-w"],
|
|
95
|
+
sublime_text: ["--wait", "-w"],
|
|
96
|
+
vscodium: ["--wait", "-w"],
|
|
97
|
+
webstorm: ["--wait", "-w"],
|
|
98
|
+
windsurf: ["--wait", "-w"],
|
|
99
|
+
zed: ["--wait", "-w"],
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Notepad++'s executable is `notepad++`, and `+` is not a key anyone will read correctly beside the
|
|
104
|
+
* rest. It is normalized to the key above rather than spelled inline twice.
|
|
105
|
+
*/
|
|
106
|
+
const NOTEPAD_PLUS_PLUS = "notepad++";
|
|
107
|
+
|
|
108
|
+
/** Windows extensions a command may carry. Stripped before the lookup: `code.cmd` is `code`. */
|
|
109
|
+
const WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the editor, refusing one that does not wait.
|
|
113
|
+
*
|
|
114
|
+
* The refusal is here rather than in the caller for the reason the whole module exists: an editor that
|
|
115
|
+
* returns immediately is not a resolution failure the caller can recover from, and a caller that gets a
|
|
116
|
+
* `ResolvedEditor` back must be able to trust that waiting on it means something.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveEditor(options: EditorEnvironment = {}): ResolvedEditor {
|
|
119
|
+
const env = options.env ?? process.env;
|
|
120
|
+
const platform = options.platform ?? process.platform;
|
|
121
|
+
|
|
122
|
+
for (const source of ["VISUAL", "EDITOR"] as const) {
|
|
123
|
+
// A variable set to nothing is a variable that names no editor. `export EDITOR=` is an ordinary
|
|
124
|
+
// line in a shell profile, and spawning the empty string is an ENOENT with nothing in it to read.
|
|
125
|
+
const value = env[source]?.trim() ?? "";
|
|
126
|
+
if (value.length === 0) continue;
|
|
127
|
+
const [command = "", ...args] = splitCommand(value);
|
|
128
|
+
return requireWaiting({ command, args, source });
|
|
129
|
+
}
|
|
130
|
+
return { command: defaultEditor(platform, options.hasCommand ?? onPath), args: [], source: "default" };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Resolve the editor for `file`, refusing first when there is no terminal to open it in.
|
|
135
|
+
*
|
|
136
|
+
* **Order matters, and it is the non-obvious way round.** A CI job with `EDITOR=code` set has two
|
|
137
|
+
* things wrong with it, and only one of them is the thing in its way: told to add `--wait`, it adds
|
|
138
|
+
* `--wait`, and the next run hangs on a window nothing will ever close. The missing terminal is
|
|
139
|
+
* answered first for the same reason every diagnostic names the cause and not the symptom.
|
|
140
|
+
*
|
|
141
|
+
* `file` is named in that refusal, absolute, so the answer to "then how do I edit it" is in the error
|
|
142
|
+
* rather than in a second command.
|
|
143
|
+
*/
|
|
144
|
+
export function requireEditor(file: string, options: EditorEnvironment = {}): ResolvedEditor {
|
|
145
|
+
const interactive = options.interactive ?? (Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY));
|
|
146
|
+
if (!interactive) {
|
|
147
|
+
throw new ValidationError({
|
|
148
|
+
message: `No terminal here, so there is no editor to open ${file} in.`,
|
|
149
|
+
action: "Run this at a terminal, or edit that file directly.",
|
|
150
|
+
detail: `editor requested for '${file}' with no TTY on stdin and stdout`,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return resolveEditor(options);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A child process, as far as this module is concerned: it closes, or it fails to start. */
|
|
157
|
+
export interface EditorProcess {
|
|
158
|
+
on(event: "close", listener: (code: number | null) => void): void;
|
|
159
|
+
on(event: "error", listener: (error: Error) => void): void;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** How the editor is spawned. A seam, so the Windows branch is testable from a POSIX host. */
|
|
163
|
+
export type SpawnEditor = (
|
|
164
|
+
command: string,
|
|
165
|
+
args: readonly string[],
|
|
166
|
+
options: { stdio: "inherit"; shell: boolean },
|
|
167
|
+
) => EditorProcess;
|
|
168
|
+
|
|
169
|
+
/** What {@link runEditor} needs beyond the editor itself. */
|
|
170
|
+
export interface RunEditorOptions {
|
|
171
|
+
/** The platform, defaulting to `process.platform`. Decides whether a shell is involved. */
|
|
172
|
+
platform?: NodeJS.Platform;
|
|
173
|
+
/** How to spawn, defaulting to `node:child_process`'s `spawn`. */
|
|
174
|
+
spawn?: SpawnEditor;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Run a resolved editor on `file` and wait for it to close. Resolves with the exit status.
|
|
179
|
+
*
|
|
180
|
+
* `stdio: "inherit"`, always: a terminal editor **is** the terminal for as long as it runs, and a piped
|
|
181
|
+
* stdin is a `vi` that cannot be typed into. There is nothing to capture — the output is the adopter's
|
|
182
|
+
* screen — and capturing it would be capturing a file full of secrets as it is drawn.
|
|
183
|
+
*
|
|
184
|
+
* **A signal is not a clean exit.** `close` carries a null code when the editor was killed, and reading
|
|
185
|
+
* that as zero would take a `kill -9` mid-edit for a finished one and write whatever the half-saved
|
|
186
|
+
* file happened to hold.
|
|
187
|
+
*
|
|
188
|
+
* **Windows goes through a shell, and that is not laziness.** Every GUI editor there is a `.cmd` shim —
|
|
189
|
+
* `code.cmd`, `subl.cmd` — and Node refuses to spawn one directly (it has since the argument-injection
|
|
190
|
+
* fix). With `shell: true` the whole command line becomes one string that `cmd.exe /d /s /c` re-splits,
|
|
191
|
+
* so anything with a space in it has to carry its own quotes: the file lives under
|
|
192
|
+
* `%APPDATA%\pithy\<project>\`, and a Windows home directory with a space in it is the common case, not
|
|
193
|
+
* the exotic one. `/s` strips exactly the outermost pair, so the inner quotes survive to cmd's own
|
|
194
|
+
* splitting. A `"` cannot appear in a Windows path at all, so there is nothing left to escape.
|
|
195
|
+
*/
|
|
196
|
+
export async function runEditor(editor: ResolvedEditor, file: string, options: RunEditorOptions = {}): Promise<number> {
|
|
197
|
+
const platform = options.platform ?? process.platform;
|
|
198
|
+
const spawnEditor = options.spawn ?? defaultSpawn;
|
|
199
|
+
const windows = platform === "win32";
|
|
200
|
+
const command = windows ? quoteForCmd(editor.command) : editor.command;
|
|
201
|
+
const args = windows ? [...editor.args, quoteForCmd(file)] : [...editor.args, file];
|
|
202
|
+
|
|
203
|
+
return new Promise<number>((resolve, reject) => {
|
|
204
|
+
const child = spawnEditor(command, args, { stdio: "inherit", shell: windows });
|
|
205
|
+
child.on("error", (cause: Error) => {
|
|
206
|
+
reject(
|
|
207
|
+
new NotFoundError(
|
|
208
|
+
{
|
|
209
|
+
message: `Could not run your editor, ${editor.command}.`,
|
|
210
|
+
action:
|
|
211
|
+
editor.source === "default"
|
|
212
|
+
? "Set EDITOR to an editor that is installed."
|
|
213
|
+
: `Check that ${editor.source} names something on your PATH.`,
|
|
214
|
+
detail: `spawning editor '${editor.command}' from ${editor.source} failed`,
|
|
215
|
+
},
|
|
216
|
+
{ cause },
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
// A signal leaves `code` null. Anything but a clean zero is the caller's to treat as an abandon.
|
|
221
|
+
child.on("close", (code: number | null) => resolve(code ?? 1));
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolve an editor and run it on `file` — the whole thing, for a caller with nothing else to decide.
|
|
227
|
+
*
|
|
228
|
+
* A caller that has to run the editor **more than once** on the same file (an edit that has to be
|
|
229
|
+
* re-opened) resolves once with {@link requireEditor} and then calls {@link runEditor}, so a changed
|
|
230
|
+
* `$EDITOR` mid-session cannot swap editors between rounds.
|
|
231
|
+
*/
|
|
232
|
+
export async function openInEditor(file: string, options: EditorEnvironment & RunEditorOptions = {}): Promise<number> {
|
|
233
|
+
// `async`, so a refusal is a rejection rather than a synchronous throw. A caller awaiting this has
|
|
234
|
+
// one failure channel; giving it two is how a refusal escapes an `await …catch` and kills the process.
|
|
235
|
+
return runEditor(requireEditor(file, options), file, options);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The platform default: `notepad` on Windows, `nano` where it is installed, `vi` everywhere else. */
|
|
239
|
+
function defaultEditor(platform: NodeJS.Platform, hasCommand: (command: string) => boolean): string {
|
|
240
|
+
// Windows never probes for a POSIX editor: a Git-for-Windows shell can well have `nano` on its PATH,
|
|
241
|
+
// and answering with it for a `cmd.exe` user is an editor they have no way to leave.
|
|
242
|
+
if (platform === "win32") return "notepad";
|
|
243
|
+
// `nano` first because it says how to quit on screen; `vi` because POSIX requires it to be there.
|
|
244
|
+
return hasCommand("nano") ? "nano" : "vi";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Whether `command` is an executable on `PATH`. The default {@link EditorEnvironment.hasCommand}. */
|
|
248
|
+
function onPath(command: string): boolean {
|
|
249
|
+
const path = process.env.PATH ?? "";
|
|
250
|
+
return path
|
|
251
|
+
.split(delimiter)
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.some((directory) => existsSync(join(directory, command)));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Split an editor variable into a command and its arguments, honoring quotes.
|
|
258
|
+
*
|
|
259
|
+
* `EDITOR` is a command line, not a filename: `code --wait` is the value the refusal below asks people
|
|
260
|
+
* to set, and `"/Applications/Sublime Text.app/…/subl" --wait` is what a macOS adopter has. Splitting on
|
|
261
|
+
* whitespace alone would make the second one a command called `"/Applications/Sublime` — an ENOENT that
|
|
262
|
+
* says nothing about what is wrong.
|
|
263
|
+
*
|
|
264
|
+
* Deliberately not a shell parser. No expansion, no substitution, no operators: the value is split into
|
|
265
|
+
* words, and quotes group a word. Anything more would be running the adopter's variable as a script.
|
|
266
|
+
*/
|
|
267
|
+
function splitCommand(value: string): string[] {
|
|
268
|
+
const words: string[] = [];
|
|
269
|
+
let word = "";
|
|
270
|
+
let quote: '"' | "'" | null = null;
|
|
271
|
+
let started = false;
|
|
272
|
+
|
|
273
|
+
for (const character of value) {
|
|
274
|
+
if (quote !== null) {
|
|
275
|
+
if (character === quote) quote = null;
|
|
276
|
+
else word += character;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (character === '"' || character === "'") {
|
|
280
|
+
quote = character;
|
|
281
|
+
started = true;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (/\s/.test(character)) {
|
|
285
|
+
if (started) words.push(word);
|
|
286
|
+
word = "";
|
|
287
|
+
started = false;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
word += character;
|
|
291
|
+
started = true;
|
|
292
|
+
}
|
|
293
|
+
if (started) words.push(word);
|
|
294
|
+
return words;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The lookup key for a command: its base name, lowercased, without a Windows extension. */
|
|
298
|
+
function editorKey(command: string): string {
|
|
299
|
+
const base = command.split(/[/\\]/).pop() ?? command;
|
|
300
|
+
const lowered = base.toLowerCase();
|
|
301
|
+
const stripped = WINDOWS_EXTENSIONS.reduce(
|
|
302
|
+
(name, extension) => (name.endsWith(extension) ? name.slice(0, -extension.length) : name),
|
|
303
|
+
lowered,
|
|
304
|
+
);
|
|
305
|
+
return stripped === NOTEPAD_PLUS_PLUS ? "notepad__" : stripped;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Refuse an editor known to return before the edit is finished, naming the flag that fixes it. */
|
|
309
|
+
function requireWaiting(editor: ResolvedEditor): ResolvedEditor {
|
|
310
|
+
const flags = WAIT_FLAGS[editorKey(editor.command)];
|
|
311
|
+
if (flags === undefined || editor.args.some((argument) => flags.includes(argument))) return editor;
|
|
312
|
+
const flag = flags[0] as string;
|
|
313
|
+
return raiseNoWait(editor, flag);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The refusal itself, kept apart so the wording is in one place and reads as one sentence. */
|
|
317
|
+
function raiseNoWait(editor: ResolvedEditor, flag: string): never {
|
|
318
|
+
const base = editor.command.split(/[/\\]/).pop() ?? editor.command;
|
|
319
|
+
throw new ValidationError({
|
|
320
|
+
message: `${editor.source} is '${editor.command}', which opens a window and returns before you are done.`,
|
|
321
|
+
action: `Set ${editor.source}="${base} ${flag}" and run this again.`,
|
|
322
|
+
detail: `editor '${editor.command}' from ${editor.source} carries no wait flag (${flag})`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Wrap a command-line word for `cmd.exe` when it needs it. Windows paths cannot contain a quote. */
|
|
327
|
+
function quoteForCmd(word: string): string {
|
|
328
|
+
return /\s/.test(word) ? `"${word}"` : word;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The real spawn, kept behind the seam so a test never starts a process. */
|
|
332
|
+
const defaultSpawn: SpawnEditor = (command, args, options) =>
|
|
333
|
+
spawn(command, [...args], { stdio: options.stdio, shell: options.shell });
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { existsSync, lstatSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { appendFile, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { homedir as osHomedir } from "node:os";
|
|
7
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
8
|
+
import { ConflictError, ValidationError } from "@pithy-sh/core/src/error/pithyError";
|
|
9
|
+
import { readOptionalFile } from "../project/readOptionalFile";
|
|
10
|
+
|
|
11
|
+
/** Mode for an rc file we create from scratch: owner read/write, group/other read (POSIX `0644`). */
|
|
12
|
+
const RC_FILE_MODE = 0o644;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read an rc file, returning `''` when it does not exist yet (the common first-install case).
|
|
16
|
+
*
|
|
17
|
+
* "Does not exist" is {@link readOptionalFile}'s decision, not this module's — an rc file that is there
|
|
18
|
+
* and will not open must not read as an empty one, because `removeFromRcFile` rewrites what it read.
|
|
19
|
+
*
|
|
20
|
+
* **The words are this file's, and they matter more here than almost anywhere** (#203). The two commands
|
|
21
|
+
* that read an rc file are `pithy alias` and `pithy doctor`: the two an adopter runs *because* something
|
|
22
|
+
* is already wrong. This used to rethrow node's own error, so a `.zshrc` that would not open answered a
|
|
23
|
+
* bare `EACCES` and a stack — the failure the error model exists to prevent, on the surface least able to
|
|
24
|
+
* absorb it. It names the file and what to do instead. Nothing of the file's contents goes in either: an
|
|
25
|
+
* rc file is where a developer keeps `export GITHUB_TOKEN=…`.
|
|
26
|
+
*/
|
|
27
|
+
export async function readRcFile(path: string): Promise<string> {
|
|
28
|
+
const contents = await readOptionalFile(path, {
|
|
29
|
+
unreadable: ({ code, cause }) =>
|
|
30
|
+
new ConflictError(
|
|
31
|
+
{
|
|
32
|
+
message: `Can't read ${path}.`,
|
|
33
|
+
action: "Fix the file's permissions, or add the Pithy alias to your shell config yourself.",
|
|
34
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
35
|
+
},
|
|
36
|
+
{ cause },
|
|
37
|
+
),
|
|
38
|
+
});
|
|
39
|
+
return contents ?? "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when `target` resolves to `home` itself or a path beneath it. */
|
|
43
|
+
function isInsideHome(home: string, target: string): boolean {
|
|
44
|
+
const rel = relative(home, target);
|
|
45
|
+
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Append text to an rc file, creating it (mode `0644`, with parent directories) when missing.
|
|
50
|
+
*
|
|
51
|
+
* Two safety refusals, per docs/CLI.md §2.5:
|
|
52
|
+
* - **Symlink escape.** If the path is a symlink whose real target lands outside the user's home
|
|
53
|
+
* directory, refuse — following it could clobber a file the user never meant to expose.
|
|
54
|
+
* - **Read-only file.** If the file exists without an owner write bit, refuse with a clean error that
|
|
55
|
+
* points to manual install, never a raw stack trace.
|
|
56
|
+
*
|
|
57
|
+
* The write itself is append-only: we never read-modify-write the whole file on install.
|
|
58
|
+
*/
|
|
59
|
+
export async function appendToRcFile(path: string, text: string): Promise<void> {
|
|
60
|
+
const home = existsSync(osHomedir()) ? realpathSync(osHomedir()) : osHomedir();
|
|
61
|
+
|
|
62
|
+
// Symlink-escape guard. lstat does not follow the link, so it fires even for a *dangling* symlink — the
|
|
63
|
+
// case a plain `existsSync` (which follows) would silently skip, letting us create a file at the escaped
|
|
64
|
+
// target. Resolve the target with realpath when it exists (following the whole chain), else from the raw
|
|
65
|
+
// link (dangling), and refuse when it lands outside the user's home directory.
|
|
66
|
+
const info = lstatSync(path, { throwIfNoEntry: false });
|
|
67
|
+
if (info?.isSymbolicLink()) {
|
|
68
|
+
let target: string;
|
|
69
|
+
try {
|
|
70
|
+
target = realpathSync(path);
|
|
71
|
+
} catch {
|
|
72
|
+
const raw = readlinkSync(path);
|
|
73
|
+
target = isAbsolute(raw) ? raw : resolve(dirname(path), raw);
|
|
74
|
+
}
|
|
75
|
+
if (!isInsideHome(home, target)) {
|
|
76
|
+
throw new ConflictError({
|
|
77
|
+
message: `${path} is a symlink that points outside your home directory.`,
|
|
78
|
+
action: "Refusing to follow it. Add the Pithy alias to your shell config yourself.",
|
|
79
|
+
detail: `Symlink target resolved to ${target}, outside ${home}.`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Permission guard: an existing file with no owner write bit is read-only to us. `statSync` follows the
|
|
85
|
+
// link, so this checks the real target (skipped for a dangling link, which has no target to write yet).
|
|
86
|
+
if (existsSync(path) && (statSync(path).mode & 0o200) === 0) {
|
|
87
|
+
throw new ValidationError({
|
|
88
|
+
message: `Can't write to ${path} — it's read-only.`,
|
|
89
|
+
action: "Make it writable, or add the Pithy alias to your shell config yourself.",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const existedBefore = existsSync(path);
|
|
94
|
+
if (!existedBefore && !info) await mkdir(dirname(path), { recursive: true });
|
|
95
|
+
await appendFile(path, text);
|
|
96
|
+
// Force the exact mode on a brand-new regular file we created, regardless of the process umask.
|
|
97
|
+
if (!existedBefore && !info) await chmod(path, RC_FILE_MODE);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Remove exactly the marker-delimited block (`open` … `close`, inclusive) from an rc file, leaving
|
|
102
|
+
* everything else byte-intact. Returns whether a block was found and removed. This is the one
|
|
103
|
+
* read-modify-write path — removal must parse and rewrite — but it touches only our own lines.
|
|
104
|
+
*/
|
|
105
|
+
export async function removeFromRcFile(path: string, open: string, close: string): Promise<boolean> {
|
|
106
|
+
if (!existsSync(path)) return false;
|
|
107
|
+
const contents = await readFile(path, "utf8");
|
|
108
|
+
const lines = contents.split("\n");
|
|
109
|
+
const openIdx = lines.indexOf(open);
|
|
110
|
+
const closeIdx = lines.indexOf(close);
|
|
111
|
+
if (openIdx === -1 || closeIdx === -1 || closeIdx < openIdx) return false;
|
|
112
|
+
|
|
113
|
+
// Also drop the single blank line we inserted before the block on install, if it is still there.
|
|
114
|
+
const start = openIdx > 0 && lines[openIdx - 1] === "" ? openIdx - 1 : openIdx;
|
|
115
|
+
lines.splice(start, closeIdx - start + 1);
|
|
116
|
+
await writeFile(path, lines.join("\n"));
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { homedir as osHomedir } from "node:os";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** The shells whose alias syntax and rc-file location we know how to write. */
|
|
9
|
+
export type ShellKind = "bash" | "zsh" | "fish" | "powershell" | "nushell";
|
|
10
|
+
|
|
11
|
+
/** A resolved shell: which one, where its config lives, and the exact line that defines `p.`. */
|
|
12
|
+
export interface ShellInfo {
|
|
13
|
+
/** The detected shell family. */
|
|
14
|
+
kind: ShellKind;
|
|
15
|
+
/** Absolute path to the rc/profile file the alias line belongs in. */
|
|
16
|
+
rcPath: string;
|
|
17
|
+
/** The shell-specific line that defines the `p.` alias (no markers). */
|
|
18
|
+
aliasSyntax: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Injectable seams so detection is testable without touching the real environment. */
|
|
22
|
+
export interface DetectShellOptions {
|
|
23
|
+
/** The shell path, defaulting to `process.env.SHELL` (e.g. `/bin/zsh`). */
|
|
24
|
+
shell?: string;
|
|
25
|
+
/** The platform, defaulting to `process.platform`. */
|
|
26
|
+
platform?: NodeJS.Platform;
|
|
27
|
+
/** The user's home directory, defaulting to `os.homedir()`. */
|
|
28
|
+
homedir?: string;
|
|
29
|
+
/** Existence probe, defaulting to `fs.existsSync` — used for the macOS `.bash_profile` preference. */
|
|
30
|
+
fileExists?: (path: string) => boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The bash/zsh single-quoted form; also the fallback we print for an unknown shell. */
|
|
34
|
+
const POSIX_ALIAS = "alias p.='pithy'";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Detect the current shell and resolve where its `p.` alias belongs.
|
|
38
|
+
*
|
|
39
|
+
* Detection is `basename($SHELL)` plus platform checks, per docs/CLI.md §2.4. bash on macOS prefers
|
|
40
|
+
* `.bash_profile` (the platform convention) and falls back to `.bashrc` only when it is absent. Windows
|
|
41
|
+
* with no unix shell resolves to PowerShell — whose alias MUST be a function, not `Set-Alias` (that
|
|
42
|
+
* rejects a `.` in the name). An unrecognized shell returns `null`: the caller prints manual instructions
|
|
43
|
+
* and writes nothing. We never guess an rc file.
|
|
44
|
+
*/
|
|
45
|
+
export async function detectShell(options: DetectShellOptions = {}): Promise<ShellInfo | null> {
|
|
46
|
+
const shell = options.shell ?? process.env.SHELL ?? "";
|
|
47
|
+
const platform = options.platform ?? process.platform;
|
|
48
|
+
const home = options.homedir ?? osHomedir();
|
|
49
|
+
const fileExists = options.fileExists ?? existsSync;
|
|
50
|
+
const base = basename(shell).toLowerCase();
|
|
51
|
+
|
|
52
|
+
if (base.endsWith("bash")) {
|
|
53
|
+
// macOS ships the `.bash_profile` convention; prefer it when it exists, else `.bashrc`.
|
|
54
|
+
const rcPath =
|
|
55
|
+
platform === "darwin" && fileExists(join(home, ".bash_profile"))
|
|
56
|
+
? join(home, ".bash_profile")
|
|
57
|
+
: join(home, ".bashrc");
|
|
58
|
+
return { kind: "bash", rcPath, aliasSyntax: POSIX_ALIAS };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (base.endsWith("zsh")) {
|
|
62
|
+
return { kind: "zsh", rcPath: join(home, ".zshrc"), aliasSyntax: POSIX_ALIAS };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (base.endsWith("fish")) {
|
|
66
|
+
// fish has no `=` in its alias form.
|
|
67
|
+
return { kind: "fish", rcPath: join(home, ".config", "fish", "config.fish"), aliasSyntax: "alias p. pithy" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (base.endsWith("nu")) {
|
|
71
|
+
// nushell uses a spaced `=` form.
|
|
72
|
+
return { kind: "nushell", rcPath: join(home, ".config", "nushell", "config.nu"), aliasSyntax: "alias p. = pithy" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// PowerShell: a unix shell was not detected but we are on Windows (or the shell names PowerShell
|
|
76
|
+
// explicitly). `Set-Alias` rejects a `.` in the name, so the alias must be a passthrough function.
|
|
77
|
+
if (platform === "win32" || base.endsWith("pwsh") || base.endsWith("powershell")) {
|
|
78
|
+
const rcPath = join(home, "Documents", "PowerShell", "Microsoft.PowerShell_profile.ps1");
|
|
79
|
+
return { kind: "powershell", rcPath, aliasSyntax: "function p. { pithy @args }" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return null;
|
|
83
|
+
}
|