taphound 0.2.0-dev.1
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 +202 -0
- package/README.md +200 -0
- package/README.zh-CN.md +200 -0
- package/assets/brand/taphound-mark.svg +9 -0
- package/assets/skills/taphound-ai-journey/GUIDE.md +862 -0
- package/assets/skills/taphound-ai-journey/SKILL.md +377 -0
- package/assets/skills/taphound-ai-journey/prompts/analyze-project.md +526 -0
- package/assets/skills/taphound-ai-journey/prompts/check-completion.md +52 -0
- package/assets/skills/taphound-ai-journey/prompts/generate-step.md +109 -0
- package/assets/skills/taphound-ai-journey/schemas/observe-output.json +42 -0
- package/assets/skills/taphound-ai-journey/schemas/project-context.json +81 -0
- package/assets/skills/taphound-ai-journey/schemas/proposed-step-envelope.json +267 -0
- package/assets/skills/taphound-ai-journey/templates/project-context.example.json +49 -0
- package/dist/adapters/adb/adb-adapter.d.ts +23 -0
- package/dist/adapters/adb/adb-adapter.js +218 -0
- package/dist/adapters/android-cli/android-cli-adapter.d.ts +11 -0
- package/dist/adapters/android-cli/android-cli-adapter.js +64 -0
- package/dist/adapters/android-cli/layout-parser.d.ts +3 -0
- package/dist/adapters/android-cli/layout-parser.js +174 -0
- package/dist/adapters/clock/system-clock.d.ts +5 -0
- package/dist/adapters/clock/system-clock.js +13 -0
- package/dist/adapters/filesystem/artifact-store.d.ts +4 -0
- package/dist/adapters/filesystem/artifact-store.js +73 -0
- package/dist/adapters/filesystem/generation-meta-writer.d.ts +16 -0
- package/dist/adapters/filesystem/generation-meta-writer.js +34 -0
- package/dist/adapters/filesystem/generation-session-store.d.ts +68 -0
- package/dist/adapters/filesystem/generation-session-store.js +1603 -0
- package/dist/adapters/filesystem/journey-writer.d.ts +16 -0
- package/dist/adapters/filesystem/journey-writer.js +34 -0
- package/dist/adapters/filesystem/project-bound-file.d.ts +6 -0
- package/dist/adapters/filesystem/project-bound-file.js +240 -0
- package/dist/adapters/filesystem/project-file-inspector.d.ts +8 -0
- package/dist/adapters/filesystem/project-file-inspector.js +198 -0
- package/dist/adapters/filesystem/skill-installer.d.ts +10 -0
- package/dist/adapters/filesystem/skill-installer.js +39 -0
- package/dist/adapters/process/node-process-runner.d.ts +8 -0
- package/dist/adapters/process/node-process-runner.js +155 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.d.ts +55 -0
- package/dist/adapters/prompt/inquirer-generation-prompt.js +213 -0
- package/dist/adapters/prompt/inquirer-init-prompt.d.ts +32 -0
- package/dist/adapters/prompt/inquirer-init-prompt.js +55 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.d.ts +47 -0
- package/dist/adapters/prompt/inquirer-recorder-prompt.js +157 -0
- package/dist/application/assertion/expectation-evaluator.d.ts +61 -0
- package/dist/application/assertion/expectation-evaluator.js +207 -0
- package/dist/application/collector/logcat-collector.d.ts +39 -0
- package/dist/application/collector/logcat-collector.js +107 -0
- package/dist/application/context/context-validator.d.ts +27 -0
- package/dist/application/context/context-validator.js +120 -0
- package/dist/application/doctor/doctor-service.d.ts +35 -0
- package/dist/application/doctor/doctor-service.js +178 -0
- package/dist/application/generation/focused-input.d.ts +2 -0
- package/dist/application/generation/focused-input.js +9 -0
- package/dist/application/generation/generation-confirmation-service.d.ts +76 -0
- package/dist/application/generation/generation-confirmation-service.js +383 -0
- package/dist/application/generation/generation-finalizer.d.ts +67 -0
- package/dist/application/generation/generation-finalizer.js +646 -0
- package/dist/application/generation/generation-publisher.d.ts +46 -0
- package/dist/application/generation/generation-publisher.js +178 -0
- package/dist/application/generation/generation-starter.d.ts +31 -0
- package/dist/application/generation/generation-starter.js +102 -0
- package/dist/application/generation/generation-step-executor.d.ts +52 -0
- package/dist/application/generation/generation-step-executor.js +668 -0
- package/dist/application/generation/proposed-step-validator.d.ts +11 -0
- package/dist/application/generation/proposed-step-validator.js +88 -0
- package/dist/application/generation/risk-evaluator.d.ts +9 -0
- package/dist/application/generation/risk-evaluator.js +14 -0
- package/dist/application/generation/runtime-observer.d.ts +39 -0
- package/dist/application/generation/runtime-observer.js +157 -0
- package/dist/application/init/init-service.d.ts +17 -0
- package/dist/application/init/init-service.js +44 -0
- package/dist/application/interaction/action-executor.d.ts +25 -0
- package/dist/application/interaction/action-executor.js +103 -0
- package/dist/application/interaction/fallback-resolver.d.ts +24 -0
- package/dist/application/interaction/fallback-resolver.js +48 -0
- package/dist/application/interaction/scroll-to-executor.d.ts +40 -0
- package/dist/application/interaction/scroll-to-executor.js +191 -0
- package/dist/application/locator/locator-resolver.d.ts +20 -0
- package/dist/application/locator/locator-resolver.js +89 -0
- package/dist/application/project/project-describer.d.ts +14 -0
- package/dist/application/project/project-describer.js +8 -0
- package/dist/application/recorder/locator-selector.d.ts +10 -0
- package/dist/application/recorder/locator-selector.js +80 -0
- package/dist/application/recorder/recorder-service.d.ts +41 -0
- package/dist/application/recorder/recorder-service.js +331 -0
- package/dist/application/report/report-writer.d.ts +10 -0
- package/dist/application/report/report-writer.js +39 -0
- package/dist/application/runtime/activity-waiter.d.ts +33 -0
- package/dist/application/runtime/activity-waiter.js +73 -0
- package/dist/application/runtime/launch-failure.d.ts +2 -0
- package/dist/application/runtime/launch-failure.js +20 -0
- package/dist/application/runtime/process-waiter.d.ts +27 -0
- package/dist/application/runtime/process-waiter.js +73 -0
- package/dist/application/runtime/step-runner.d.ts +50 -0
- package/dist/application/runtime/step-runner.js +429 -0
- package/dist/application/runtime/verify-runtime.d.ts +44 -0
- package/dist/application/runtime/verify-runtime.js +350 -0
- package/dist/application/wait/idle-waiter.d.ts +30 -0
- package/dist/application/wait/idle-waiter.js +90 -0
- package/dist/cli/commands/context.d.ts +3 -0
- package/dist/cli/commands/context.js +94 -0
- package/dist/cli/commands/doctor.d.ts +3 -0
- package/dist/cli/commands/doctor.js +51 -0
- package/dist/cli/commands/generation.d.ts +3 -0
- package/dist/cli/commands/generation.js +518 -0
- package/dist/cli/commands/init.d.ts +3 -0
- package/dist/cli/commands/init.js +104 -0
- package/dist/cli/commands/project.d.ts +3 -0
- package/dist/cli/commands/project.js +58 -0
- package/dist/cli/commands/record.d.ts +3 -0
- package/dist/cli/commands/record.js +88 -0
- package/dist/cli/commands/verify.d.ts +3 -0
- package/dist/cli/commands/verify.js +106 -0
- package/dist/cli/dependencies.d.ts +66 -0
- package/dist/cli/dependencies.js +204 -0
- package/dist/cli/main.d.ts +8 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/output.d.ts +16 -0
- package/dist/cli/output.js +22 -0
- package/dist/cli/program.d.ts +3 -0
- package/dist/cli/program.js +43 -0
- package/dist/domain/activity.d.ts +7 -0
- package/dist/domain/activity.js +65 -0
- package/dist/domain/app-process.d.ts +7 -0
- package/dist/domain/app-process.js +10 -0
- package/dist/domain/config.d.ts +15 -0
- package/dist/domain/config.js +17 -0
- package/dist/domain/failure.d.ts +4 -0
- package/dist/domain/failure.js +43 -0
- package/dist/domain/generation.d.ts +912 -0
- package/dist/domain/generation.js +342 -0
- package/dist/domain/init.d.ts +42 -0
- package/dist/domain/init.js +114 -0
- package/dist/domain/journey.d.ts +673 -0
- package/dist/domain/journey.js +109 -0
- package/dist/domain/layout.d.ts +35 -0
- package/dist/domain/layout.js +33 -0
- package/dist/domain/locator.d.ts +8 -0
- package/dist/domain/locator.js +5 -0
- package/dist/domain/project-context.d.ts +101 -0
- package/dist/domain/project-context.js +64 -0
- package/dist/domain/proposed-step.d.ts +341 -0
- package/dist/domain/proposed-step.js +87 -0
- package/dist/domain/report.d.ts +349 -0
- package/dist/domain/report.js +135 -0
- package/dist/domain/runtime-snapshot.d.ts +16 -0
- package/dist/domain/runtime-snapshot.js +46 -0
- package/dist/ports/adb.d.ts +42 -0
- package/dist/ports/adb.js +1 -0
- package/dist/ports/android-cli.d.ts +21 -0
- package/dist/ports/android-cli.js +1 -0
- package/dist/ports/artifact-store.d.ts +12 -0
- package/dist/ports/artifact-store.js +1 -0
- package/dist/ports/clock.d.ts +4 -0
- package/dist/ports/clock.js +1 -0
- package/dist/ports/generation-meta-writer.d.ts +10 -0
- package/dist/ports/generation-meta-writer.js +1 -0
- package/dist/ports/generation-prompt.d.ts +19 -0
- package/dist/ports/generation-prompt.js +6 -0
- package/dist/ports/generation-session-store.d.ts +32 -0
- package/dist/ports/generation-session-store.js +26 -0
- package/dist/ports/init-prompt.d.ts +8 -0
- package/dist/ports/init-prompt.js +6 -0
- package/dist/ports/journey-writer.d.ts +10 -0
- package/dist/ports/journey-writer.js +1 -0
- package/dist/ports/process-runner.d.ts +32 -0
- package/dist/ports/process-runner.js +1 -0
- package/dist/ports/project-bound-file.d.ts +8 -0
- package/dist/ports/project-bound-file.js +1 -0
- package/dist/ports/project-file-inspector.d.ts +33 -0
- package/dist/ports/project-file-inspector.js +1 -0
- package/dist/ports/recorder-prompt.d.ts +29 -0
- package/dist/ports/recorder-prompt.js +1 -0
- package/dist/ports/skill-installer.d.ts +8 -0
- package/dist/ports/skill-installer.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Journey } from "../../domain/journey.js";
|
|
2
|
+
import type { ProjectBoundJourneyWriterPort } from "../../ports/journey-writer.js";
|
|
3
|
+
import type { ProjectBoundPath } from "../../ports/project-bound-file.js";
|
|
4
|
+
import { type ProjectBoundFileHooks } from "./project-bound-file.js";
|
|
5
|
+
export interface FileSystemJourneyWriterOptions {
|
|
6
|
+
beforeBoundInstall?: ProjectBoundFileHooks["beforeInstall"];
|
|
7
|
+
}
|
|
8
|
+
export declare class FileSystemJourneyWriter implements ProjectBoundJourneyWriterPort {
|
|
9
|
+
private readonly options;
|
|
10
|
+
constructor(options?: FileSystemJourneyWriterOptions);
|
|
11
|
+
write(outputPath: string, input: Journey): Promise<void>;
|
|
12
|
+
writeProjectBound(input: ProjectBoundPath & {
|
|
13
|
+
journey: Journey;
|
|
14
|
+
}): Promise<void>;
|
|
15
|
+
readProjectBound(input: ProjectBoundPath): Promise<Buffer>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { JourneySchema } from "../../domain/journey.js";
|
|
5
|
+
import { readProjectBoundFile, writeProjectBoundText } from "./project-bound-file.js";
|
|
6
|
+
export class FileSystemJourneyWriter {
|
|
7
|
+
options;
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
}
|
|
11
|
+
async write(outputPath, input) {
|
|
12
|
+
const journey = JourneySchema.parse(input);
|
|
13
|
+
const directory = dirname(outputPath);
|
|
14
|
+
const temporaryPath = join(directory, `.${basename(outputPath)}.${randomUUID()}.tmp`);
|
|
15
|
+
await mkdir(directory, { recursive: true });
|
|
16
|
+
try {
|
|
17
|
+
await writeFile(temporaryPath, `${JSON.stringify(journey, null, 2)}\n`, "utf8");
|
|
18
|
+
await rename(temporaryPath, outputPath);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
await rm(temporaryPath, { force: true });
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async writeProjectBound(input) {
|
|
26
|
+
const journey = JourneySchema.parse(input.journey);
|
|
27
|
+
await writeProjectBoundText(input, `${JSON.stringify(journey, null, 2)}\n`, this.options.beforeBoundInstall === undefined
|
|
28
|
+
? {}
|
|
29
|
+
: { beforeInstall: this.options.beforeBoundInstall });
|
|
30
|
+
}
|
|
31
|
+
async readProjectBound(input) {
|
|
32
|
+
return readProjectBoundFile(input);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ProjectBoundPath } from "../../ports/project-bound-file.js";
|
|
2
|
+
export interface ProjectBoundFileHooks {
|
|
3
|
+
beforeInstall?: (() => Promise<void> | void) | undefined;
|
|
4
|
+
}
|
|
5
|
+
export declare function writeProjectBoundText(input: ProjectBoundPath, value: string, hooks?: ProjectBoundFileHooks): Promise<void>;
|
|
6
|
+
export declare function readProjectBoundFile(input: ProjectBoundPath): Promise<Buffer>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { basename, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
function sameIdentity(left, right) {
|
|
5
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
6
|
+
}
|
|
7
|
+
function isNodeError(error) {
|
|
8
|
+
return error instanceof Error && "code" in error;
|
|
9
|
+
}
|
|
10
|
+
async function captureDirectory(path) {
|
|
11
|
+
const stats = await lstat(path, { bigint: true });
|
|
12
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
13
|
+
throw new Error(`Project-bound path is not a safe directory: ${path}`);
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
path,
|
|
17
|
+
canonicalPath: await realpath(path),
|
|
18
|
+
dev: stats.dev,
|
|
19
|
+
ino: stats.ino
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
async function captureFile(path) {
|
|
23
|
+
const stats = await lstat(path, { bigint: true });
|
|
24
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
25
|
+
throw new Error(`Project-bound destination is not a regular file: ${path}`);
|
|
26
|
+
}
|
|
27
|
+
return { dev: stats.dev, ino: stats.ino };
|
|
28
|
+
}
|
|
29
|
+
async function captureOptionalFile(path) {
|
|
30
|
+
try {
|
|
31
|
+
return await captureFile(path);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (isNodeError(error) && error.code === "ENOENT") {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function relativeInside(root, candidate) {
|
|
41
|
+
const fromRoot = relative(root, candidate);
|
|
42
|
+
if (fromRoot.length === 0
|
|
43
|
+
|| fromRoot === ".."
|
|
44
|
+
|| fromRoot.startsWith(`..${sep}`)
|
|
45
|
+
|| isAbsolute(fromRoot)) {
|
|
46
|
+
throw new Error("Project-bound output escapes the real project root");
|
|
47
|
+
}
|
|
48
|
+
return fromRoot;
|
|
49
|
+
}
|
|
50
|
+
function assertOutsideAuthority(authorityCanonicalPath, candidateCanonicalPath) {
|
|
51
|
+
const fromAuthority = relative(authorityCanonicalPath, candidateCanonicalPath);
|
|
52
|
+
if (fromAuthority.length === 0
|
|
53
|
+
|| (fromAuthority !== ".."
|
|
54
|
+
&& !fromAuthority.startsWith(`..${sep}`)
|
|
55
|
+
&& !isAbsolute(fromAuthority))) {
|
|
56
|
+
throw new Error("Project-bound output overlaps the authority subtree");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function verifyDirectory(expected) {
|
|
60
|
+
const current = await captureDirectory(expected.path);
|
|
61
|
+
if (current.canonicalPath !== expected.canonicalPath
|
|
62
|
+
|| !sameIdentity(current, expected)) {
|
|
63
|
+
throw new Error(`Project-bound directory identity changed: ${expected.path}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function verifyBoundOutput(bound) {
|
|
67
|
+
for (const directory of bound.directories) {
|
|
68
|
+
await verifyDirectory(directory);
|
|
69
|
+
}
|
|
70
|
+
await verifyDirectory(bound.authority);
|
|
71
|
+
relativeInside(bound.root.canonicalPath, bound.parent.canonicalPath);
|
|
72
|
+
assertOutsideAuthority(bound.authority.canonicalPath, bound.parent.canonicalPath);
|
|
73
|
+
}
|
|
74
|
+
async function verifyOptionalFile(path, expected) {
|
|
75
|
+
const current = await captureOptionalFile(path);
|
|
76
|
+
if ((current === null) !== (expected === null)
|
|
77
|
+
|| (current !== null
|
|
78
|
+
&& expected !== null
|
|
79
|
+
&& !sameIdentity(current, expected))) {
|
|
80
|
+
throw new Error(`Project-bound destination identity changed: ${path}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function syncDirectory(path) {
|
|
84
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
85
|
+
try {
|
|
86
|
+
await handle.sync();
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function lexicalSegments(root, output) {
|
|
93
|
+
const fromRoot = relative(root, output);
|
|
94
|
+
if (fromRoot.length === 0
|
|
95
|
+
|| fromRoot === ".."
|
|
96
|
+
|| fromRoot.startsWith(`..${sep}`)
|
|
97
|
+
|| isAbsolute(fromRoot)) {
|
|
98
|
+
throw new Error("Project-bound output must stay within the project");
|
|
99
|
+
}
|
|
100
|
+
const segments = fromRoot.split(sep);
|
|
101
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) {
|
|
102
|
+
throw new Error("Project-bound output path must be normalized");
|
|
103
|
+
}
|
|
104
|
+
return segments;
|
|
105
|
+
}
|
|
106
|
+
async function prepareBoundOutput(input, createParents) {
|
|
107
|
+
const projectPath = resolve(input.projectRoot);
|
|
108
|
+
const outputPath = resolve(input.outputPath);
|
|
109
|
+
const authorityPath = resolve(input.authorityRoot);
|
|
110
|
+
const segments = lexicalSegments(projectPath, outputPath);
|
|
111
|
+
const root = await captureDirectory(projectPath);
|
|
112
|
+
const authority = await captureDirectory(authorityPath);
|
|
113
|
+
relativeInside(root.canonicalPath, authority.canonicalPath);
|
|
114
|
+
const authorityFromRoot = relative(root.canonicalPath, authority.canonicalPath);
|
|
115
|
+
if (authorityFromRoot === ".."
|
|
116
|
+
|| authorityFromRoot.startsWith(`..${sep}`)
|
|
117
|
+
|| isAbsolute(authorityFromRoot)) {
|
|
118
|
+
throw new Error("Authority root escapes the real project root");
|
|
119
|
+
}
|
|
120
|
+
let current = projectPath;
|
|
121
|
+
const directories = [root];
|
|
122
|
+
for (const segment of segments.slice(0, -1)) {
|
|
123
|
+
const next = resolve(current, segment);
|
|
124
|
+
try {
|
|
125
|
+
const captured = await captureDirectory(next);
|
|
126
|
+
relativeInside(root.canonicalPath, captured.canonicalPath);
|
|
127
|
+
assertOutsideAuthority(authority.canonicalPath, captured.canonicalPath);
|
|
128
|
+
directories.push(captured);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (!createParents
|
|
132
|
+
|| !isNodeError(error)
|
|
133
|
+
|| error.code !== "ENOENT") {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
await verifyDirectory(directories.at(-1));
|
|
137
|
+
await mkdir(next);
|
|
138
|
+
await syncDirectory(current);
|
|
139
|
+
const captured = await captureDirectory(next);
|
|
140
|
+
relativeInside(root.canonicalPath, captured.canonicalPath);
|
|
141
|
+
assertOutsideAuthority(authority.canonicalPath, captured.canonicalPath);
|
|
142
|
+
directories.push(captured);
|
|
143
|
+
}
|
|
144
|
+
current = next;
|
|
145
|
+
}
|
|
146
|
+
const parent = directories.at(-1);
|
|
147
|
+
if (parent === undefined) {
|
|
148
|
+
throw new Error("Project-bound output parent is unavailable");
|
|
149
|
+
}
|
|
150
|
+
const bound = {
|
|
151
|
+
root,
|
|
152
|
+
authority,
|
|
153
|
+
parent,
|
|
154
|
+
directories,
|
|
155
|
+
outputPath
|
|
156
|
+
};
|
|
157
|
+
await verifyBoundOutput(bound);
|
|
158
|
+
return bound;
|
|
159
|
+
}
|
|
160
|
+
async function safeTemporaryCleanup(bound, temporaryPath, expected) {
|
|
161
|
+
if (expected === undefined) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await verifyBoundOutput(bound);
|
|
166
|
+
const current = await captureFile(temporaryPath);
|
|
167
|
+
if (sameIdentity(current, expected)) {
|
|
168
|
+
await unlink(temporaryPath);
|
|
169
|
+
await syncDirectory(bound.parent.path);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Never follow a substituted path to clean up.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
export async function writeProjectBoundText(input, value, hooks = {}) {
|
|
177
|
+
const bound = await prepareBoundOutput(input, true);
|
|
178
|
+
const originalDestination = await captureOptionalFile(bound.outputPath);
|
|
179
|
+
const temporaryPath = resolve(bound.parent.path, `.${basename(bound.outputPath)}.${randomUUID()}.tmp`);
|
|
180
|
+
let temporaryIdentity;
|
|
181
|
+
let handle;
|
|
182
|
+
try {
|
|
183
|
+
handle = await open(temporaryPath, constants.O_WRONLY
|
|
184
|
+
| constants.O_CREAT
|
|
185
|
+
| constants.O_EXCL
|
|
186
|
+
| constants.O_NOFOLLOW, 0o600);
|
|
187
|
+
await handle.writeFile(value, "utf8");
|
|
188
|
+
await handle.sync();
|
|
189
|
+
const stats = await handle.stat({ bigint: true });
|
|
190
|
+
temporaryIdentity = { dev: stats.dev, ino: stats.ino };
|
|
191
|
+
await handle.close();
|
|
192
|
+
handle = undefined;
|
|
193
|
+
await hooks.beforeInstall?.();
|
|
194
|
+
await verifyBoundOutput(bound);
|
|
195
|
+
await verifyOptionalFile(bound.outputPath, originalDestination);
|
|
196
|
+
const stagedIdentity = await captureFile(temporaryPath);
|
|
197
|
+
if (!sameIdentity(stagedIdentity, temporaryIdentity)) {
|
|
198
|
+
throw new Error("Project-bound temporary file identity changed");
|
|
199
|
+
}
|
|
200
|
+
await rename(temporaryPath, bound.outputPath);
|
|
201
|
+
const installedIdentity = await captureFile(bound.outputPath);
|
|
202
|
+
if (!sameIdentity(installedIdentity, temporaryIdentity)) {
|
|
203
|
+
throw new Error("Project-bound installed file identity changed");
|
|
204
|
+
}
|
|
205
|
+
await syncDirectory(bound.parent.path);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (handle !== undefined) {
|
|
209
|
+
await handle.close().catch(() => undefined);
|
|
210
|
+
}
|
|
211
|
+
await safeTemporaryCleanup(bound, temporaryPath, temporaryIdentity);
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
export async function readProjectBoundFile(input) {
|
|
216
|
+
const bound = await prepareBoundOutput(input, false);
|
|
217
|
+
const handle = await open(bound.outputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
218
|
+
try {
|
|
219
|
+
const stats = await handle.stat({ bigint: true });
|
|
220
|
+
if (!stats.isFile()) {
|
|
221
|
+
throw new Error("Project-bound output is not a regular file");
|
|
222
|
+
}
|
|
223
|
+
const openedIdentity = { dev: stats.dev, ino: stats.ino };
|
|
224
|
+
await verifyBoundOutput(bound);
|
|
225
|
+
const pathIdentity = await captureFile(bound.outputPath);
|
|
226
|
+
if (!sameIdentity(openedIdentity, pathIdentity)) {
|
|
227
|
+
throw new Error("Project-bound output identity changed");
|
|
228
|
+
}
|
|
229
|
+
const bytes = await handle.readFile();
|
|
230
|
+
await verifyBoundOutput(bound);
|
|
231
|
+
const finalIdentity = await captureFile(bound.outputPath);
|
|
232
|
+
if (!sameIdentity(openedIdentity, finalIdentity)) {
|
|
233
|
+
throw new Error("Project-bound output identity changed during read");
|
|
234
|
+
}
|
|
235
|
+
return bytes;
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await handle.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type ProjectFileInspection, type ProjectFileInspector } from "../../ports/project-file-inspector.js";
|
|
2
|
+
export declare class NodeProjectFileInspector implements ProjectFileInspector {
|
|
3
|
+
readonly inspectProjectFile: (input: {
|
|
4
|
+
projectRoot: string;
|
|
5
|
+
relativePath: string;
|
|
6
|
+
maximumBytes: number;
|
|
7
|
+
}) => Promise<ProjectFileInspection>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
+
function errorCode(error) {
|
|
6
|
+
return (typeof error === "object"
|
|
7
|
+
&& error !== null
|
|
8
|
+
&& "code" in error
|
|
9
|
+
&& typeof error.code === "string")
|
|
10
|
+
? error.code
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
function isContained(root, path) {
|
|
14
|
+
const fromRoot = relative(root, path);
|
|
15
|
+
return (fromRoot === ""
|
|
16
|
+
|| (fromRoot !== ".."
|
|
17
|
+
&& !fromRoot.startsWith(`..${sep}`)
|
|
18
|
+
&& !isAbsolute(fromRoot)));
|
|
19
|
+
}
|
|
20
|
+
function sameIdentity(left, right) {
|
|
21
|
+
return (left.dev === right.dev
|
|
22
|
+
&& left.ino === right.ino
|
|
23
|
+
&& left.size === right.size
|
|
24
|
+
&& left.mtimeNs === right.mtimeNs
|
|
25
|
+
&& left.ctimeNs === right.ctimeNs);
|
|
26
|
+
}
|
|
27
|
+
async function fileIdentity(handle) {
|
|
28
|
+
const fileStats = await handle.stat({ bigint: true });
|
|
29
|
+
return {
|
|
30
|
+
dev: fileStats.dev,
|
|
31
|
+
ino: fileStats.ino,
|
|
32
|
+
size: fileStats.size,
|
|
33
|
+
mtimeNs: fileStats.mtimeNs,
|
|
34
|
+
ctimeNs: fileStats.ctimeNs
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function pathIdentity(path) {
|
|
38
|
+
const fileStats = await stat(path, { bigint: true });
|
|
39
|
+
return {
|
|
40
|
+
dev: fileStats.dev,
|
|
41
|
+
ino: fileStats.ino,
|
|
42
|
+
size: fileStats.size,
|
|
43
|
+
mtimeNs: fileStats.mtimeNs,
|
|
44
|
+
ctimeNs: fileStats.ctimeNs
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function normalizedRelativePath(root, path) {
|
|
48
|
+
return relative(root, path).replaceAll("\\", "/");
|
|
49
|
+
}
|
|
50
|
+
function evidenceFailure(error) {
|
|
51
|
+
return errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR"
|
|
52
|
+
? { status: "notFound" }
|
|
53
|
+
: { status: "unreadable" };
|
|
54
|
+
}
|
|
55
|
+
export class NodeProjectFileInspector {
|
|
56
|
+
inspectProjectFile = async (input) => {
|
|
57
|
+
let canonicalRoot;
|
|
58
|
+
try {
|
|
59
|
+
canonicalRoot = await realpath(input.projectRoot);
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
return errorCode(error) === "ENOENT" || errorCode(error) === "ENOTDIR"
|
|
63
|
+
? { status: "rootNotFound" }
|
|
64
|
+
: { status: "rootUnreadable" };
|
|
65
|
+
}
|
|
66
|
+
let rootBefore;
|
|
67
|
+
try {
|
|
68
|
+
const rootStats = await stat(canonicalRoot, { bigint: true });
|
|
69
|
+
if (!rootStats.isDirectory()) {
|
|
70
|
+
return { status: "rootNotDirectory" };
|
|
71
|
+
}
|
|
72
|
+
rootBefore = {
|
|
73
|
+
dev: rootStats.dev,
|
|
74
|
+
ino: rootStats.ino,
|
|
75
|
+
size: rootStats.size,
|
|
76
|
+
mtimeNs: rootStats.mtimeNs,
|
|
77
|
+
ctimeNs: rootStats.ctimeNs
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
return errorCode(error) === "ENOENT"
|
|
82
|
+
? { status: "rootNotFound" }
|
|
83
|
+
: { status: "rootUnreadable" };
|
|
84
|
+
}
|
|
85
|
+
const normalizedPath = input.relativePath.replaceAll("\\", "/");
|
|
86
|
+
if (normalizedPath.startsWith("/")
|
|
87
|
+
|| /^[A-Za-z]:/.test(normalizedPath)
|
|
88
|
+
|| normalizedPath.split("/").includes("..")) {
|
|
89
|
+
return { status: "escape" };
|
|
90
|
+
}
|
|
91
|
+
const candidate = resolve(canonicalRoot, normalizedPath);
|
|
92
|
+
if (!isContained(canonicalRoot, candidate)) {
|
|
93
|
+
return { status: "escape" };
|
|
94
|
+
}
|
|
95
|
+
let canonicalBefore;
|
|
96
|
+
let pathBefore;
|
|
97
|
+
try {
|
|
98
|
+
canonicalBefore = await realpath(candidate);
|
|
99
|
+
if (!isContained(canonicalRoot, canonicalBefore)) {
|
|
100
|
+
return { status: "escape" };
|
|
101
|
+
}
|
|
102
|
+
pathBefore = await pathIdentity(canonicalBefore);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return evidenceFailure(error);
|
|
106
|
+
}
|
|
107
|
+
const resolvedRelativePath = normalizedRelativePath(canonicalRoot, canonicalBefore);
|
|
108
|
+
const openFlags = process.platform === "win32"
|
|
109
|
+
? constants.O_RDONLY
|
|
110
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
111
|
+
let handle;
|
|
112
|
+
try {
|
|
113
|
+
handle = await open(canonicalBefore, openFlags);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
return errorCode(error) === "ELOOP"
|
|
117
|
+
? { status: "changedIdentity", resolvedRelativePath }
|
|
118
|
+
: evidenceFailure(error);
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const openedBefore = await fileIdentity(handle);
|
|
122
|
+
if (!sameIdentity(pathBefore, openedBefore)) {
|
|
123
|
+
return { status: "changedIdentity", resolvedRelativePath };
|
|
124
|
+
}
|
|
125
|
+
const openedStats = await handle.stat({ bigint: true });
|
|
126
|
+
if (!openedStats.isFile()) {
|
|
127
|
+
return { status: "notFile", resolvedRelativePath };
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const canonicalOpened = await realpath(candidate);
|
|
131
|
+
const rootOpened = await realpath(input.projectRoot);
|
|
132
|
+
const pathOpened = await pathIdentity(canonicalOpened);
|
|
133
|
+
if (canonicalOpened !== canonicalBefore
|
|
134
|
+
|| rootOpened !== canonicalRoot
|
|
135
|
+
|| !isContained(canonicalRoot, canonicalOpened)
|
|
136
|
+
|| !sameIdentity(openedBefore, pathOpened)) {
|
|
137
|
+
return { status: "changedIdentity", resolvedRelativePath };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return { status: "changedIdentity", resolvedRelativePath };
|
|
142
|
+
}
|
|
143
|
+
const hash = createHash("sha256");
|
|
144
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
145
|
+
let totalBytes = 0;
|
|
146
|
+
let tooLarge = false;
|
|
147
|
+
while (totalBytes <= input.maximumBytes) {
|
|
148
|
+
const remaining = input.maximumBytes + 1 - totalBytes;
|
|
149
|
+
const bytesToRead = Math.min(buffer.byteLength, remaining);
|
|
150
|
+
const readResult = await handle.read(buffer, 0, bytesToRead, null);
|
|
151
|
+
if (readResult.bytesRead === 0) {
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
totalBytes += readResult.bytesRead;
|
|
155
|
+
hash.update(buffer.subarray(0, readResult.bytesRead));
|
|
156
|
+
if (totalBytes > input.maximumBytes) {
|
|
157
|
+
tooLarge = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const openedAfter = await fileIdentity(handle);
|
|
162
|
+
let canonicalAfter;
|
|
163
|
+
let rootCanonicalAfter;
|
|
164
|
+
let pathAfter;
|
|
165
|
+
let rootAfter;
|
|
166
|
+
try {
|
|
167
|
+
canonicalAfter = await realpath(candidate);
|
|
168
|
+
rootCanonicalAfter = await realpath(input.projectRoot);
|
|
169
|
+
pathAfter = await pathIdentity(canonicalAfter);
|
|
170
|
+
rootAfter = await pathIdentity(canonicalRoot);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return { status: "changedIdentity", resolvedRelativePath };
|
|
174
|
+
}
|
|
175
|
+
if (canonicalAfter !== canonicalBefore
|
|
176
|
+
|| rootCanonicalAfter !== canonicalRoot
|
|
177
|
+
|| !isContained(canonicalRoot, canonicalAfter)
|
|
178
|
+
|| !sameIdentity(openedBefore, openedAfter)
|
|
179
|
+
|| !sameIdentity(openedAfter, pathAfter)
|
|
180
|
+
|| !sameIdentity(rootBefore, rootAfter)) {
|
|
181
|
+
return { status: "changedIdentity", resolvedRelativePath };
|
|
182
|
+
}
|
|
183
|
+
return tooLarge
|
|
184
|
+
? { status: "tooLarge", resolvedRelativePath }
|
|
185
|
+
: {
|
|
186
|
+
status: "inspected",
|
|
187
|
+
resolvedRelativePath,
|
|
188
|
+
sha256: hash.digest("hex")
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return { status: "unreadable" };
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
await handle.close().catch(() => undefined);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SkillInstallResult, SkillInstallerPort } from "../../ports/skill-installer.js";
|
|
2
|
+
export declare class SkillPayloadMissingError extends Error {
|
|
3
|
+
readonly payloadPath: string;
|
|
4
|
+
readonly name = "SkillPayloadMissingError";
|
|
5
|
+
constructor(payloadPath: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class FileSystemSkillInstaller implements SkillInstallerPort {
|
|
8
|
+
findPayload(): Promise<string>;
|
|
9
|
+
installTo(targetDir: string): Promise<SkillInstallResult>;
|
|
10
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { access, cp, mkdir } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { SKILL_DIRECTORY_NAME } from "../../domain/init.js";
|
|
6
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
|
|
7
|
+
const PAYLOAD_PATH = resolve(PACKAGE_ROOT, "assets", "skills", SKILL_DIRECTORY_NAME);
|
|
8
|
+
export class SkillPayloadMissingError extends Error {
|
|
9
|
+
payloadPath;
|
|
10
|
+
name = "SkillPayloadMissingError";
|
|
11
|
+
constructor(payloadPath) {
|
|
12
|
+
super(`Skill payload not found at ${payloadPath}`);
|
|
13
|
+
this.payloadPath = payloadPath;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export class FileSystemSkillInstaller {
|
|
17
|
+
async findPayload() {
|
|
18
|
+
try {
|
|
19
|
+
await access(PAYLOAD_PATH, constants.R_OK);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new SkillPayloadMissingError(PAYLOAD_PATH);
|
|
23
|
+
}
|
|
24
|
+
return PAYLOAD_PATH;
|
|
25
|
+
}
|
|
26
|
+
async installTo(targetDir) {
|
|
27
|
+
const payloadPath = await this.findPayload();
|
|
28
|
+
const resolvedTarget = resolve(targetDir);
|
|
29
|
+
if (resolvedTarget === resolve(payloadPath)) {
|
|
30
|
+
return { targetPath: resolvedTarget, skipped: true };
|
|
31
|
+
}
|
|
32
|
+
await mkdir(dirname(resolvedTarget), { recursive: true });
|
|
33
|
+
await cp(payloadPath, resolvedTarget, {
|
|
34
|
+
recursive: true,
|
|
35
|
+
force: true
|
|
36
|
+
});
|
|
37
|
+
return { targetPath: resolvedTarget, skipped: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { CommandResult, CommandSpec, ProcessRunner, RunningCommand, StreamHandlers } from "../../ports/process-runner.js";
|
|
2
|
+
export declare class NodeProcessRunner implements ProcessRunner {
|
|
3
|
+
private readonly defaultTimeoutMs;
|
|
4
|
+
private readonly streamStartupGraceMs;
|
|
5
|
+
constructor(defaultTimeoutMs?: number, streamStartupGraceMs?: number);
|
|
6
|
+
run(spec: CommandSpec): Promise<CommandResult>;
|
|
7
|
+
start(spec: CommandSpec, handlers?: StreamHandlers): RunningCommand;
|
|
8
|
+
}
|