@halyard/cli 0.1.2 → 0.2.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/README.md +88 -1
- package/dist/cli.js +27 -4
- package/dist/cli.js.map +1 -1
- package/dist/commands/api-keys.d.ts +3 -0
- package/dist/commands/api-keys.d.ts.map +1 -0
- package/dist/commands/api-keys.js +90 -0
- package/dist/commands/api-keys.js.map +1 -0
- package/dist/commands/hook.d.ts +6 -0
- package/dist/commands/hook.d.ts.map +1 -0
- package/dist/commands/hook.js +106 -0
- package/dist/commands/hook.js.map +1 -0
- package/dist/commands/login.d.ts +17 -0
- package/dist/commands/login.d.ts.map +1 -1
- package/dist/commands/login.js +111 -14
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/orgs.d.ts +3 -0
- package/dist/commands/orgs.d.ts.map +1 -0
- package/dist/commands/orgs.js +102 -0
- package/dist/commands/orgs.js.map +1 -0
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +29 -6
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/sessions.d.ts +3 -0
- package/dist/commands/sessions.d.ts.map +1 -0
- package/dist/commands/sessions.js +139 -0
- package/dist/commands/sessions.js.map +1 -0
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +28 -3
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/skills.d.ts +12 -0
- package/dist/commands/skills.d.ts.map +1 -0
- package/dist/commands/skills.js +171 -0
- package/dist/commands/skills.js.map +1 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +51 -49
- package/dist/commands/sync.js.map +1 -1
- package/dist/commands/users.d.ts +13 -0
- package/dist/commands/users.d.ts.map +1 -0
- package/dist/commands/users.js +205 -0
- package/dist/commands/users.js.map +1 -0
- package/dist/commands/whoami.d.ts +21 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +77 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/lib/api-client.d.ts +64 -2
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +32 -7
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/auth.d.ts +42 -6
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +101 -27
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/config-files.d.ts +6 -0
- package/dist/lib/config-files.d.ts.map +1 -0
- package/dist/lib/config-files.js +27 -0
- package/dist/lib/config-files.js.map +1 -0
- package/dist/lib/config.d.ts +13 -7
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +22 -8
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/copilot-export.d.ts +31 -0
- package/dist/lib/copilot-export.d.ts.map +1 -0
- package/dist/lib/copilot-export.js +129 -0
- package/dist/lib/copilot-export.js.map +1 -0
- package/dist/lib/discovery.d.ts +60 -2
- package/dist/lib/discovery.d.ts.map +1 -1
- package/dist/lib/discovery.js +187 -15
- package/dist/lib/discovery.js.map +1 -1
- package/dist/lib/hook-discovery.d.ts +43 -0
- package/dist/lib/hook-discovery.d.ts.map +1 -0
- package/dist/lib/hook-discovery.js +90 -0
- package/dist/lib/hook-discovery.js.map +1 -0
- package/dist/lib/hook-input.d.ts +36 -0
- package/dist/lib/hook-input.d.ts.map +1 -0
- package/dist/lib/hook-input.js +161 -0
- package/dist/lib/hook-input.js.map +1 -0
- package/dist/lib/hook-runner.d.ts +48 -0
- package/dist/lib/hook-runner.d.ts.map +1 -0
- package/dist/lib/hook-runner.js +306 -0
- package/dist/lib/hook-runner.js.map +1 -0
- package/dist/lib/http.d.ts +18 -0
- package/dist/lib/http.d.ts.map +1 -0
- package/dist/lib/http.js +54 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/opencode-export.d.ts +22 -0
- package/dist/lib/opencode-export.d.ts.map +1 -0
- package/dist/lib/opencode-export.js +53 -0
- package/dist/lib/opencode-export.js.map +1 -0
- package/dist/lib/output.d.ts +27 -0
- package/dist/lib/output.d.ts.map +1 -0
- package/dist/lib/output.js +74 -0
- package/dist/lib/output.js.map +1 -0
- package/dist/lib/preflight.d.ts +35 -0
- package/dist/lib/preflight.d.ts.map +1 -0
- package/dist/lib/preflight.js +54 -0
- package/dist/lib/preflight.js.map +1 -0
- package/dist/lib/repo-binding.d.ts +43 -0
- package/dist/lib/repo-binding.d.ts.map +1 -0
- package/dist/lib/repo-binding.js +158 -0
- package/dist/lib/repo-binding.js.map +1 -0
- package/dist/lib/repo-identity.d.ts +29 -0
- package/dist/lib/repo-identity.d.ts.map +1 -0
- package/dist/lib/repo-identity.js +113 -0
- package/dist/lib/repo-identity.js.map +1 -0
- package/dist/lib/setup-hooks.d.ts +41 -0
- package/dist/lib/setup-hooks.d.ts.map +1 -0
- package/dist/lib/setup-hooks.js +276 -0
- package/dist/lib/setup-hooks.js.map +1 -0
- package/dist/lib/setup.d.ts +4 -1
- package/dist/lib/setup.d.ts.map +1 -1
- package/dist/lib/setup.js +20 -31
- package/dist/lib/setup.js.map +1 -1
- package/dist/lib/skill-batches.d.ts +16 -0
- package/dist/lib/skill-batches.d.ts.map +1 -0
- package/dist/lib/skill-batches.js +35 -0
- package/dist/lib/skill-batches.js.map +1 -0
- package/dist/lib/skill-files.d.ts +4 -0
- package/dist/lib/skill-files.d.ts.map +1 -0
- package/dist/lib/skill-files.js +36 -0
- package/dist/lib/skill-files.js.map +1 -0
- package/dist/lib/skill-fingerprint.d.ts +55 -0
- package/dist/lib/skill-fingerprint.d.ts.map +1 -0
- package/dist/lib/skill-fingerprint.js +204 -0
- package/dist/lib/skill-fingerprint.js.map +1 -0
- package/dist/lib/skills-discovery.d.ts +57 -0
- package/dist/lib/skills-discovery.d.ts.map +1 -0
- package/dist/lib/skills-discovery.js +157 -0
- package/dist/lib/skills-discovery.js.map +1 -0
- package/dist/lib/skills-sync.d.ts +66 -0
- package/dist/lib/skills-sync.d.ts.map +1 -0
- package/dist/lib/skills-sync.js +251 -0
- package/dist/lib/skills-sync.js.map +1 -0
- package/dist/lib/sync-runner.d.ts +56 -0
- package/dist/lib/sync-runner.d.ts.map +1 -0
- package/dist/lib/sync-runner.js +106 -0
- package/dist/lib/sync-runner.js.map +1 -0
- package/dist/lib/sync-state.d.ts +36 -6
- package/dist/lib/sync-state.d.ts.map +1 -1
- package/dist/lib/sync-state.js +41 -11
- package/dist/lib/sync-state.js.map +1 -1
- package/package.json +10 -5
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdirSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, join } from 'node:path';
|
|
3
|
+
import { isDirectory, isNonEmptyFile, pathSlug, } from './hook-input.js';
|
|
4
|
+
function listFiles(dir, depth, predicate, out = []) {
|
|
5
|
+
if (depth < 0 || !isDirectory(dir))
|
|
6
|
+
return out;
|
|
7
|
+
let entries = [];
|
|
8
|
+
try {
|
|
9
|
+
entries = readdirSync(dir).sort();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return out;
|
|
13
|
+
}
|
|
14
|
+
for (const name of entries) {
|
|
15
|
+
const full = join(dir, name);
|
|
16
|
+
if (isDirectory(full))
|
|
17
|
+
listFiles(full, depth - 1, predicate, out);
|
|
18
|
+
else if (predicate(name) && isNonEmptyFile(full))
|
|
19
|
+
out.push(full);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/** Claude keeps subagent transcripts beside the main one:
|
|
24
|
+
* <dir>/<session>.jsonl + <dir>/<session>/subagents/agent-*.jsonl */
|
|
25
|
+
export function claudeSessionFiles(transcript) {
|
|
26
|
+
const dir = dirname(transcript);
|
|
27
|
+
const sessionId = basename(transcript, '.jsonl');
|
|
28
|
+
const files = [transcript];
|
|
29
|
+
const subagents = join(dir, sessionId, 'subagents');
|
|
30
|
+
if (isDirectory(subagents)) {
|
|
31
|
+
for (const name of readdirSync(subagents).sort()) {
|
|
32
|
+
if (name.endsWith('.jsonl'))
|
|
33
|
+
files.push(join(subagents, name));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return files.filter(isNonEmptyFile);
|
|
37
|
+
}
|
|
38
|
+
/** Rollout files end in the thread id: rollout-<timestamp>-<id>.jsonl */
|
|
39
|
+
export function findCodexRollout(homeDir, sessionId) {
|
|
40
|
+
if (!sessionId)
|
|
41
|
+
return null;
|
|
42
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
43
|
+
const rollouts = listFiles(join(homeDir, '.codex', 'sessions'), 6, (name) => name.startsWith('rollout-') && name.endsWith(suffix));
|
|
44
|
+
return rollouts[0] ?? null;
|
|
45
|
+
}
|
|
46
|
+
/** ~/.cursor/projects/<path-slug>/agent-transcripts/<conversation>/<conversation>.jsonl */
|
|
47
|
+
export function sweepCursor(ctx) {
|
|
48
|
+
const slug = pathSlug(ctx.projectRoot).replace(/^-/, '');
|
|
49
|
+
const dir = join(ctx.homeDir, '.cursor', 'projects', slug, 'agent-transcripts');
|
|
50
|
+
return listFiles(dir, 1, (name) => name.endsWith('.jsonl')).map((file) => ({
|
|
51
|
+
file,
|
|
52
|
+
provider: 'cursor',
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
55
|
+
/** Grok Build keeps sessions under ~/.grok/sessions; the payload carries only
|
|
56
|
+
* the session id, so match on it. Layout is undocumented — best effort. */
|
|
57
|
+
export function sweepGrok(ctx) {
|
|
58
|
+
if (!ctx.sessionId)
|
|
59
|
+
return [];
|
|
60
|
+
const dir = join(ctx.homeDir, '.grok', 'sessions');
|
|
61
|
+
return listFiles(dir, 4, (name) => name.includes(ctx.sessionId) && (name.endsWith('.json') || name.endsWith('.jsonl'))).map((file) => ({ file, provider: 'other' }));
|
|
62
|
+
}
|
|
63
|
+
/** Amp caches threads as T-<id>.json under ~/.local/share/amp (or AMP_DATA_DIR). */
|
|
64
|
+
export function sweepAmp(ctx) {
|
|
65
|
+
if (!ctx.sessionId)
|
|
66
|
+
return [];
|
|
67
|
+
const dir = ctx.env['AMP_DATA_DIR'] ?? join(ctx.homeDir, '.local', 'share', 'amp');
|
|
68
|
+
const wanted = `${ctx.sessionId}.json`;
|
|
69
|
+
return listFiles(dir, 6, (name) => name === wanted).map((file) => ({
|
|
70
|
+
file,
|
|
71
|
+
provider: 'other',
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
export function sweepForClient(client, ctx) {
|
|
75
|
+
switch (client) {
|
|
76
|
+
case 'claude':
|
|
77
|
+
case 'codex':
|
|
78
|
+
return { kind: 'sync', provider: client, scoped: true };
|
|
79
|
+
case 'cursor':
|
|
80
|
+
return { kind: 'files', targets: sweepCursor(ctx) };
|
|
81
|
+
case 'grok':
|
|
82
|
+
return { kind: 'files', targets: sweepGrok(ctx) };
|
|
83
|
+
case 'amp':
|
|
84
|
+
return { kind: 'files', targets: sweepAmp(ctx) };
|
|
85
|
+
default:
|
|
86
|
+
// Gemini / OpenCode / Copilot logs carry no cwd: machine-wide sync.
|
|
87
|
+
return { kind: 'sync', provider: client, scoped: false };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=hook-discovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-discovery.js","sourceRoot":"","sources":["../../src/lib/hook-discovery.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parsing and normalisation for `halyard hook`: the JSON every client pipes to
|
|
3
|
+
* a lifecycle hook, the event names they use, and where the repo root is.
|
|
4
|
+
*/
|
|
5
|
+
export type HookClient = 'claude' | 'codex' | 'cursor' | 'gemini' | 'copilot' | 'opencode' | 'grok' | 'amp';
|
|
6
|
+
/** `start` sweeps missed transcripts, `end` uploads the one that just finished. */
|
|
7
|
+
export type HookEvent = 'start' | 'end' | 'sweep';
|
|
8
|
+
export declare const HOOK_CLIENTS: readonly HookClient[];
|
|
9
|
+
export declare function isHookClient(value: string): value is HookClient;
|
|
10
|
+
export interface HookInput {
|
|
11
|
+
event: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
transcriptPath: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Field names differ per client (Claude/Codex/Gemini: snake_case, Copilot:
|
|
18
|
+
* camelCase, Cursor: conversation_id + workspace_roots[]), so pick from lists.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseHookInput(raw: string): HookInput;
|
|
21
|
+
export declare function normalizeEvent(name: string): HookEvent | null;
|
|
22
|
+
/** Transcript path first, then the host's environment. Cursor also exports
|
|
23
|
+
* CLAUDE_PROJECT_DIR as a compatibility alias, so it is tested before Claude. */
|
|
24
|
+
export declare function detectClient(transcriptPath: string, env: NodeJS.ProcessEnv): HookClient;
|
|
25
|
+
/** Ingest provider for a client. Clients without a Halyard parser upload as
|
|
26
|
+
* `other`: the raw log is kept and re-processed once a parser lands. */
|
|
27
|
+
export declare function providerForClient(client: HookClient): string;
|
|
28
|
+
export declare function isDirectory(path: string): boolean;
|
|
29
|
+
export declare function isNonEmptyFile(path: string): boolean;
|
|
30
|
+
/** First existing directory among the candidates, promoted to its git root. */
|
|
31
|
+
export declare function resolveProjectRoot(candidates: ReadonlyArray<string | undefined>, fallback: string): string;
|
|
32
|
+
/** Last `KEY=value` line of a dotenv file (quotes stripped), or ''. */
|
|
33
|
+
export declare function readDotenvVar(file: string, key: string): string;
|
|
34
|
+
/** Claude and Cursor name project dirs by the path with `/` and `.` → `-`. */
|
|
35
|
+
export declare function pathSlug(path: string): string;
|
|
36
|
+
//# sourceMappingURL=hook-input.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-input.d.ts","sourceRoot":"","sources":["../../src/lib/hook-input.ts"],"names":[],"mappings":"AAGA;;;GAGG;AAEH,MAAM,MAAM,UAAU,GAClB,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,QAAQ,GACR,SAAS,GACT,UAAU,GACV,MAAM,GACN,KAAK,CAAA;AAET,mFAAmF;AACnF,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,OAAO,CAAA;AAEjD,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAS7C,CAAA;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,UAAU,CAE/D;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,cAAc,EAAE,MAAM,CAAA;IACtB,GAAG,EAAE,MAAM,CAAA;CACZ;AAgBD;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAqBrD;AAeD,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,CAK7D;AAUD;kFACkF;AAClF,wBAAgB,YAAY,CAAC,cAAc,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,UAAU,CAQvF;AAED;yEACyE;AACzE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,UAAU,GAAG,MAAM,CAE5D;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAMjD;AAED,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAOpD;AAWD,+EAA+E;AAC/E,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,aAAa,CAAC,MAAM,GAAG,SAAS,CAAC,EAC7C,QAAQ,EAAE,MAAM,GACf,MAAM,CAMR;AAUD,uEAAuE;AACvE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAS/D;AAED,8EAA8E;AAC9E,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C"}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
export const HOOK_CLIENTS = [
|
|
4
|
+
'claude',
|
|
5
|
+
'codex',
|
|
6
|
+
'cursor',
|
|
7
|
+
'gemini',
|
|
8
|
+
'copilot',
|
|
9
|
+
'opencode',
|
|
10
|
+
'grok',
|
|
11
|
+
'amp',
|
|
12
|
+
];
|
|
13
|
+
export function isHookClient(value) {
|
|
14
|
+
return HOOK_CLIENTS.includes(value);
|
|
15
|
+
}
|
|
16
|
+
function asRecord(value) {
|
|
17
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
18
|
+
? value
|
|
19
|
+
: {};
|
|
20
|
+
}
|
|
21
|
+
function pickString(record, keys) {
|
|
22
|
+
for (const key of keys) {
|
|
23
|
+
const value = record[key];
|
|
24
|
+
if (typeof value === 'string' && value.length > 0)
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Field names differ per client (Claude/Codex/Gemini: snake_case, Copilot:
|
|
31
|
+
* camelCase, Cursor: conversation_id + workspace_roots[]), so pick from lists.
|
|
32
|
+
*/
|
|
33
|
+
export function parseHookInput(raw) {
|
|
34
|
+
let record = {};
|
|
35
|
+
try {
|
|
36
|
+
record = asRecord(JSON.parse(raw));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
record = {};
|
|
40
|
+
}
|
|
41
|
+
const roots = Array.isArray(record['workspace_roots']) ? record['workspace_roots'] : [];
|
|
42
|
+
const firstRoot = typeof roots[0] === 'string' ? roots[0] : '';
|
|
43
|
+
return {
|
|
44
|
+
event: pickString(record, ['hook_event_name', 'hookEventName', 'event', 'type']),
|
|
45
|
+
sessionId: pickString(record, [
|
|
46
|
+
'session_id',
|
|
47
|
+
'sessionId',
|
|
48
|
+
'conversation_id',
|
|
49
|
+
'thread_id',
|
|
50
|
+
'threadId',
|
|
51
|
+
]),
|
|
52
|
+
transcriptPath: pickString(record, ['transcript_path', 'transcriptPath']),
|
|
53
|
+
cwd: pickString(record, ['cwd', 'workspace_root', 'workspaceRoot', 'project_dir']) || firstRoot,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const START_EVENTS = new Set(['SessionStart', 'sessionStart', 'session_start', 'session.start']);
|
|
57
|
+
const END_EVENTS = new Set([
|
|
58
|
+
'SessionEnd',
|
|
59
|
+
'sessionEnd',
|
|
60
|
+
'session_end',
|
|
61
|
+
'session.end',
|
|
62
|
+
'Stop',
|
|
63
|
+
'stop',
|
|
64
|
+
'session.idle',
|
|
65
|
+
'agent-turn-complete',
|
|
66
|
+
'agent.end',
|
|
67
|
+
]);
|
|
68
|
+
export function normalizeEvent(name) {
|
|
69
|
+
if (name === 'sweep')
|
|
70
|
+
return 'sweep';
|
|
71
|
+
if (START_EVENTS.has(name))
|
|
72
|
+
return 'start';
|
|
73
|
+
if (END_EVENTS.has(name))
|
|
74
|
+
return 'end';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const CLIENT_BY_PATH = [
|
|
78
|
+
['/.claude/', 'claude'],
|
|
79
|
+
['/.codex/', 'codex'],
|
|
80
|
+
['/.cursor/', 'cursor'],
|
|
81
|
+
['/.gemini/', 'gemini'],
|
|
82
|
+
['/.copilot/', 'copilot'],
|
|
83
|
+
];
|
|
84
|
+
/** Transcript path first, then the host's environment. Cursor also exports
|
|
85
|
+
* CLAUDE_PROJECT_DIR as a compatibility alias, so it is tested before Claude. */
|
|
86
|
+
export function detectClient(transcriptPath, env) {
|
|
87
|
+
for (const [needle, client] of CLIENT_BY_PATH) {
|
|
88
|
+
if (transcriptPath.includes(needle))
|
|
89
|
+
return client;
|
|
90
|
+
}
|
|
91
|
+
if (env['CURSOR_PROJECT_DIR'] || env['CURSOR_VERSION'])
|
|
92
|
+
return 'cursor';
|
|
93
|
+
if (env['GEMINI_PROJECT_DIR'])
|
|
94
|
+
return 'gemini';
|
|
95
|
+
if (env['CODEX_CWD'] || env['CODEX_HOME'])
|
|
96
|
+
return 'codex';
|
|
97
|
+
return 'claude';
|
|
98
|
+
}
|
|
99
|
+
/** Ingest provider for a client. Clients without a Halyard parser upload as
|
|
100
|
+
* `other`: the raw log is kept and re-processed once a parser lands. */
|
|
101
|
+
export function providerForClient(client) {
|
|
102
|
+
return client === 'grok' || client === 'amp' ? 'other' : client;
|
|
103
|
+
}
|
|
104
|
+
export function isDirectory(path) {
|
|
105
|
+
try {
|
|
106
|
+
return statSync(path).isDirectory();
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function isNonEmptyFile(path) {
|
|
113
|
+
try {
|
|
114
|
+
const stat = statSync(path);
|
|
115
|
+
return stat.isFile() && stat.size > 0;
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function gitToplevel(dir) {
|
|
122
|
+
const result = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], {
|
|
123
|
+
encoding: 'utf8',
|
|
124
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
125
|
+
});
|
|
126
|
+
const out = result.status === 0 ? result.stdout.trim() : '';
|
|
127
|
+
return out.length > 0 ? out : null;
|
|
128
|
+
}
|
|
129
|
+
/** First existing directory among the candidates, promoted to its git root. */
|
|
130
|
+
export function resolveProjectRoot(candidates, fallback) {
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
if (!candidate || !isDirectory(candidate))
|
|
133
|
+
continue;
|
|
134
|
+
return gitToplevel(candidate) ?? candidate;
|
|
135
|
+
}
|
|
136
|
+
return fallback;
|
|
137
|
+
}
|
|
138
|
+
function unquote(value) {
|
|
139
|
+
const trimmed = value.trim();
|
|
140
|
+
const quoted = (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
141
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"));
|
|
142
|
+
return quoted && trimmed.length >= 2 ? trimmed.slice(1, -1) : trimmed;
|
|
143
|
+
}
|
|
144
|
+
/** Last `KEY=value` line of a dotenv file (quotes stripped), or ''. */
|
|
145
|
+
export function readDotenvVar(file, key) {
|
|
146
|
+
if (!existsSync(file))
|
|
147
|
+
return '';
|
|
148
|
+
const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}=(.*)$`);
|
|
149
|
+
let found = '';
|
|
150
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
151
|
+
const match = pattern.exec(line.replace(/\r$/, ''));
|
|
152
|
+
if (match)
|
|
153
|
+
found = unquote(match[1] ?? '');
|
|
154
|
+
}
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
/** Claude and Cursor name project dirs by the path with `/` and `.` → `-`. */
|
|
158
|
+
export function pathSlug(path) {
|
|
159
|
+
return path.replace(/[/.]/g, '-');
|
|
160
|
+
}
|
|
161
|
+
//# sourceMappingURL=hook-input.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-input.js","sourceRoot":"","sources":["../../src/lib/hook-input.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type HookClient, type HookEvent } from './hook-input.js';
|
|
2
|
+
import { type TargetOrg } from './preflight.js';
|
|
3
|
+
/**
|
|
4
|
+
* `halyard hook`: the uploader every client's lifecycle hook calls.
|
|
5
|
+
*
|
|
6
|
+
* - `end` uploads the transcript that just finished (Claude: plus its
|
|
7
|
+
* subagent transcripts; Codex: the rollout matched by thread id).
|
|
8
|
+
* - `sweep` uploads this repo's earlier transcripts that never made it up
|
|
9
|
+
* (run detached from a `start` event by the command layer).
|
|
10
|
+
*
|
|
11
|
+
* Org binding: a repo's `.halyard.json` decides which org its sessions
|
|
12
|
+
* belong to. The hook resolves credentials the same way `sync` and `push`
|
|
13
|
+
* do (HALYARD_TOKEN → per-org login for the bound org → default login) and
|
|
14
|
+
* refuses login credentials for another org.
|
|
15
|
+
*
|
|
16
|
+
* Why session end and not every turn: the ingest endpoint keeps the FIRST
|
|
17
|
+
* upload it sees for a session id, so a growing transcript uploaded per turn
|
|
18
|
+
* would freeze at turn one.
|
|
19
|
+
*
|
|
20
|
+
* Never throws for expected conditions — every skip reason goes to
|
|
21
|
+
* ~/.halyard/hooks.log — and never writes to stdout (clients parse it).
|
|
22
|
+
*/
|
|
23
|
+
export interface HookRunOptions {
|
|
24
|
+
/** Raw JSON the client piped to the hook; may be empty. */
|
|
25
|
+
input: string;
|
|
26
|
+
client?: HookClient;
|
|
27
|
+
event?: HookEvent;
|
|
28
|
+
root?: string;
|
|
29
|
+
transcript?: string;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
apiUrl: string;
|
|
32
|
+
halyardDir: string;
|
|
33
|
+
homeDir: string;
|
|
34
|
+
/** Should be process.env in production: HALYARD_TOKEN found in the repo .env is exported into it. */
|
|
35
|
+
env: NodeJS.ProcessEnv;
|
|
36
|
+
cwd: string;
|
|
37
|
+
/** Injectable for tests (defaults to the shared upload preflight). */
|
|
38
|
+
resolveTarget?: (apiUrl: string) => Promise<TargetOrg>;
|
|
39
|
+
}
|
|
40
|
+
export declare class HookLogger {
|
|
41
|
+
private readonly file;
|
|
42
|
+
private readonly tag;
|
|
43
|
+
constructor(file: string, tag: string);
|
|
44
|
+
log(message: string): void;
|
|
45
|
+
private rotate;
|
|
46
|
+
}
|
|
47
|
+
export declare function runHook(opts: HookRunOptions): Promise<void>;
|
|
48
|
+
//# sourceMappingURL=hook-runner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hook-runner.d.ts","sourceRoot":"","sources":["../../src/lib/hook-runner.ts"],"names":[],"mappings":"AAsBA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,SAAS,EACf,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAoB,KAAK,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAYjE;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,MAAM,WAAW,cAAc;IAC7B,2DAA2D;IAC3D,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,MAAM,EAAE,OAAO,CAAA;IACf,MAAM,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,MAAM,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,qGAAqG;IACrG,GAAG,EAAE,MAAM,CAAC,UAAU,CAAA;IACtB,GAAG,EAAE,MAAM,CAAA;IACX,sEAAsE;IACtE,aAAa,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,CAAC,CAAA;CACvD;AAMD,qBAAa,UAAU;IAEnB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,GAAG;gBADH,IAAI,EAAE,MAAM,EACZ,GAAG,EAAE,MAAM;IAG9B,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI;IAW1B,OAAO,CAAC,MAAM;CAWf;AAeD,wBAAsB,OAAO,CAAC,IAAI,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDjE"}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, rmdirSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
4
|
+
import { uploadSession } from './api-client.js';
|
|
5
|
+
import { getAuthHeaders, getCredentials } from './auth.js';
|
|
6
|
+
import { formatError } from './config-files.js';
|
|
7
|
+
import { claudeSessionFiles, findCodexRollout, sweepForClient, } from './hook-discovery.js';
|
|
8
|
+
import { detectClient, isNonEmptyFile, normalizeEvent, parseHookInput, providerForClient, readDotenvVar, resolveProjectRoot, } from './hook-input.js';
|
|
9
|
+
import { resolveTargetOrg } from './preflight.js';
|
|
10
|
+
import { findRepoBinding, gitToplevel } from './repo-binding.js';
|
|
11
|
+
import { runSkillsSync } from './skills-sync.js';
|
|
12
|
+
import { runSync } from './sync-runner.js';
|
|
13
|
+
import { hashFileContents, isUploaded, markUploaded, readSyncState, writeSyncState, } from './sync-state.js';
|
|
14
|
+
const LOG_MAX_BYTES = 1_048_576;
|
|
15
|
+
const LOG_KEEP_BYTES = 262_144;
|
|
16
|
+
const LOCK_STALE_MS = 10 * 60 * 1000;
|
|
17
|
+
export class HookLogger {
|
|
18
|
+
file;
|
|
19
|
+
tag;
|
|
20
|
+
constructor(file, tag) {
|
|
21
|
+
this.file = file;
|
|
22
|
+
this.tag = tag;
|
|
23
|
+
}
|
|
24
|
+
log(message) {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
27
|
+
this.rotate();
|
|
28
|
+
const stamp = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
29
|
+
appendFileSync(this.file, `${stamp} [${this.tag}] ${message}\n`);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// Logging must never fail the hook.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
rotate() {
|
|
36
|
+
let size = 0;
|
|
37
|
+
try {
|
|
38
|
+
size = statSync(this.file).size;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (size <= LOG_MAX_BYTES)
|
|
44
|
+
return;
|
|
45
|
+
const content = readFileSync(this.file);
|
|
46
|
+
writeFileSync(this.file, content.subarray(content.length - LOG_KEEP_BYTES));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function runHook(opts) {
|
|
50
|
+
if (opts.env['HALYARD_SESSION_UPLOAD'] === '0')
|
|
51
|
+
return;
|
|
52
|
+
const parsed = parseHookInput(opts.input);
|
|
53
|
+
const event = opts.event ?? normalizeEvent(parsed.event);
|
|
54
|
+
if (!event)
|
|
55
|
+
return;
|
|
56
|
+
const transcript = opts.transcript || parsed.transcriptPath || opts.env['CURSOR_TRANSCRIPT_PATH'] || '';
|
|
57
|
+
const client = opts.client ?? detectClient(transcript, opts.env);
|
|
58
|
+
const logger = new HookLogger(join(opts.halyardDir, 'hooks.log'), `${client}/${event}`);
|
|
59
|
+
const projectRoot = resolveProjectRoot([
|
|
60
|
+
opts.root,
|
|
61
|
+
opts.env['HALYARD_PROJECT_ROOT'],
|
|
62
|
+
opts.env['CLAUDE_PROJECT_DIR'],
|
|
63
|
+
opts.env['CURSOR_PROJECT_DIR'],
|
|
64
|
+
opts.env['GEMINI_PROJECT_DIR'],
|
|
65
|
+
opts.env['CODEX_CWD'],
|
|
66
|
+
parsed.cwd,
|
|
67
|
+
opts.cwd,
|
|
68
|
+
], opts.cwd);
|
|
69
|
+
const ctx = {
|
|
70
|
+
opts,
|
|
71
|
+
client,
|
|
72
|
+
event,
|
|
73
|
+
projectRoot,
|
|
74
|
+
binding: findRepoBinding(projectRoot),
|
|
75
|
+
sessionId: parsed.sessionId,
|
|
76
|
+
transcript,
|
|
77
|
+
logger,
|
|
78
|
+
};
|
|
79
|
+
if (!ensureToken(ctx))
|
|
80
|
+
return;
|
|
81
|
+
const target = await resolveTarget(ctx);
|
|
82
|
+
if (!target)
|
|
83
|
+
return;
|
|
84
|
+
let authHeaders;
|
|
85
|
+
try {
|
|
86
|
+
authHeaders = await getAuthHeaders(opts.apiUrl, ctx.binding);
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
logger.log(`skip: ${formatError(err)}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const upload = { ...ctx, authHeaders, stateKey: target.stateKey };
|
|
93
|
+
logger.log(`root=${projectRoot} org=${target.label} transcript=${transcript || '<none>'} session=${parsed.sessionId || '<none>'}`);
|
|
94
|
+
const plan = event === 'end' ? endPlan(upload) : sweepForClient(client, sweepContext(upload));
|
|
95
|
+
await executePlan(upload, plan);
|
|
96
|
+
if (event === 'sweep')
|
|
97
|
+
await sweepSkills(upload);
|
|
98
|
+
logger.log(`${event} finished`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Session start also lifts this repo's committed skills (`.claude/skills`,
|
|
102
|
+
* `.agents/skills`, ...) into the bound org. Project scope only: personal
|
|
103
|
+
* `~/.claude/skills` are synced by `halyard skills sync`, never by a hook
|
|
104
|
+
* firing inside whichever org's repo happens to be open. The ledger makes
|
|
105
|
+
* this a no-op when nothing changed. HALYARD_SKILLS_SYNC=0 disables it.
|
|
106
|
+
*/
|
|
107
|
+
async function sweepSkills(ctx) {
|
|
108
|
+
if (ctx.opts.env['HALYARD_SKILLS_SYNC'] === '0')
|
|
109
|
+
return;
|
|
110
|
+
const tag = 'skills[project]';
|
|
111
|
+
// Only a real repository is a project root. The hook's project dir falls
|
|
112
|
+
// back to the raw cwd, and scanning `~` as a "project" would upload the
|
|
113
|
+
// user's personal skill directories as repo skills.
|
|
114
|
+
const projectRoot = ctx.binding?.rootDir ?? gitToplevel(ctx.projectRoot);
|
|
115
|
+
if (!projectRoot) {
|
|
116
|
+
ctx.logger.log(`${tag} skip: ${ctx.projectRoot} is not a git repository`);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const summary = await runSkillsSync({
|
|
121
|
+
homeDir: ctx.opts.homeDir,
|
|
122
|
+
projectRoot,
|
|
123
|
+
scope: 'project',
|
|
124
|
+
stateKey: ctx.stateKey,
|
|
125
|
+
authHeaders: ctx.authHeaders,
|
|
126
|
+
apiUrl: ctx.opts.apiUrl,
|
|
127
|
+
dryRun: ctx.opts.dryRun,
|
|
128
|
+
force: false,
|
|
129
|
+
log: (line) => ctx.logger.log(`${tag} ${line.trim()}`),
|
|
130
|
+
});
|
|
131
|
+
ctx.logger.log(`${tag} found=${summary.found} created=${summary.created} updated=${summary.updated} unchanged=${summary.unchanged} skipped=${summary.skipped} failed=${summary.failed}`);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
ctx.logger.log(`${tag} failed: ${formatError(err)}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/** Token resolution: environment → repo .env → `halyard login` credentials. */
|
|
138
|
+
function ensureToken(ctx) {
|
|
139
|
+
const env = ctx.opts.env;
|
|
140
|
+
if (!env['HALYARD_TOKEN']) {
|
|
141
|
+
const fromRepo = readDotenvVar(join(ctx.projectRoot, '.env'), 'HALYARD_TOKEN');
|
|
142
|
+
if (fromRepo)
|
|
143
|
+
env['HALYARD_TOKEN'] = fromRepo;
|
|
144
|
+
}
|
|
145
|
+
if (env['HALYARD_TOKEN'] || getCredentials(ctx.binding))
|
|
146
|
+
return true;
|
|
147
|
+
ctx.logger.log(`skip: not authenticated (run \`halyard login\` in ${ctx.projectRoot}, or set HALYARD_TOKEN in the environment or ${ctx.projectRoot}/.env)`);
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Where uploads will land, and whether that agrees with the repo binding.
|
|
152
|
+
* Mirrors `runPreflight` for sync/push, except that a mismatch skips instead
|
|
153
|
+
* of failing: a hook must never break the agent.
|
|
154
|
+
*/
|
|
155
|
+
async function resolveTarget(ctx) {
|
|
156
|
+
let target;
|
|
157
|
+
try {
|
|
158
|
+
target = await (ctx.opts.resolveTarget ?? resolveTargetOrg)(ctx.opts.apiUrl);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
ctx.logger.log(`skip: could not resolve the destination org (${formatError(err)})`);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (!ctx.binding)
|
|
165
|
+
return target;
|
|
166
|
+
if (target.slug === null) {
|
|
167
|
+
ctx.logger.log(`note: repo is bound to '${ctx.binding.org}' but an API key's org cannot be verified; uploading to the key's org`);
|
|
168
|
+
return target;
|
|
169
|
+
}
|
|
170
|
+
if (target.slug === ctx.binding.org)
|
|
171
|
+
return target;
|
|
172
|
+
ctx.logger.log(`skip: credentials are for org '${target.slug}' but this repo is bound to '${ctx.binding.org}' (${ctx.binding.rootDir}/.halyard.json). Run \`halyard login\` inside the repo, or set HALYARD_TOKEN to a ${ctx.binding.org} API key in ${ctx.projectRoot}/.env`);
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
function sweepContext(ctx) {
|
|
176
|
+
return {
|
|
177
|
+
homeDir: ctx.opts.homeDir,
|
|
178
|
+
projectRoot: ctx.projectRoot,
|
|
179
|
+
sessionId: ctx.sessionId,
|
|
180
|
+
transcriptPath: ctx.transcript,
|
|
181
|
+
event: ctx.event,
|
|
182
|
+
env: ctx.opts.env,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
function endPlan(ctx) {
|
|
186
|
+
if (ctx.transcript && isNonEmptyFile(ctx.transcript)) {
|
|
187
|
+
const files = ctx.client === 'claude' ? claudeSessionFiles(ctx.transcript) : [ctx.transcript];
|
|
188
|
+
const provider = providerForClient(ctx.client);
|
|
189
|
+
return { kind: 'files', targets: files.map((file) => ({ file, provider })) };
|
|
190
|
+
}
|
|
191
|
+
if (ctx.client === 'codex') {
|
|
192
|
+
const rollout = findCodexRollout(ctx.opts.homeDir, ctx.sessionId);
|
|
193
|
+
if (rollout)
|
|
194
|
+
return { kind: 'files', targets: [{ file: rollout, provider: 'codex' }] };
|
|
195
|
+
}
|
|
196
|
+
return sweepForClient(ctx.client, sweepContext(ctx));
|
|
197
|
+
}
|
|
198
|
+
/** The live session is uploaded at its own end (first upload wins server-side). */
|
|
199
|
+
function isLiveSession(ctx, session) {
|
|
200
|
+
if (!ctx.sessionId)
|
|
201
|
+
return false;
|
|
202
|
+
if (ctx.client === 'codex')
|
|
203
|
+
return session.filePath.endsWith(`-${ctx.sessionId}.jsonl`);
|
|
204
|
+
return session.sessionId === ctx.sessionId || session.filePath.includes(`/${ctx.sessionId}/`);
|
|
205
|
+
}
|
|
206
|
+
async function executePlan(ctx, plan) {
|
|
207
|
+
if (plan.kind === 'sync') {
|
|
208
|
+
const tag = `sync[${plan.provider}${plan.scoped ? ', repo-scoped' : ''}]`;
|
|
209
|
+
const summary = await runSync({
|
|
210
|
+
providerFilter: plan.provider,
|
|
211
|
+
scopeRootDir: plan.scoped ? (ctx.binding?.rootDir ?? ctx.projectRoot) : undefined,
|
|
212
|
+
exclude: (session) => isLiveSession(ctx, session),
|
|
213
|
+
stateKey: ctx.stateKey,
|
|
214
|
+
apiUrl: ctx.opts.apiUrl,
|
|
215
|
+
authHeaders: ctx.authHeaders,
|
|
216
|
+
dryRun: ctx.opts.dryRun,
|
|
217
|
+
log: (line) => ctx.logger.log(`${tag} ${line.trim()}`),
|
|
218
|
+
});
|
|
219
|
+
ctx.logger.log(`${tag} found=${summary.found} matched=${summary.matched} unresolved=${summary.unresolved} uploaded=${summary.uploaded} skipped=${summary.skipped} failed=${summary.failed}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (plan.targets.length === 0) {
|
|
223
|
+
ctx.logger.log('nothing to upload');
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
for (const target of plan.targets) {
|
|
227
|
+
await pushFile(ctx, target);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function lockPath(halyardDir, file) {
|
|
231
|
+
const key = createHash('sha1').update(file).digest('hex').slice(0, 16);
|
|
232
|
+
return join(halyardDir, 'locks', key);
|
|
233
|
+
}
|
|
234
|
+
/** A per-file directory lock: safe for two hooks (repo + plugin) to race. A
|
|
235
|
+
* stale lock (crashed hook) is reclaimed after ten minutes. */
|
|
236
|
+
function acquireLock(halyardDir, file) {
|
|
237
|
+
const lock = lockPath(halyardDir, file);
|
|
238
|
+
mkdirSync(dirname(lock), { recursive: true });
|
|
239
|
+
try {
|
|
240
|
+
mkdirSync(lock);
|
|
241
|
+
return lock;
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// fall through to the stale check
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
if (Date.now() - statSync(lock).mtimeMs < LOCK_STALE_MS)
|
|
248
|
+
return null;
|
|
249
|
+
rmdirSync(lock);
|
|
250
|
+
mkdirSync(lock);
|
|
251
|
+
return lock;
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function releaseLock(lock) {
|
|
258
|
+
try {
|
|
259
|
+
rmdirSync(lock);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
// already gone
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/** The session id is the file name without extension — the same rule
|
|
266
|
+
* `halyard sync` uses, so hook uploads and later manual syncs dedupe together. */
|
|
267
|
+
async function pushFile(ctx, target) {
|
|
268
|
+
const { file, provider } = target;
|
|
269
|
+
if (!isNonEmptyFile(file))
|
|
270
|
+
return;
|
|
271
|
+
const content = readFileSync(file);
|
|
272
|
+
const hash = hashFileContents(content);
|
|
273
|
+
const state = readSyncState();
|
|
274
|
+
if (isUploaded(state, ctx.stateKey, file, hash)) {
|
|
275
|
+
ctx.logger.log(`skip (already uploaded): ${file}`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const lock = acquireLock(ctx.opts.halyardDir, file);
|
|
279
|
+
if (!lock) {
|
|
280
|
+
ctx.logger.log(`skip (another hook is uploading): ${file}`);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
if (ctx.opts.dryRun) {
|
|
285
|
+
ctx.logger.log(`dry-run: would push [${provider}] ${file} (${content.length} bytes)`);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const sessionId = basename(file, extname(file));
|
|
289
|
+
const result = await uploadSession(content, basename(file), provider, sessionId, ctx.authHeaders, ctx.opts.apiUrl);
|
|
290
|
+
markUploaded(state, ctx.stateKey, file, {
|
|
291
|
+
sessionId: result.sessionId,
|
|
292
|
+
uploadedAt: new Date().toISOString(),
|
|
293
|
+
size: content.length,
|
|
294
|
+
hash,
|
|
295
|
+
});
|
|
296
|
+
writeSyncState(state);
|
|
297
|
+
ctx.logger.log(`push[${provider}] ${result.status}: ${file} (${content.length} bytes)`);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
ctx.logger.log(`push[${provider}] failed: ${file} :: ${formatError(err)}`);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
releaseLock(lock);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=hook-runner.js.map
|