create-pathfinder 1.4.0 → 1.5.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/AGENTS.md +4 -0
- package/CLAUDE.md +7 -1
- package/README.md +97 -9
- package/bin/create-pathfinder.mjs +32 -4
- package/context/features/example-feature-spec.md +5 -1
- package/context/project-overview.md +16 -3
- package/copy-list.json +0 -1
- package/package.json +2 -2
- package/skills/reflect/SKILL.md +46 -46
- package/skills/reverse-engineer/SKILL.md +2 -0
- package/skills/to-specs/SKILL.md +1 -1
- package/src/cli.mjs +765 -34
- package/src/clipboard.mjs +134 -0
- package/src/detect.mjs +183 -0
- package/src/editor.mjs +136 -0
- package/src/git.mjs +58 -0
- package/src/harnesses/adapter.mjs +288 -0
- package/src/harnesses/index.mjs +122 -0
- package/src/install.mjs +209 -1
- package/src/kickstart-prompt.mjs +81 -0
- package/src/prompt.mjs +307 -0
- package/templates/project-overview.template.md +16 -3
- package/prompts/01-kickstart-project.md +0 -1
- package/prompts/01-teach-current-feature.md +0 -9
- package/prompts/02-debate-me.md +0 -1
- package/prompts/02-quiz-current-feature.md +0 -7
- package/prompts/03-challenge-current-feature.md +0 -7
- package/prompts/03-prototype.md +0 -1
- package/prompts/04-teach-current-architecture.md +0 -7
- package/prompts/04-to-specs.md +0 -1
- package/prompts/05-learning-review.md +0 -5
- package/prompts/05-load-feature.md +0 -1
- package/prompts/06-start-feature.md +0 -1
- package/prompts/07-review-feature.md +0 -1
- package/prompts/08-complete-feature.md +0 -1
- package/prompts/09-learn-feature.md +0 -1
- package/prompts/10-learn-codebase.md +0 -1
- package/prompts/11-handoff.md +0 -1
- package/prompts/12-skillsmith.md +0 -1
- package/prompts/13-reverse-engineer.md +0 -18
- package/prompts/14-reflect.md +0 -13
- package/prompts/15-debug-issue.md +0 -15
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing one string to the system clipboard, and never reading it back.
|
|
3
|
+
*
|
|
4
|
+
* Three rules shape this file.
|
|
5
|
+
*
|
|
6
|
+
* **It is a convenience, so it may not fail.** Every way this can go wrong — no
|
|
7
|
+
* tool installed, no display to talk to, a tool that exits non-zero, a tool that
|
|
8
|
+
* hangs — degrades to "not copied" and a printed prompt the user can select the
|
|
9
|
+
* old way. Nothing here can change an install's exit code.
|
|
10
|
+
*
|
|
11
|
+
* **No dependency.** A clipboard package would be the first runtime dependency
|
|
12
|
+
* in a project whose identity is "not a framework," bought for a feature that
|
|
13
|
+
* has to be optional anyway. Platform-native commands cost a table.
|
|
14
|
+
*
|
|
15
|
+
* **Write only.** There is no read function, and there should never be one. The
|
|
16
|
+
* clipboard is where people keep passwords for thirty seconds.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
import { onPath } from "./detect.mjs";
|
|
22
|
+
|
|
23
|
+
/** Long enough for a real tool, short enough that a wedged one is not the install. */
|
|
24
|
+
const TIMEOUT_MS = 3000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The clipboard writers worth trying, in the order they are tried.
|
|
28
|
+
*
|
|
29
|
+
* Ordered per platform, but *selected* by availability, which is what makes WSL
|
|
30
|
+
* work without a special case: it reports `linux`, has neither Wayland nor X,
|
|
31
|
+
* and does have `clip.exe`, so it falls through the three Linux entries and
|
|
32
|
+
* lands on the fourth. Probing the platform string alone would have told it to
|
|
33
|
+
* give up after `xsel`.
|
|
34
|
+
*
|
|
35
|
+
* Every command takes its text on **stdin**. None of them takes it as an
|
|
36
|
+
* argument, which is not a coincidence — it is the property that makes this
|
|
37
|
+
* safe, because the prompt never becomes part of a command line.
|
|
38
|
+
*/
|
|
39
|
+
const WRITERS = Object.freeze({
|
|
40
|
+
darwin: [{ command: "pbcopy", args: [] }],
|
|
41
|
+
win32: [{ command: "clip", args: [] }],
|
|
42
|
+
default: [
|
|
43
|
+
{ command: "wl-copy", args: [] },
|
|
44
|
+
{ command: "xclip", args: ["-selection", "clipboard"] },
|
|
45
|
+
{ command: "xsel", args: ["--clipboard", "--input"] },
|
|
46
|
+
// WSL, and any Linux that can reach a Windows host's clipboard.
|
|
47
|
+
{ command: "clip.exe", args: [] },
|
|
48
|
+
],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The clipboard command this machine can actually run, or null.
|
|
53
|
+
*
|
|
54
|
+
* Null is an ordinary answer, not an error: a headless server has no clipboard,
|
|
55
|
+
* and saying so is more useful than trying four commands and reporting the
|
|
56
|
+
* fourth one's stderr.
|
|
57
|
+
*
|
|
58
|
+
* @returns {{command: string, args: string[]} | null}
|
|
59
|
+
*/
|
|
60
|
+
export function clipboardCommand({ env = {}, platform = process.platform } = {}) {
|
|
61
|
+
const candidates = WRITERS[platform] ?? WRITERS.default;
|
|
62
|
+
return candidates.find((writer) => onPath(writer.command, env, platform)) ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Put `text` on the clipboard. Say whether it worked.
|
|
67
|
+
*
|
|
68
|
+
* Never throws. A spawn that fails, a tool that exits non-zero, a timeout, and
|
|
69
|
+
* a machine with no clipboard tool at all are four different reasons and one
|
|
70
|
+
* outcome, and the caller prints a single line about it either way.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} text
|
|
73
|
+
* @returns {{ok: true, command: string} | {ok: false, reason: string}}
|
|
74
|
+
*/
|
|
75
|
+
export function copyToClipboard(text, { env = {}, platform = process.platform } = {}) {
|
|
76
|
+
const writer = clipboardCommand({ env, platform });
|
|
77
|
+
if (writer === null) return { ok: false, reason: "no clipboard tool is available here" };
|
|
78
|
+
|
|
79
|
+
let result;
|
|
80
|
+
try {
|
|
81
|
+
result = spawnSync(writer.command, writer.args, {
|
|
82
|
+
// An array, never a shell string. The text goes down stdin and is never
|
|
83
|
+
// interpolated into a command line, so a prompt containing a backtick, a
|
|
84
|
+
// semicolon, or a newline is data on every platform.
|
|
85
|
+
shell: false,
|
|
86
|
+
// The same environment the command was *found* in. Resolving `pbcopy`
|
|
87
|
+
// against one PATH and then running it against another is how a probe
|
|
88
|
+
// ends up disagreeing with the thing it probed for.
|
|
89
|
+
env,
|
|
90
|
+
input: text,
|
|
91
|
+
timeout: TIMEOUT_MS,
|
|
92
|
+
// Captured, not inherited: xclip's complaint about a missing DISPLAY must
|
|
93
|
+
// not appear to be something Pathfinder said.
|
|
94
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// spawnSync generally reports rather than throws, but a bad argv or an
|
|
99
|
+
// exhausted process table can still land here, and a convenience feature
|
|
100
|
+
// is not allowed to take the install down with it.
|
|
101
|
+
return { ok: false, reason: describe(writer.command, error?.message) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (result.error) {
|
|
105
|
+
const timedOut = result.error.code === "ETIMEDOUT" || result.signal !== null;
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: timedOut
|
|
109
|
+
? `${writer.command} did not finish`
|
|
110
|
+
: describe(writer.command, result.error.message),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (result.status !== 0) {
|
|
115
|
+
// The tool's own first line of stderr is the most useful thing available —
|
|
116
|
+
// "Error: Can't open display" is the actual answer — but it is quoted, so
|
|
117
|
+
// it reads as the tool's words rather than Pathfinder's.
|
|
118
|
+
const detail = firstLine(result.stderr);
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: detail === "" ? `${writer.command} failed` : `${writer.command} said: ${detail}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { ok: true, command: writer.command };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function describe(command, message) {
|
|
129
|
+
return message ? `${command} could not be run: ${firstLine(message)}` : `${command} could not be run`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function firstLine(text) {
|
|
133
|
+
return String(text ?? "").split("\n")[0].trim();
|
|
134
|
+
}
|
package/src/detect.mjs
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is already here, and what is available to run.
|
|
3
|
+
*
|
|
4
|
+
* Detection is advisory. Everything in this file answers a question of the form
|
|
5
|
+
* "is this present?" and nothing acts on the answer: a finding sets a default
|
|
6
|
+
* or a line in the report, never a file on disk. That boundary is the whole
|
|
7
|
+
* reason this is a separate module — a detector that could also configure
|
|
8
|
+
* something would make every future "it noticed X" into "it did X to me."
|
|
9
|
+
*
|
|
10
|
+
* Nothing here spawns a process. Availability is decided by scanning PATH with
|
|
11
|
+
* existsSync rather than by running `which`/`where` or the tool itself, which
|
|
12
|
+
* buys three things: detection cannot hang, cannot print another program's
|
|
13
|
+
* output over ours, and cannot become the dependency on the `git` binary that
|
|
14
|
+
* findGitRoot exists to avoid. The cost is that a PATH entry which is present
|
|
15
|
+
* but not executable reads as detected. That is the right direction to be
|
|
16
|
+
* wrong in, because a finding only ever offers a default the user can decline.
|
|
17
|
+
*
|
|
18
|
+
* Every probe is wrapped so a failure degrades to "not detected". An unreadable
|
|
19
|
+
* $HOME, a PATH full of directories that do not exist, a permissions error —
|
|
20
|
+
* none of them may abort an install.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
24
|
+
import { join } from "node:path";
|
|
25
|
+
|
|
26
|
+
import { findGitRoot } from "./kit.mjs";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The tools worth reporting, in the order they are reported.
|
|
30
|
+
*
|
|
31
|
+
* This table says how to recognize a tool, and deliberately says nothing about
|
|
32
|
+
* what to do with one. Feature 11 owns the harness registry — where each writes
|
|
33
|
+
* and what it generates — and will consume these ids rather than restate them.
|
|
34
|
+
* Two of these four (VS Code, Cursor) are not skill harnesses at all and never
|
|
35
|
+
* will be; they are here because later features act on the editor, not the
|
|
36
|
+
* harness.
|
|
37
|
+
*/
|
|
38
|
+
const TOOLS = [
|
|
39
|
+
{
|
|
40
|
+
id: "claude-code",
|
|
41
|
+
label: "Claude Code",
|
|
42
|
+
commands: ["claude"],
|
|
43
|
+
homeMarkers: [".claude"],
|
|
44
|
+
projectMarkers: [".claude"],
|
|
45
|
+
termProgram: [],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "codex",
|
|
49
|
+
label: "Codex",
|
|
50
|
+
commands: ["codex"],
|
|
51
|
+
homeMarkers: [".codex", join(".agents", "skills")],
|
|
52
|
+
projectMarkers: [".agents"],
|
|
53
|
+
termProgram: [],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "vscode",
|
|
57
|
+
label: "VS Code",
|
|
58
|
+
commands: ["code"],
|
|
59
|
+
homeMarkers: [],
|
|
60
|
+
projectMarkers: [],
|
|
61
|
+
termProgram: ["vscode"],
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: "cursor",
|
|
65
|
+
label: "Cursor",
|
|
66
|
+
commands: ["cursor"],
|
|
67
|
+
homeMarkers: [".cursor"],
|
|
68
|
+
projectMarkers: [".cursor"],
|
|
69
|
+
termProgram: ["cursor"],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything the CLI knows about its environment before it asks anything.
|
|
75
|
+
*
|
|
76
|
+
* Reads the filesystem and the environment. Writes nothing, spawns nothing.
|
|
77
|
+
*
|
|
78
|
+
* @returns {{
|
|
79
|
+
* git: {repositoryRoot: string|null, insideRepository: boolean, binary: boolean},
|
|
80
|
+
* pathfinder: {installed: boolean, skillCount: number},
|
|
81
|
+
* tools: {id: string, label: string, detected: boolean}[],
|
|
82
|
+
* }}
|
|
83
|
+
*/
|
|
84
|
+
export function detect({ cwd, env = {}, platform = process.platform } = {}) {
|
|
85
|
+
const home = homeDirectory(env);
|
|
86
|
+
const repositoryRoot = safe(() => findGitRoot(cwd), null);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
git: {
|
|
90
|
+
repositoryRoot,
|
|
91
|
+
insideRepository: repositoryRoot !== null,
|
|
92
|
+
binary: safe(() => onPath("git", env, platform), false),
|
|
93
|
+
},
|
|
94
|
+
pathfinder: safe(() => detectPathfinder(cwd), { installed: false, skillCount: 0 }),
|
|
95
|
+
tools: TOOLS.map((tool) => ({
|
|
96
|
+
id: tool.id,
|
|
97
|
+
label: tool.label,
|
|
98
|
+
detected: safe(() => detectTool(tool, { cwd, home, env, platform }), false),
|
|
99
|
+
})),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The labels of every detected tool, in table order. Convenience for the report. */
|
|
104
|
+
export function detectedToolLabels(findings) {
|
|
105
|
+
return findings.tools.filter((tool) => tool.detected).map((tool) => tool.label);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function detectTool(tool, { cwd, home, env, platform }) {
|
|
109
|
+
const term = env.TERM_PROGRAM ?? "";
|
|
110
|
+
if (tool.termProgram.some((name) => name.toLowerCase() === term.toLowerCase())) return true;
|
|
111
|
+
if (tool.commands.some((command) => onPath(command, env, platform))) return true;
|
|
112
|
+
if (home !== null && tool.homeMarkers.some((marker) => existsSync(join(home, marker)))) return true;
|
|
113
|
+
return tool.projectMarkers.some((marker) => existsSync(join(cwd, marker)));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Does this directory already look like a Pathfinder project?
|
|
118
|
+
*
|
|
119
|
+
* Decided by counting skill directories rather than by testing for `CLAUDE.md`,
|
|
120
|
+
* which any agent-assisted project may have written for its own reasons.
|
|
121
|
+
* A `skills/<name>/SKILL.md` is a far more specific signature, and the count is
|
|
122
|
+
* worth having on its own — it is what makes "already installed (20 skills)"
|
|
123
|
+
* checkable by the person reading it.
|
|
124
|
+
*/
|
|
125
|
+
function detectPathfinder(cwd) {
|
|
126
|
+
const skillsDirectory = join(cwd, "skills");
|
|
127
|
+
if (!existsSync(skillsDirectory)) return { installed: false, skillCount: 0 };
|
|
128
|
+
|
|
129
|
+
const skillCount = readdirSync(skillsDirectory, { withFileTypes: true }).filter(
|
|
130
|
+
(entry) => entry.isDirectory() && existsSync(join(skillsDirectory, entry.name, "SKILL.md")),
|
|
131
|
+
).length;
|
|
132
|
+
|
|
133
|
+
return { installed: skillCount > 0, skillCount };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Is `command` findable on PATH?
|
|
138
|
+
*
|
|
139
|
+
* The separator is derived from the passed platform rather than read from
|
|
140
|
+
* `node:path`, so a test can synthesize a Windows environment on any host.
|
|
141
|
+
* PATHEXT is honored for the same reason `git` is spelled `git.exe` there.
|
|
142
|
+
*/
|
|
143
|
+
export function onPath(command, env, platform) {
|
|
144
|
+
const searchPath = env.PATH ?? env.Path ?? "";
|
|
145
|
+
if (searchPath === "") return false;
|
|
146
|
+
|
|
147
|
+
const separator = platform === "win32" ? ";" : ":";
|
|
148
|
+
const extensions =
|
|
149
|
+
platform === "win32"
|
|
150
|
+
? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)
|
|
151
|
+
: [""];
|
|
152
|
+
|
|
153
|
+
for (const directory of searchPath.split(separator)) {
|
|
154
|
+
if (directory === "") continue;
|
|
155
|
+
for (const extension of extensions) {
|
|
156
|
+
if (existsSync(join(directory, command + extension))) return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The user's home directory, or null when the environment does not say.
|
|
165
|
+
*
|
|
166
|
+
* Null rather than a guess. A wrong home directory would make every
|
|
167
|
+
* home-marker probe silently answer about somewhere nobody lives, and "not
|
|
168
|
+
* detected" is the honest result when the environment is that impoverished —
|
|
169
|
+
* which is a real CI configuration, not a hypothetical one.
|
|
170
|
+
*/
|
|
171
|
+
function homeDirectory(env) {
|
|
172
|
+
return env.HOME || env.USERPROFILE || null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Run a probe; treat any failure as "not detected". */
|
|
176
|
+
function safe(probe, fallback) {
|
|
177
|
+
try {
|
|
178
|
+
const value = probe();
|
|
179
|
+
return value === undefined ? fallback : value;
|
|
180
|
+
} catch {
|
|
181
|
+
return fallback;
|
|
182
|
+
}
|
|
183
|
+
}
|
package/src/editor.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finding the editors this machine can actually launch, and launching one.
|
|
3
|
+
*
|
|
4
|
+
* The same three rules the clipboard follows, for the same reasons.
|
|
5
|
+
*
|
|
6
|
+
* **It is a convenience, so it may not fail.** No editor installed, a binary
|
|
7
|
+
* that cannot be executed, a launch that dies on the spot — all of it degrades
|
|
8
|
+
* to "not opened" and one printed line. The project is installed either way,
|
|
9
|
+
* and nothing here can change an install's exit code.
|
|
10
|
+
*
|
|
11
|
+
* **Only what was detected.** There is no way to name an editor, pass a path,
|
|
12
|
+
* or configure one. An editor Pathfinder did not find on PATH is an editor it
|
|
13
|
+
* does not offer, which is what keeps this from growing into a launcher.
|
|
14
|
+
*
|
|
15
|
+
* **Detached.** The CLI hands the project to the editor and forgets it. It does
|
|
16
|
+
* not wait for the editor to exit, does not hold its streams, and does not
|
|
17
|
+
* print its output as Pathfinder's — an installer that stays alive until you
|
|
18
|
+
* close your editor would be a bug that looks like a hang.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
22
|
+
import { once } from "node:events";
|
|
23
|
+
|
|
24
|
+
import { onPath } from "./detect.mjs";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The editors that can be offered, in the order they are offered.
|
|
28
|
+
*
|
|
29
|
+
* Alphabetical by label, deliberately. Some order has to exist, and every
|
|
30
|
+
* ordering that means something — most popular, best supported, the one we use
|
|
31
|
+
* — would make this table an endorsement. The alphabet endorses nothing, and
|
|
32
|
+
* it puts Cursor above VS Code, which is a useful reminder that neither is a
|
|
33
|
+
* Pathfinder requirement.
|
|
34
|
+
*
|
|
35
|
+
* Detection is by command on PATH and nothing else. `src/detect.mjs` reports
|
|
36
|
+
* these two tools more generously — a `.cursor` directory counts there — and
|
|
37
|
+
* that is right for a finding and wrong for a launch: a marker directory does
|
|
38
|
+
* not give you a binary to run.
|
|
39
|
+
*/
|
|
40
|
+
const EDITORS = Object.freeze([
|
|
41
|
+
Object.freeze({ id: "cursor", label: "Cursor", command: "cursor" }),
|
|
42
|
+
Object.freeze({ id: "vscode", label: "VS Code", command: "code" }),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The editors this machine can launch. Possibly none, which is an ordinary
|
|
47
|
+
* answer: a server with no editor installed simply is not asked the question.
|
|
48
|
+
*
|
|
49
|
+
* @returns {ReadonlyArray<{id: string, label: string, command: string}>}
|
|
50
|
+
*/
|
|
51
|
+
export function detectEditors({ env = {}, platform = process.platform } = {}) {
|
|
52
|
+
return EDITORS.filter((editor) => onPath(editor.command, env, platform));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ask `editor` to open `directory`, and say whether the launch got off the
|
|
57
|
+
* ground. Never throws.
|
|
58
|
+
*
|
|
59
|
+
* "Got off the ground" is the honest limit of what this can report. It resolves
|
|
60
|
+
* as soon as the child process exists — Node's `spawn` event — and deliberately
|
|
61
|
+
* not on its exit, because waiting for an editor to exit is waiting for the
|
|
62
|
+
* user to close their editor. An editor that starts and then fails on its own
|
|
63
|
+
* has its own way of saying so, on its own screen.
|
|
64
|
+
*
|
|
65
|
+
* @param {{label: string, command: string}} editor
|
|
66
|
+
* @param {string} directory
|
|
67
|
+
* @returns {Promise<{ok: true} | {ok: false, reason: string}>}
|
|
68
|
+
*/
|
|
69
|
+
export async function openInEditor(
|
|
70
|
+
editor,
|
|
71
|
+
directory,
|
|
72
|
+
{ env = {}, platform = process.platform } = {},
|
|
73
|
+
) {
|
|
74
|
+
const windows = platform === "win32";
|
|
75
|
+
|
|
76
|
+
// `code` and `cursor` are `.cmd` shims on Windows, which Node refuses to
|
|
77
|
+
// execute directly, so there it goes through the command processor. The
|
|
78
|
+
// quoting is ours rather than the shell's, because `windowsVerbatimArguments`
|
|
79
|
+
// hands this line through untouched — and it needs one more pair of quotes
|
|
80
|
+
// than looks right.
|
|
81
|
+
//
|
|
82
|
+
// `cmd /s /c` strips the first and last quote of everything after `/c`,
|
|
83
|
+
// unconditionally. Passing `"code" "C:\my project"` therefore loses the quote
|
|
84
|
+
// before `code` and the one after the path, leaving `code" "C:\my project` —
|
|
85
|
+
// a broken command line, and a silently unopened editor. Wrapping the whole
|
|
86
|
+
// thing in an outer pair spends those two quotes on the wrapper, so what
|
|
87
|
+
// survives the strip is the command line actually meant: the executable
|
|
88
|
+
// quoted, and a project path with spaces in it still a single argument.
|
|
89
|
+
//
|
|
90
|
+
// Everywhere else it is an argument array with no shell at all.
|
|
91
|
+
const command = windows ? env.ComSpec || env.COMSPEC || "cmd.exe" : editor.command;
|
|
92
|
+
const args = windows
|
|
93
|
+
? ["/d", "/s", "/c", `""${editor.command}" "${directory}""`]
|
|
94
|
+
: [directory];
|
|
95
|
+
|
|
96
|
+
let child;
|
|
97
|
+
try {
|
|
98
|
+
child = spawn(command, args, {
|
|
99
|
+
shell: false,
|
|
100
|
+
// The environment the command was *found* in, for the same reason the
|
|
101
|
+
// clipboard does it: resolving `code` against one PATH and running it
|
|
102
|
+
// against another is how a probe disagrees with the thing it probed for.
|
|
103
|
+
env,
|
|
104
|
+
// Detached and ignored together. Its own process group so it outlives
|
|
105
|
+
// this one, and no inherited streams so the editor's startup chatter is
|
|
106
|
+
// never mistaken for something Pathfinder said.
|
|
107
|
+
detached: true,
|
|
108
|
+
stdio: "ignore",
|
|
109
|
+
windowsVerbatimArguments: windows,
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return { ok: false, reason: describe(editor.command, error?.message) };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
// Resolves on `spawn`, rejects on `error` — a missing or unexecutable
|
|
118
|
+
// binary reports ENOENT or EACCES here, before anything is unreferenced.
|
|
119
|
+
await once(child, "spawn");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return { ok: false, reason: describe(editor.command, error?.message) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Anything that goes wrong after a successful spawn belongs to the editor,
|
|
125
|
+
// not to the install — but an unhandled `error` event would still take this
|
|
126
|
+
// process down on its way out, so it is absorbed.
|
|
127
|
+
child.on("error", () => {});
|
|
128
|
+
child.unref();
|
|
129
|
+
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function describe(command, message) {
|
|
134
|
+
const detail = String(message ?? "").split("\n")[0].trim();
|
|
135
|
+
return detail === "" ? `${command} could not be run` : `${command} could not be run: ${detail}`;
|
|
136
|
+
}
|
package/src/git.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one Git command this tool is allowed to run.
|
|
3
|
+
*
|
|
4
|
+
* `git init`, in the directory it was given, and nothing else. No `add`, no
|
|
5
|
+
* `commit`, no `config`, no branch manipulation, no `init` in a parent. The
|
|
6
|
+
* narrowness is the design: this module has no way to express a destructive
|
|
7
|
+
* operation, so no future edit to a caller can turn one on by passing a
|
|
8
|
+
* different argument.
|
|
9
|
+
*
|
|
10
|
+
* Invoked with an argument array, never a shell string, so a directory whose
|
|
11
|
+
* name contains a space, a quote, or a `;` is data rather than syntax.
|
|
12
|
+
*
|
|
13
|
+
* Output is captured rather than inherited. `git init` prints a `hint:` block
|
|
14
|
+
* about default branch names on many installations, and inheriting stdio would
|
|
15
|
+
* make that paragraph appear to be Pathfinder's own voice, in the middle of
|
|
16
|
+
* Pathfinder's own report. Success is reported in our words; failure hands back
|
|
17
|
+
* git's stderr, because there the detail is the actionable part.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { spawnSync } from "node:child_process";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize a repository in `cwd`.
|
|
24
|
+
*
|
|
25
|
+
* Never throws. A missing binary, a read-only path, and a permissions error are
|
|
26
|
+
* all outcomes to report, not exceptions to escape into a stack trace over the
|
|
27
|
+
* top of a half-finished install.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} cwd
|
|
30
|
+
* @param {{spawn?: typeof spawnSync}} [options] - seam for tests; production
|
|
31
|
+
* passes nothing.
|
|
32
|
+
* @returns {{ok: true} | {ok: false, message: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function initRepository(cwd, { spawn = spawnSync } = {}) {
|
|
35
|
+
let result;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
result = spawn("git", ["init"], {
|
|
39
|
+
cwd,
|
|
40
|
+
encoding: "utf8",
|
|
41
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
42
|
+
windowsHide: true,
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { ok: false, message: error.message };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ENOENT arrives here rather than as a throw: the binary vanished between
|
|
49
|
+
// detection and use, or PATH said something that was not true.
|
|
50
|
+
if (result.error) return { ok: false, message: result.error.message };
|
|
51
|
+
|
|
52
|
+
if (result.status !== 0) {
|
|
53
|
+
const stderr = (result.stderr ?? "").trim();
|
|
54
|
+
return { ok: false, message: stderr || `\`git init\` exited with status ${result.status}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|