@hasna/hooks 0.2.20 → 0.3.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/README.md +46 -2
- package/bin/index.js +1205 -93
- package/dist/db/migrations/002_session_events.d.ts +10 -0
- package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/remote-storage.d.ts +9 -0
- package/dist/db/schema.d.ts +4 -3
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +1092 -67
- package/dist/lib/installer.d.ts +26 -5
- package/dist/lib/registry.d.ts +5 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +696 -0
- package/hooks/codewith-native-common.test.ts +61 -0
- package/hooks/codewith-native-common.ts +478 -0
- package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
- package/hooks/hook-agent-rules-version-check/README.md +50 -0
- package/hooks/hook-agent-rules-version-check/package.json +53 -0
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
- package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
- package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
- package/hooks/hook-announce-start/README.md +7 -5
- package/hooks/hook-announce-start/package.json +2 -2
- package/hooks/hook-announce-start/src/hook.ts +33 -14
- package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
- package/hooks/hook-fleet-blockers-gate/README.md +39 -0
- package/hooks/hook-fleet-blockers-gate/package.json +52 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
- package/hooks/hook-fleet-catchup/LICENSE +191 -0
- package/hooks/hook-fleet-catchup/README.md +40 -0
- package/hooks/hook-fleet-catchup/package.json +52 -0
- package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
- package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
- package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
- package/hooks/hook-knowledge-context/README.md +53 -0
- package/hooks/hook-knowledge-context/package.json +52 -0
- package/hooks/hook-knowledge-context/src/hook.test.ts +168 -0
- package/hooks/hook-knowledge-context/src/hook.ts +355 -0
- package/hooks/hook-knowledge-context/tsconfig.json +25 -0
- package/hooks/pre-bash/README.md +17 -0
- package/hooks/pre-bash/package.json +12 -0
- package/hooks/pre-bash/src/hook.ts +103 -0
- package/hooks/prompt-guard/README.md +17 -0
- package/hooks/prompt-guard/package.json +12 -0
- package/hooks/prompt-guard/src/hook.ts +76 -0
- package/hooks/session-start/README.md +17 -0
- package/hooks/session-start/package.json +12 -0
- package/hooks/session-start/src/hook.ts +150 -0
- package/hooks/stop-sync/README.md +17 -0
- package/hooks/stop-sync/package.json +12 -0
- package/hooks/stop-sync/src/hook.ts +69 -0
- package/hooks/worktree-guard/README.md +17 -0
- package/hooks/worktree-guard/package.json +12 -0
- package/hooks/worktree-guard/src/hook.ts +88 -0
- package/package.json +14 -6
- package/dist/cli/components/App.d.ts +0 -7
- package/dist/cli/components/CategorySelect.d.ts +0 -7
- package/dist/cli/components/DataTable.d.ts +0 -11
- package/dist/cli/components/Header.d.ts +0 -7
- package/dist/cli/components/HookSelect.d.ts +0 -11
- package/dist/cli/components/InstallProgress.d.ts +0 -9
- package/dist/cli/components/SearchView.d.ts +0 -9
- package/dist/cli/index.d.ts +0 -2
- package/dist/lib/db-writer.d.ts +0 -9
- package/dist/mcp/http.d.ts +0 -16
- package/dist/mcp/server.d.ts +0 -17
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { getAgentName, gitCommandInfo, managedWorktreeInfo } from "./codewith-native-common";
|
|
6
|
+
|
|
7
|
+
describe("codewith native common helpers", () => {
|
|
8
|
+
test("gitCommandInfo detects global option commit/push forms and target cwd", () => {
|
|
9
|
+
const base = mkdtempSync(join(tmpdir(), "hooks-common-"));
|
|
10
|
+
try {
|
|
11
|
+
expect(gitCommandInfo("git -c user.name=test commit -m test", base)?.action).toBe("commit");
|
|
12
|
+
expect(gitCommandInfo("git -C ./repo push origin feature", base)).toMatchObject({
|
|
13
|
+
action: "push",
|
|
14
|
+
targetCwd: join(base, "repo"),
|
|
15
|
+
});
|
|
16
|
+
expect(gitCommandInfo("git --git-dir .git --work-tree . commit", base)).toMatchObject({
|
|
17
|
+
action: "commit",
|
|
18
|
+
targetCwd: base,
|
|
19
|
+
});
|
|
20
|
+
} finally {
|
|
21
|
+
rmSync(base, { recursive: true, force: true });
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("managedWorktreeInfo rejects malformed fake managed-root paths", () => {
|
|
26
|
+
const tmp = mkdtempSync(join(tmpdir(), "hooks-worktrees-"));
|
|
27
|
+
const previous = process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
28
|
+
process.env.HASNA_REPOS_WORKTREES_ROOT = join(tmp, "worktrees");
|
|
29
|
+
try {
|
|
30
|
+
const valid = join(tmp, "worktrees", "station01", "open-hooks-a55c105a", "wt_2ab04216a30ef5ece642792e", "repo");
|
|
31
|
+
expect(managedWorktreeInfo(valid).managed).toBe(true);
|
|
32
|
+
expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-a55c105a", "wt_fake", "repo")).managed).toBe(false);
|
|
33
|
+
expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-nohash", "wt_2ab04216a30ef5ece642792e", "repo")).managed).toBe(false);
|
|
34
|
+
expect(managedWorktreeInfo(join(tmp, "worktrees", "station01", "open-hooks-zzzzzzzz", "wt_2ab04216a30ef5ece642792e", "repo")).managed).toBe(false);
|
|
35
|
+
} finally {
|
|
36
|
+
if (previous === undefined) delete process.env.HASNA_REPOS_WORKTREES_ROOT;
|
|
37
|
+
else process.env.HASNA_REPOS_WORKTREES_ROOT = previous;
|
|
38
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("getAgentName reads Codewith input.agent profile injection", () => {
|
|
43
|
+
const saved = {
|
|
44
|
+
HOOKS_AGENT_NAME: process.env.HOOKS_AGENT_NAME,
|
|
45
|
+
CODEWITH_AGENT_NAME: process.env.CODEWITH_AGENT_NAME,
|
|
46
|
+
CONVERSATIONS_AGENT_ID: process.env.CONVERSATIONS_AGENT_ID,
|
|
47
|
+
};
|
|
48
|
+
delete process.env.HOOKS_AGENT_NAME;
|
|
49
|
+
delete process.env.CODEWITH_AGENT_NAME;
|
|
50
|
+
delete process.env.CONVERSATIONS_AGENT_ID;
|
|
51
|
+
try {
|
|
52
|
+
expect(getAgentName({ agent: { name: "profile-agent" } })).toBe("profile-agent");
|
|
53
|
+
expect(getAgentName({ agent: { agent_id: "profile_id" } })).toBe("profile_id");
|
|
54
|
+
} finally {
|
|
55
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
56
|
+
if (value === undefined) delete process.env[key];
|
|
57
|
+
else process.env[key] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "path";
|
|
3
|
+
import { homedir, tmpdir } from "os";
|
|
4
|
+
|
|
5
|
+
export interface CodewithHookInput {
|
|
6
|
+
session_id?: string;
|
|
7
|
+
cwd?: string;
|
|
8
|
+
hook_event_name?: string;
|
|
9
|
+
model?: string;
|
|
10
|
+
permission_mode?: string;
|
|
11
|
+
source?: string;
|
|
12
|
+
prompt?: string;
|
|
13
|
+
tool_name?: string;
|
|
14
|
+
tool_input?: Record<string, unknown>;
|
|
15
|
+
tool_use_id?: string;
|
|
16
|
+
transcript_path?: string | null;
|
|
17
|
+
turn_id?: string;
|
|
18
|
+
last_assistant_message?: string | null;
|
|
19
|
+
stop_hook_active?: boolean;
|
|
20
|
+
agent_id?: string;
|
|
21
|
+
agent_type?: string;
|
|
22
|
+
agent?: unknown;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CodewithHookOutput {
|
|
27
|
+
continue?: boolean;
|
|
28
|
+
decision?: "approve" | "block";
|
|
29
|
+
reason?: string;
|
|
30
|
+
stopReason?: string;
|
|
31
|
+
suppressOutput?: boolean;
|
|
32
|
+
systemMessage?: string;
|
|
33
|
+
hookSpecificOutput?: {
|
|
34
|
+
hookEventName: "SessionStart" | "UserPromptSubmit" | "SubagentStart" | "PreToolUse";
|
|
35
|
+
additionalContext?: string;
|
|
36
|
+
permissionDecision?: "allow" | "deny" | "ask";
|
|
37
|
+
permissionDecisionReason?: string;
|
|
38
|
+
updatedInput?: unknown;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CommandResult {
|
|
43
|
+
exitCode: number | null;
|
|
44
|
+
stdout: string;
|
|
45
|
+
stderr: string;
|
|
46
|
+
timedOut: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function readInput(): CodewithHookInput {
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(0, "utf-8").trim();
|
|
52
|
+
if (!raw) return {};
|
|
53
|
+
return JSON.parse(raw) as CodewithHookInput;
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function respond(output: CodewithHookOutput): void {
|
|
60
|
+
process.stdout.write(`${JSON.stringify(output)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function warn(message: string): void {
|
|
64
|
+
process.stderr.write(`[hooks] ${message}\n`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cap(text: string, max = 6000): string {
|
|
68
|
+
if (text.length <= max) return text;
|
|
69
|
+
return `${text.slice(0, max)}\n[truncated ${text.length - max} bytes]`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function commandExists(command: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
|
73
|
+
const pathValue = env.PATH || "";
|
|
74
|
+
for (const dir of pathValue.split(":")) {
|
|
75
|
+
if (!dir) continue;
|
|
76
|
+
if (existsSync(join(dir, command))) return true;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function runCommand(
|
|
82
|
+
argv: string[],
|
|
83
|
+
options: { cwd?: string; timeoutMs?: number; env?: NodeJS.ProcessEnv } = {}
|
|
84
|
+
): Promise<CommandResult> {
|
|
85
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
86
|
+
const env = options.env ?? process.env;
|
|
87
|
+
let proc: ReturnType<typeof Bun.spawn> | null = null;
|
|
88
|
+
let timedOut = false;
|
|
89
|
+
try {
|
|
90
|
+
proc = Bun.spawn(argv, {
|
|
91
|
+
cwd: options.cwd,
|
|
92
|
+
env,
|
|
93
|
+
stdin: "ignore",
|
|
94
|
+
stdout: "pipe",
|
|
95
|
+
stderr: "pipe",
|
|
96
|
+
});
|
|
97
|
+
const timer = setTimeout(() => {
|
|
98
|
+
timedOut = true;
|
|
99
|
+
try { proc?.kill(); } catch {}
|
|
100
|
+
}, timeoutMs);
|
|
101
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
102
|
+
new Response(proc.stdout).text(),
|
|
103
|
+
new Response(proc.stderr).text(),
|
|
104
|
+
proc.exited.catch(() => null),
|
|
105
|
+
]);
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
return { exitCode, stdout, stderr, timedOut };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
return { exitCode: null, stdout: "", stderr: error instanceof Error ? error.message : String(error), timedOut };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getCommand(input: CodewithHookInput): string {
|
|
114
|
+
const command = input.tool_input?.command;
|
|
115
|
+
return typeof command === "string" ? command : "";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isBashPreToolUse(input: CodewithHookInput): boolean {
|
|
119
|
+
return input.hook_event_name === "PreToolUse" && input.tool_name === "Bash";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface GitCommandInfo {
|
|
123
|
+
action: "commit" | "push";
|
|
124
|
+
targetCwd: string;
|
|
125
|
+
gitDir?: string;
|
|
126
|
+
workTree?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function splitShellSegments(command: string): string[] {
|
|
130
|
+
const segments: string[] = [];
|
|
131
|
+
let current = "";
|
|
132
|
+
let quote: "'" | '"' | null = null;
|
|
133
|
+
let escaped = false;
|
|
134
|
+
|
|
135
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
136
|
+
const ch = command[i];
|
|
137
|
+
if (escaped) {
|
|
138
|
+
current += ch;
|
|
139
|
+
escaped = false;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (ch === "\\" && quote !== "'") {
|
|
143
|
+
escaped = true;
|
|
144
|
+
current += ch;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (quote) {
|
|
148
|
+
current += ch;
|
|
149
|
+
if (ch === quote) quote = null;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (ch === "'" || ch === "\"") {
|
|
153
|
+
quote = ch;
|
|
154
|
+
current += ch;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")" || ch === "\n") {
|
|
158
|
+
if (current.trim()) segments.push(current.trim());
|
|
159
|
+
current = "";
|
|
160
|
+
if ((ch === "|" || ch === "&") && command[i + 1] === ch) i += 1;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
current += ch;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (current.trim()) segments.push(current.trim());
|
|
167
|
+
return segments;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function shellWords(segment: string): string[] {
|
|
171
|
+
const words: string[] = [];
|
|
172
|
+
let current = "";
|
|
173
|
+
let quote: "'" | '"' | null = null;
|
|
174
|
+
let escaped = false;
|
|
175
|
+
|
|
176
|
+
const push = () => {
|
|
177
|
+
if (current.length > 0) {
|
|
178
|
+
words.push(current);
|
|
179
|
+
current = "";
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
for (let i = 0; i < segment.length; i += 1) {
|
|
184
|
+
const ch = segment[i];
|
|
185
|
+
if (escaped) {
|
|
186
|
+
current += ch;
|
|
187
|
+
escaped = false;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (ch === "\\" && quote !== "'") {
|
|
191
|
+
escaped = true;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (quote) {
|
|
195
|
+
if (ch === quote) {
|
|
196
|
+
quote = null;
|
|
197
|
+
} else {
|
|
198
|
+
current += ch;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "'" || ch === "\"") {
|
|
203
|
+
quote = ch;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (/\s/.test(ch)) {
|
|
207
|
+
push();
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
current += ch;
|
|
211
|
+
}
|
|
212
|
+
push();
|
|
213
|
+
return words;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function expandHome(path: string): string {
|
|
217
|
+
if (path === "~") return homedir();
|
|
218
|
+
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
|
219
|
+
return path;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveFrom(cwd: string, path: string): string {
|
|
223
|
+
const expanded = expandHome(path);
|
|
224
|
+
return isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function optionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null {
|
|
228
|
+
if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 };
|
|
229
|
+
if (token.startsWith(`${option}=`)) return { value: token.slice(option.length + 1), consumed: 1 };
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function shortOptionValue(token: string, next: string | undefined, option: string): { value?: string; consumed: number } | null {
|
|
234
|
+
if (token === option) return { value: next, consumed: next === undefined ? 1 : 2 };
|
|
235
|
+
if (token.startsWith(option) && token.length > option.length) return { value: token.slice(option.length), consumed: 1 };
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isGitToken(token: string): boolean {
|
|
240
|
+
return token === "git" || token.endsWith("/git");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function gitInfoFromTokens(tokens: string[], baseCwd: string): GitCommandInfo | null {
|
|
244
|
+
const gitIndex = tokens.findIndex(isGitToken);
|
|
245
|
+
if (gitIndex === -1) return null;
|
|
246
|
+
|
|
247
|
+
let i = gitIndex + 1;
|
|
248
|
+
let cwd = resolve(baseCwd);
|
|
249
|
+
let gitDir: string | undefined;
|
|
250
|
+
let workTree: string | undefined;
|
|
251
|
+
|
|
252
|
+
while (i < tokens.length) {
|
|
253
|
+
const token = tokens[i];
|
|
254
|
+
|
|
255
|
+
const cDir = shortOptionValue(token, tokens[i + 1], "-C");
|
|
256
|
+
if (cDir) {
|
|
257
|
+
if (cDir.value) cwd = resolveFrom(cwd, cDir.value);
|
|
258
|
+
i += cDir.consumed;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const config = shortOptionValue(token, tokens[i + 1], "-c");
|
|
263
|
+
if (config) {
|
|
264
|
+
i += config.consumed;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const gitDirValue = optionValue(token, tokens[i + 1], "--git-dir");
|
|
269
|
+
if (gitDirValue) {
|
|
270
|
+
if (gitDirValue.value) gitDir = resolveFrom(cwd, gitDirValue.value);
|
|
271
|
+
i += gitDirValue.consumed;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const workTreeValue = optionValue(token, tokens[i + 1], "--work-tree");
|
|
276
|
+
if (workTreeValue) {
|
|
277
|
+
if (workTreeValue.value) workTree = resolveFrom(cwd, workTreeValue.value);
|
|
278
|
+
i += workTreeValue.consumed;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const namespaceValue = optionValue(token, tokens[i + 1], "--namespace");
|
|
283
|
+
if (namespaceValue) {
|
|
284
|
+
i += namespaceValue.consumed;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const execPathValue = optionValue(token, tokens[i + 1], "--exec-path");
|
|
289
|
+
if (execPathValue) {
|
|
290
|
+
i += execPathValue.consumed;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (token === "--config-env") {
|
|
295
|
+
i += tokens[i + 1] === undefined ? 1 : 2;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (token === "--") {
|
|
300
|
+
i += 1;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (token.startsWith("-")) {
|
|
305
|
+
i += 1;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (token === "commit" || token === "push") {
|
|
310
|
+
const targetCwd = workTree || (gitDir ? (gitDir.endsWith(`${sep}.git`) || gitDir.endsWith("/.git") ? dirname(gitDir) : gitDir) : cwd);
|
|
311
|
+
return { action: token, targetCwd, ...(gitDir ? { gitDir } : {}), ...(workTree ? { workTree } : {}) };
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function gitCommandInfo(command: string, baseCwd: string = process.cwd()): GitCommandInfo | null {
|
|
320
|
+
for (const segment of splitShellSegments(command)) {
|
|
321
|
+
const tokens = shellWords(segment);
|
|
322
|
+
const info = gitInfoFromTokens(tokens, baseCwd);
|
|
323
|
+
if (info) return info;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export function isGitCommitOrPush(command: string): boolean {
|
|
329
|
+
return gitCommandInfo(command) !== null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function isGitPushOrCommitCommand(command: string): "commit" | "push" | null {
|
|
333
|
+
return gitCommandInfo(command)?.action || null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function isRiskyOperation(command: string): boolean {
|
|
337
|
+
const patterns = [
|
|
338
|
+
/(^|[;&|()\s])(?:npm|pnpm|yarn|bun)\s+publish\b/,
|
|
339
|
+
/(^|[;&|()\s])gh\s+release\b/,
|
|
340
|
+
/(^|[;&|()\s])terraform\s+(?:apply|destroy|import)\b/,
|
|
341
|
+
/(^|[;&|()\s])tofu\s+(?:apply|destroy|import)\b/,
|
|
342
|
+
/(^|[;&|()\s])kubectl\s+(?:apply|delete|rollout|scale)\b/,
|
|
343
|
+
/(^|[;&|()\s])aws\s+[^;&|]*\bdeploy\b/,
|
|
344
|
+
/(^|[;&|()\s])(?:drizzle|prisma|sequelize|knex)\s+[^;&|]*\bmigrat(?:e|ion)\b/,
|
|
345
|
+
/(^|[;&|()\s])(?:migrate|migration)\b/,
|
|
346
|
+
/\bdeploy(?:ment)?\b/,
|
|
347
|
+
];
|
|
348
|
+
return patterns.some((pattern) => pattern.test(command));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function cacheDir(): string {
|
|
352
|
+
return process.env.HASNA_HOOKS_CACHE_DIR || join(tmpdir(), "hasna-hooks-codewith");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function cachePath(name: string): string {
|
|
356
|
+
return join(cacheDir(), `${name.replace(/[^a-zA-Z0-9_.-]/g, "-")}.json`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function readCache<T>(name: string, ttlMs: number): T | null {
|
|
360
|
+
try {
|
|
361
|
+
const path = cachePath(name);
|
|
362
|
+
if (!existsSync(path)) return null;
|
|
363
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as { ts: number; value: T };
|
|
364
|
+
if (!parsed.ts || Date.now() - parsed.ts > ttlMs) return null;
|
|
365
|
+
return parsed.value;
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function writeCache<T>(name: string, value: T): void {
|
|
372
|
+
try {
|
|
373
|
+
const path = cachePath(name);
|
|
374
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
375
|
+
writeFileSync(path, JSON.stringify({ ts: Date.now(), value }, null, 2));
|
|
376
|
+
} catch {}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function safeJsonSummary(raw: string): string {
|
|
380
|
+
return cap(raw.replace(/((?:secret|token|password|api[_-]?key)\s*[:=]\s*)["']?[^"'\s,}]+/gi, "$1<redacted>"), 6000);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function getAgentName(input: CodewithHookInput): string | null {
|
|
384
|
+
const agent = input.agent && typeof input.agent === "object" ? input.agent as Record<string, unknown> : null;
|
|
385
|
+
const candidates = [
|
|
386
|
+
process.env.HOOKS_AGENT_NAME,
|
|
387
|
+
process.env.CODEWITH_AGENT_NAME,
|
|
388
|
+
process.env.CONVERSATIONS_AGENT_ID,
|
|
389
|
+
typeof agent?.name === "string" ? agent.name : undefined,
|
|
390
|
+
typeof agent?.agent_id === "string" ? agent.agent_id : undefined,
|
|
391
|
+
typeof agent?.id === "string" ? agent.id : undefined,
|
|
392
|
+
typeof input.agent_id === "string" ? input.agent_id : undefined,
|
|
393
|
+
];
|
|
394
|
+
for (const candidate of candidates) {
|
|
395
|
+
if (candidate && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(candidate)) return candidate;
|
|
396
|
+
}
|
|
397
|
+
return null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function isTopLevelSession(input: CodewithHookInput): boolean {
|
|
401
|
+
if (process.env.CODEWITH_SUBAGENT === "1" || process.env.HASNA_SUBAGENT === "1") return false;
|
|
402
|
+
if (typeof input.agent_type === "string" && /subagent/i.test(input.agent_type)) return false;
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function defaultWorktreesRoot(): string {
|
|
407
|
+
return process.env.HASNA_REPOS_WORKTREES_ROOT || join(homedir(), ".hasna", "repos", "worktrees");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function isInsidePath(child: string, parent: string): boolean {
|
|
411
|
+
const rel = relative(resolve(parent), resolve(child));
|
|
412
|
+
return rel === "" || (!!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function managedWorktreeInfo(cwd: string): { managed: boolean; root: string; reason?: string } {
|
|
416
|
+
const root = defaultWorktreesRoot();
|
|
417
|
+
if (!isInsidePath(cwd, root)) return { managed: false, root, reason: "outside worktrees root" };
|
|
418
|
+
const rel = relative(resolve(root), resolve(cwd));
|
|
419
|
+
const parts = rel.split(sep).filter(Boolean);
|
|
420
|
+
if (parts.length < 3) return { managed: false, root, reason: "path is inside worktrees root but not deep enough" };
|
|
421
|
+
const [machine, repoSlugHash, lease] = parts;
|
|
422
|
+
if (!machine || !repoSlugHash || !lease) return { managed: false, root, reason: "missing machine/repo/lease path segments" };
|
|
423
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{1,80}$/.test(machine)) return { managed: false, root, reason: "machine segment is malformed" };
|
|
424
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*-[0-9a-fA-F]{7,16}$/.test(repoSlugHash)) {
|
|
425
|
+
return { managed: false, root, reason: "repo segment must end in a hex hash suffix" };
|
|
426
|
+
}
|
|
427
|
+
if (!/^wt_[0-9a-fA-F]{16,64}$/.test(lease)) {
|
|
428
|
+
return { managed: false, root, reason: "lease segment must look like wt_<hex hash>" };
|
|
429
|
+
}
|
|
430
|
+
return { managed: true, root };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function gitRepoRoot(cwd: string): Promise<string | null> {
|
|
434
|
+
if (!commandExists("git")) return null;
|
|
435
|
+
const result = await runCommand(["git", "rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
436
|
+
if (result.exitCode !== 0) return null;
|
|
437
|
+
return result.stdout.trim() || null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function gitRemoteSlug(cwd: string): Promise<string | null> {
|
|
441
|
+
if (!commandExists("git")) return null;
|
|
442
|
+
const result = await runCommand(["git", "remote", "get-url", "origin"], { cwd, timeoutMs: 2000 });
|
|
443
|
+
if (result.exitCode !== 0) return null;
|
|
444
|
+
const remote = result.stdout.trim();
|
|
445
|
+
if (!remote) return null;
|
|
446
|
+
const match = remote.match(/[:/]([^/:\s]+\/[^/\s]+?)(?:\.git)?$/);
|
|
447
|
+
return match?.[1] || null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function claimCommand(repo: string | null, taskId: string | null, runId: string | null): string {
|
|
451
|
+
return `repos worktrees claim --repo ${repo || "<repo>"} --task-id ${taskId || "<task-id>"} --run-id ${runId || "<run-id>"} --base main --mode required --json`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function taskIdFrom(input: CodewithHookInput): string | null {
|
|
455
|
+
const candidates = [
|
|
456
|
+
process.env.HASNA_TASK_ID,
|
|
457
|
+
process.env.TASK_ID,
|
|
458
|
+
process.env.CODEWITH_TASK_ID,
|
|
459
|
+
typeof input.task_id === "string" ? input.task_id : undefined,
|
|
460
|
+
];
|
|
461
|
+
return candidates.find(Boolean) || null;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function runIdFrom(input: CodewithHookInput): string | null {
|
|
465
|
+
const candidates = [
|
|
466
|
+
process.env.HASNA_RUN_ID,
|
|
467
|
+
process.env.RUN_ID,
|
|
468
|
+
process.env.CODEWITH_RUN_ID,
|
|
469
|
+
typeof input.run_id === "string" ? input.run_id : undefined,
|
|
470
|
+
input.turn_id,
|
|
471
|
+
input.session_id,
|
|
472
|
+
];
|
|
473
|
+
return candidates.find((v): v is string => typeof v === "string" && v.length > 0) || null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function redactGitleaksOutput(_stdout: string, _stderr: string): string {
|
|
477
|
+
return "Staged secrets scan found possible credential(s). Details redacted; run gitleaks locally to inspect.";
|
|
478
|
+
}
|