@indigoai-us/hq-cli 5.345.151 → 5.345.153
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/CHANGELOG.md +129 -0
- package/dist/bin/hq-auth-refresh.d.ts +4 -0
- package/dist/bin/hq-auth-refresh.js +9 -1
- package/dist/command-catalog.generated.d.ts +188 -3
- package/dist/command-catalog.generated.js +230 -3
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +12 -0
- package/dist/commands/agent-enroll.js +37 -4
- package/dist/commands/core-policy.js +4 -4
- package/dist/commands/core-session-project-native.js +19 -4
- package/dist/commands/decide.d.ts +13 -0
- package/dist/commands/decide.js +74 -0
- package/dist/commands/files.js +23 -7
- package/dist/commands/integrations-connect.js +24 -1
- package/dist/commands/integrations-core.d.ts +14 -0
- package/dist/commands/integrations-core.js +19 -1
- package/dist/commands/integrations-import.js +22 -1
- package/dist/commands/integrations.js +43 -2
- package/dist/commands/lanes-message.js +4 -11
- package/dist/commands/lanes-questions.js +3 -0
- package/dist/commands/lanes-resume.js +4 -11
- package/dist/commands/lanes-stories.js +3 -0
- package/dist/commands/rescue.d.ts +7 -0
- package/dist/commands/rescue.js +22 -13
- package/dist/commands/run.js +14 -5
- package/dist/commands/secrets.d.ts +13 -0
- package/dist/commands/secrets.js +51 -8
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +186 -186
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
- package/dist/lib/agent-kit/adopt-identity.js +18 -3
- package/dist/lib/agent-kit/services.d.ts +3 -1
- package/dist/lib/agent-kit/services.js +5 -1
- package/dist/lib/daemon/ops/policy.d.ts +9 -1
- package/dist/lib/daemon/ops/policy.js +37 -2
- package/dist/lib/decisions/client.d.ts +67 -0
- package/dist/lib/decisions/client.js +120 -0
- package/dist/lib/decisions/expiry.d.ts +2 -0
- package/dist/lib/decisions/expiry.js +12 -0
- package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
- package/dist/lib/decisions/fixtures/us001.js +21 -0
- package/dist/lib/decisions/import-lanes.d.ts +16 -0
- package/dist/lib/decisions/import-lanes.js +152 -0
- package/dist/lib/decisions/principal.d.ts +11 -0
- package/dist/lib/decisions/principal.js +52 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
- package/dist/lib/doctor/checks/claude-wiring.js +21 -10
- package/dist/lib/doctor/checks/work-context.js +7 -2
- package/dist/lib/doctor/fix/apply.d.ts +3 -2
- package/dist/lib/doctor/fix/apply.js +19 -15
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
- package/dist/lib/doctor/fix/remediation.js +4 -2
- package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
- package/dist/lib/doctor/hook-gate-profiles.js +34 -0
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/install/provider-wrappers.js +5 -1
- package/dist/lib/lanes/autopilot.js +4 -1
- package/dist/lib/lanes/imported-question.d.ts +5 -0
- package/dist/lib/lanes/imported-question.js +17 -0
- package/dist/lib/lanes/questions.d.ts +0 -5
- package/dist/lib/lanes/questions.js +2 -0
- package/dist/lib/lanes/reconcile.js +21 -10
- package/dist/lib/lanes/store.d.ts +0 -8
- package/dist/lib/lanes/store.js +2 -1
- package/dist/lib/lanes/stories.d.ts +15 -0
- package/dist/lib/lanes/stories.js +25 -0
- package/dist/lib/mesh/client.d.ts +8 -0
- package/dist/lib/mesh/client.js +44 -3
- package/dist/lib/mesh/denial-codes.js +1 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
- package/dist/lib/mesh/live/daemon/run.js +30 -1
- package/dist/lib/mesh/live/flush.d.ts +7 -0
- package/dist/lib/mesh/live/flush.js +53 -10
- package/dist/lib/registry/registry.js +6 -0
- package/dist/lib/scan-packages/index.js +7 -3
- package/dist/lib/work-context/organize.d.ts +2 -0
- package/dist/lib/work-context/organize.js +22 -4
- package/dist/lib/workers-registry/index.d.ts +12 -1
- package/dist/lib/workers-registry/index.js +39 -1
- package/dist/utils/cognito-session.d.ts +4 -1
- package/dist/utils/cognito-session.js +11 -1
- package/dist/utils/hook-trust.d.ts +27 -0
- package/dist/utils/hook-trust.js +150 -8
- package/dist/utils/shipped-codex-hooks.d.ts +17 -0
- package/dist/utils/shipped-codex-hooks.js +86 -0
- package/package.json +8 -8
|
@@ -59,7 +59,13 @@ function workerYamlFiles(root, relativeRoot) {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
|
|
62
|
+
// A `.git` file (linked worktree) or folder (clone) below the scan root marks
|
|
63
|
+
// a separate checkout. Its worker.yaml files belong to that checkout, so the
|
|
64
|
+
// walk stops there. A checkout that is itself one worker keeps its own entry.
|
|
65
|
+
const isNestedCheckout = isNestedWorkerCheckout(relativeRoot, relative, entries.map((entry) => entry.name));
|
|
62
66
|
for (const entry of entries) {
|
|
67
|
+
if (isNestedCheckout && entry.name !== 'worker.yaml')
|
|
68
|
+
continue;
|
|
63
69
|
const childRelative = path.join(relative, entry.name);
|
|
64
70
|
const child = path.join(root, childRelative);
|
|
65
71
|
if (entry.name === 'worker.yaml' && (entry.isFile() || isFile(child))) {
|
|
@@ -162,12 +168,21 @@ function withoutTimestamp(content) {
|
|
|
162
168
|
* (`.codex/worktrees/<name>/`) are the same trap in a different folder: a
|
|
163
169
|
* nested HQ root under a company project. Those copies land in the registry as
|
|
164
170
|
* `companies/<slug>/…/.claude/worktrees/…` or `.codex/worktrees/…` paths, so
|
|
165
|
-
* `hq lanes create` then fails with `worker_tenancy_unresolved`.
|
|
171
|
+
* `hq lanes create` then fails with `worker_tenancy_unresolved`. HQ's own
|
|
172
|
+
* `workspace/worktrees/<name>/` is the same shape.
|
|
173
|
+
*
|
|
174
|
+
* `node_modules` and `.git` never hold workers and are large, so they are
|
|
175
|
+
* skipped wherever they appear. Any other checkout (a directory holding `.git`)
|
|
176
|
+
* is caught by the walk itself, because only the filesystem can tell.
|
|
166
177
|
*/
|
|
167
178
|
export function isExcludedWorkerDirectory(relativeDirectory) {
|
|
168
179
|
const normalized = relativeDirectory.replaceAll('\\', '/');
|
|
169
180
|
if (/(?:^|\/)\.(?:claude|codex)\/worktrees(?:\/|$)/.test(normalized))
|
|
170
181
|
return true;
|
|
182
|
+
if (/(?:^|\/)workspace\/worktrees(?:\/|$)/.test(normalized))
|
|
183
|
+
return true;
|
|
184
|
+
if (/(?:^|\/)(?:node_modules|\.git)(?:\/|$)/.test(normalized))
|
|
185
|
+
return true;
|
|
171
186
|
const [root, tenant, nested] = normalized.split('/');
|
|
172
187
|
if (root !== 'companies')
|
|
173
188
|
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
@@ -177,6 +192,29 @@ export function isExcludedWorkerDirectory(relativeDirectory) {
|
|
|
177
192
|
return true;
|
|
178
193
|
return /(?:^|\/)_overrides(?:\/|$)/.test(normalized);
|
|
179
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Directories that may be git repositories without being a nested checkout.
|
|
197
|
+
*
|
|
198
|
+
* An operator can keep a whole tenant (`companies/<slug>`) or a tenant's
|
|
199
|
+
* `workers` folder in its own repository. Treating either as a checkout would
|
|
200
|
+
* drop every real worker in that tenant.
|
|
201
|
+
*/
|
|
202
|
+
function isCheckoutBoundaryExempt(relativeDirectory) {
|
|
203
|
+
const parts = relativeDirectory.replaceAll('\\', '/').split('/');
|
|
204
|
+
if (parts[0] !== 'companies')
|
|
205
|
+
return false;
|
|
206
|
+
return parts.length === 2 || (parts.length === 3 && parts[2] === 'workers');
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* True when `relativeDirectory`, found while walking `scanRoot`, is a separate
|
|
210
|
+
* git checkout (its listing `names` holds `.git`). The caller may still take a
|
|
211
|
+
* `worker.yaml` directly inside it, but must not descend any further.
|
|
212
|
+
*/
|
|
213
|
+
export function isNestedWorkerCheckout(scanRoot, relativeDirectory, names) {
|
|
214
|
+
return relativeDirectory !== scanRoot
|
|
215
|
+
&& !isCheckoutBoundaryExempt(relativeDirectory)
|
|
216
|
+
&& names.includes('.git');
|
|
217
|
+
}
|
|
180
218
|
/**
|
|
181
219
|
* Every exclusion is a property of the directory holding the `worker.yaml`, so
|
|
182
220
|
* the file rule is the directory rule applied to its parent. Deriving it keeps
|
|
@@ -294,7 +294,10 @@ export declare function buildVaultConfig(authToken: string): VaultServiceConfig;
|
|
|
294
294
|
* means no valid session could be ensured non-interactively; the caller gets a
|
|
295
295
|
* reason string and can decide what to do next. Never opens a browser.
|
|
296
296
|
*/
|
|
297
|
-
type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource"> & MachineTokenValidityOptions
|
|
297
|
+
type MachineRefreshOptions = Pick<EnsureCognitoTokenOptions, "tokenSource"> & MachineTokenValidityOptions & {
|
|
298
|
+
/** Refresh the person session even when the cached token is still valid. */
|
|
299
|
+
force?: boolean;
|
|
300
|
+
};
|
|
298
301
|
export declare function refreshCachedSession(options?: MachineRefreshOptions): Promise<{
|
|
299
302
|
refreshed: boolean;
|
|
300
303
|
reason?: string;
|
|
@@ -1158,6 +1158,15 @@ export function buildVaultConfig(authToken) {
|
|
|
1158
1158
|
clientInfo: CLI_CLIENT_INFO,
|
|
1159
1159
|
};
|
|
1160
1160
|
}
|
|
1161
|
+
/** Person sessions renew below this many seconds of validity by default. */
|
|
1162
|
+
const PERSON_REFRESH_DEFAULT_SECONDS = 120;
|
|
1163
|
+
/** Person tokens last one hour; a caller minimum above this would always refresh. */
|
|
1164
|
+
const MAX_PERSON_MIN_VALIDITY_SECONDS = 1800;
|
|
1165
|
+
function personRefreshThresholdSeconds(minValiditySeconds) {
|
|
1166
|
+
if (minValiditySeconds === undefined)
|
|
1167
|
+
return PERSON_REFRESH_DEFAULT_SECONDS;
|
|
1168
|
+
return Math.max(PERSON_REFRESH_DEFAULT_SECONDS, Math.min(minValiditySeconds, MAX_PERSON_MIN_VALIDITY_SECONDS));
|
|
1169
|
+
}
|
|
1161
1170
|
export async function refreshCachedSession(options = {}) {
|
|
1162
1171
|
if (wantsMachineCognitoTokens(options))
|
|
1163
1172
|
return refreshCachedSessionUnserialized(options);
|
|
@@ -1198,7 +1207,8 @@ async function refreshCachedSessionUnserialized(options) {
|
|
|
1198
1207
|
}
|
|
1199
1208
|
return { refreshed: false, reason: "no cached session" };
|
|
1200
1209
|
}
|
|
1201
|
-
if (!
|
|
1210
|
+
if (!options.force &&
|
|
1211
|
+
!isExpiring(cached, personRefreshThresholdSeconds(options.minValiditySeconds))) {
|
|
1202
1212
|
return { refreshed: true };
|
|
1203
1213
|
}
|
|
1204
1214
|
try {
|
|
@@ -26,6 +26,20 @@ export interface HookTrustDependencies {
|
|
|
26
26
|
* our read and our commit — which no other seam can reach.
|
|
27
27
|
*/
|
|
28
28
|
readConfigText?: (file: string) => string;
|
|
29
|
+
/**
|
|
30
|
+
* Reads the HQ `.codex` hook files exactly as the installed core release
|
|
31
|
+
* ships them. Used only by the post-rescue re-trust; see
|
|
32
|
+
* {@link retrustCodexHooksAfterRescue}.
|
|
33
|
+
*/
|
|
34
|
+
loadShippedCodexHooks?: (hqRoot: string) => Promise<ShippedCodexHookFiles>;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The text of the hook files in core's `.codex/` layer at the release a rescue
|
|
38
|
+
* installed. A file the release does not ship is left out.
|
|
39
|
+
*/
|
|
40
|
+
export interface ShippedCodexHookFiles {
|
|
41
|
+
'config.toml'?: string;
|
|
42
|
+
'hooks.json'?: string;
|
|
29
43
|
}
|
|
30
44
|
/** Per-call knobs the `hq reindex` orchestration passes down. */
|
|
31
45
|
export interface HookTrustOptions {
|
|
@@ -62,6 +76,19 @@ export declare function planCodexLaunch(executable: string, args: readonly strin
|
|
|
62
76
|
export declare function createCodexAppServerClient(cwd: string, executable?: string, args?: string[]): Promise<CodexRpcClient>;
|
|
63
77
|
/** Trust only hooks declared by this HQ root's project `.codex/` layer. */
|
|
64
78
|
export declare function trustCodexProjectHooks(hqRoot: string, deps?: HookTrustDependencies): Promise<RuntimeHookTrustResult>;
|
|
79
|
+
/**
|
|
80
|
+
* Re-trust the Codex hooks HQ core ships after `hq rescue` re-lays core.
|
|
81
|
+
*
|
|
82
|
+
* Codex trusts a hook by the hash of its current command, so an update that
|
|
83
|
+
* changes a hook command leaves Codex skipping it as untrusted until the next
|
|
84
|
+
* trust pass. Rescue runs that pass itself, for core-shipped hooks only: a
|
|
85
|
+
* hook in `<hqRoot>/.codex/` is trusted only when its entry matches the
|
|
86
|
+
* shipped copy of its file (see {@link coreShippedHooks}), so a hand-added
|
|
87
|
+
* entry is never trusted. Unlike `hq reindex`, rescue does not re-enable a
|
|
88
|
+
* shipped hook the user turned off. Already trusted hooks are left as they
|
|
89
|
+
* are, and a machine without Codex is skipped without starting anything.
|
|
90
|
+
*/
|
|
91
|
+
export declare function retrustCodexHooksAfterRescue(hqRoot: string, deps?: HookTrustDependencies, codexInstalled?: () => boolean): Promise<RuntimeHookTrustResult>;
|
|
65
92
|
/**
|
|
66
93
|
* Trust the user-scope hooks `hq install --global --runtime codex` registered
|
|
67
94
|
* in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
|
package/dist/utils/hook-trust.js
CHANGED
|
@@ -3,12 +3,14 @@ import * as fs from 'node:fs';
|
|
|
3
3
|
import * as os from 'node:os';
|
|
4
4
|
import * as path from 'node:path';
|
|
5
5
|
import * as readline from 'node:readline';
|
|
6
|
+
import { parse as parseToml } from 'smol-toml';
|
|
6
7
|
// `~/.claude.json` is Claude Code's live user-global state file, so the write
|
|
7
8
|
// reuses the hardened MCP-registration primitives (advisory lock held across
|
|
8
9
|
// read->merge->write, backup before the first byte, temp+fsync+rename commit)
|
|
9
10
|
// rather than a bare readFile/writeFile pair.
|
|
10
11
|
import { acquireLock, atomicReplace, backupConfig, realpathOrSelf, releaseLock, restoreFromBackup, } from '../commands/mcp-registration.js';
|
|
11
12
|
import { findExecutableOnPath } from './resolve-executable.js';
|
|
13
|
+
import { loadShippedCodexHookFiles } from './shipped-codex-hooks.js';
|
|
12
14
|
import { planResolvedCommandSpawn } from './windows-spawn.js';
|
|
13
15
|
/**
|
|
14
16
|
* The home directory every runtime's config is resolved under.
|
|
@@ -84,6 +86,98 @@ function hqProjectHooks(hqRoot, hooks) {
|
|
|
84
86
|
!hook.isManaged &&
|
|
85
87
|
isWithin(path.resolve(hook.sourcePath), codexLayer));
|
|
86
88
|
}
|
|
89
|
+
/** Codex's snake_case spelling of a hook event name (`PreToolUse` -> `pre_tool_use`). */
|
|
90
|
+
function snakeEvent(event) {
|
|
91
|
+
return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
/** Stable JSON for comparing two parsed hook entries regardless of key order. */
|
|
94
|
+
function canonicalJson(value) {
|
|
95
|
+
if (Array.isArray(value))
|
|
96
|
+
return `[${value.map(canonicalJson).join(',')}]`;
|
|
97
|
+
const object = asObject(value);
|
|
98
|
+
if (object) {
|
|
99
|
+
return `{${Object.keys(object)
|
|
100
|
+
.sort()
|
|
101
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
|
|
102
|
+
.join(',')}}`;
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(value) ?? 'null';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Every hook entry in a Codex hooks file (TOML or JSON), keyed by the
|
|
108
|
+
* `<snake_event>:<group>:<hook>` suffix Codex appends to the file path to form
|
|
109
|
+
* a hook key. Unparseable text yields undefined.
|
|
110
|
+
*/
|
|
111
|
+
function codexHookEntries(text, toml) {
|
|
112
|
+
const entries = new Map();
|
|
113
|
+
let doc;
|
|
114
|
+
try {
|
|
115
|
+
doc = toml ? parseToml(text) : JSON.parse(text);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const hooks = asObject(asObject(doc)?.hooks);
|
|
121
|
+
for (const [event, groups] of Object.entries(hooks ?? {})) {
|
|
122
|
+
if (!Array.isArray(groups))
|
|
123
|
+
continue;
|
|
124
|
+
groups.forEach((group, g) => {
|
|
125
|
+
const handlers = asObject(group)?.hooks;
|
|
126
|
+
if (!Array.isArray(handlers))
|
|
127
|
+
return;
|
|
128
|
+
handlers.forEach((handler, h) => {
|
|
129
|
+
entries.set(`${snakeEvent(event)}:${g}:${h}`, canonicalJson(handler));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return entries;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The HQ project hooks that ship in core: those whose entry in
|
|
137
|
+
* `<hqRoot>/.codex/config.toml` or `.codex/hooks.json` is identical, at the
|
|
138
|
+
* same position, to the entry in the shipped copy of that file. A hand-added
|
|
139
|
+
* entry, or a shipped entry the user edited, matches nothing and is left out.
|
|
140
|
+
* When an HQ hook comes from a file the release does not ship, or whose
|
|
141
|
+
* shipped copy does not parse, nothing can be compared and the pass fails.
|
|
142
|
+
*/
|
|
143
|
+
function coreShippedHooks(hqRoot, hooks, shipped) {
|
|
144
|
+
const codexLayer = path.resolve(hqRoot, '.codex');
|
|
145
|
+
const local = new Map();
|
|
146
|
+
const selected = [];
|
|
147
|
+
for (const hook of hqProjectHooks(hqRoot, hooks)) {
|
|
148
|
+
const sourcePath = path.resolve(hook.sourcePath);
|
|
149
|
+
const file = path.relative(codexLayer, sourcePath);
|
|
150
|
+
if (file !== 'config.toml' && file !== 'hooks.json')
|
|
151
|
+
continue;
|
|
152
|
+
const prefix = `${hook.sourcePath}:`;
|
|
153
|
+
if (!hook.key.startsWith(prefix))
|
|
154
|
+
continue;
|
|
155
|
+
const toml = file.endsWith('.toml');
|
|
156
|
+
const shippedText = shipped[file];
|
|
157
|
+
if (shippedText === undefined)
|
|
158
|
+
return { fail: `the installed core release does not ship .codex/${file}` };
|
|
159
|
+
const theirs = codexHookEntries(shippedText, toml);
|
|
160
|
+
if (!theirs)
|
|
161
|
+
return { fail: `the shipped .codex/${file} is not valid ${toml ? 'TOML' : 'JSON'}` };
|
|
162
|
+
let mine = local.get(file);
|
|
163
|
+
if (!mine) {
|
|
164
|
+
let text;
|
|
165
|
+
try {
|
|
166
|
+
text = fs.readFileSync(sourcePath, 'utf8');
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
text = undefined;
|
|
170
|
+
}
|
|
171
|
+
mine = (text === undefined ? undefined : codexHookEntries(text, toml)) ?? new Map();
|
|
172
|
+
local.set(file, mine);
|
|
173
|
+
}
|
|
174
|
+
const position = hook.key.slice(prefix.length);
|
|
175
|
+
const entry = mine.get(position);
|
|
176
|
+
if (entry !== undefined && entry === theirs.get(position))
|
|
177
|
+
selected.push(hook);
|
|
178
|
+
}
|
|
179
|
+
return selected.length === 0 ? { skip: 'no core-shipped HQ hooks discovered' } : { hooks: selected };
|
|
180
|
+
}
|
|
87
181
|
/**
|
|
88
182
|
* How to launch Codex. npm installs Codex on Windows as a `codex.cmd` shim,
|
|
89
183
|
* which `spawn('codex')` cannot find (ENOENT) and Node refuses to run without a
|
|
@@ -203,6 +297,7 @@ export async function createCodexAppServerClient(cwd, executable = 'codex', args
|
|
|
203
297
|
const DEFAULT_DEPS = {
|
|
204
298
|
createCodexClient: createCodexAppServerClient,
|
|
205
299
|
homeDir: defaultHomeDir,
|
|
300
|
+
loadShippedCodexHooks: (hqRoot) => loadShippedCodexHookFiles(hqRoot),
|
|
206
301
|
};
|
|
207
302
|
/** Trust only hooks declared by this HQ root's project `.codex/` layer. */
|
|
208
303
|
export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
@@ -216,6 +311,46 @@ export async function trustCodexProjectHooks(hqRoot, deps = DEFAULT_DEPS) {
|
|
|
216
311
|
: { hooks: projectHooks };
|
|
217
312
|
});
|
|
218
313
|
}
|
|
314
|
+
/** Whether a Codex CLI is on PATH (PATHEXT-expanded on Windows). */
|
|
315
|
+
function codexOnPath() {
|
|
316
|
+
return findExecutableOnPath('codex') !== undefined;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Re-trust the Codex hooks HQ core ships after `hq rescue` re-lays core.
|
|
320
|
+
*
|
|
321
|
+
* Codex trusts a hook by the hash of its current command, so an update that
|
|
322
|
+
* changes a hook command leaves Codex skipping it as untrusted until the next
|
|
323
|
+
* trust pass. Rescue runs that pass itself, for core-shipped hooks only: a
|
|
324
|
+
* hook in `<hqRoot>/.codex/` is trusted only when its entry matches the
|
|
325
|
+
* shipped copy of its file (see {@link coreShippedHooks}), so a hand-added
|
|
326
|
+
* entry is never trusted. Unlike `hq reindex`, rescue does not re-enable a
|
|
327
|
+
* shipped hook the user turned off. Already trusted hooks are left as they
|
|
328
|
+
* are, and a machine without Codex is skipped without starting anything.
|
|
329
|
+
*/
|
|
330
|
+
export async function retrustCodexHooksAfterRescue(hqRoot, deps = DEFAULT_DEPS, codexInstalled = codexOnPath) {
|
|
331
|
+
if (!codexInstalled()) {
|
|
332
|
+
return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'Codex CLI not installed' };
|
|
333
|
+
}
|
|
334
|
+
if (!fs.existsSync(path.join(hqRoot, '.codex'))) {
|
|
335
|
+
return { runtime: 'codex', status: 'skipped', trusted: 0, reason: 'project .codex layer absent' };
|
|
336
|
+
}
|
|
337
|
+
if (!deps.loadShippedCodexHooks) {
|
|
338
|
+
return { runtime: 'codex', status: 'failed', trusted: 0, reason: 'no source for the shipped Codex hooks' };
|
|
339
|
+
}
|
|
340
|
+
let shipped;
|
|
341
|
+
try {
|
|
342
|
+
shipped = await deps.loadShippedCodexHooks(hqRoot);
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
runtime: 'codex',
|
|
347
|
+
status: 'failed',
|
|
348
|
+
trusted: 0,
|
|
349
|
+
reason: `could not read the shipped Codex hooks: ${errorMessage(error)}`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
return trustCodexHooks(hqRoot, deps, (hooks) => coreShippedHooks(hqRoot, hooks, shipped), { enableDisabled: false });
|
|
353
|
+
}
|
|
219
354
|
/**
|
|
220
355
|
* Trust the user-scope hooks `hq install --global --runtime codex` registered
|
|
221
356
|
* in ~/.codex/hooks.json, named by their Codex keys (`<file>:<event>:<group>:<hook>`).
|
|
@@ -234,8 +369,12 @@ export async function trustCodexUserHooks(cwd, keys, deps = DEFAULT_DEPS) {
|
|
|
234
369
|
return { hooks: keys.map((key) => byKey.get(key)) };
|
|
235
370
|
});
|
|
236
371
|
}
|
|
237
|
-
/**
|
|
238
|
-
|
|
372
|
+
/**
|
|
373
|
+
* List hooks for `cwd`, trust the selected ones, then verify. By default a
|
|
374
|
+
* selected hook the user disabled is also re-enabled; `enableDisabled: false`
|
|
375
|
+
* keeps each hook's enabled state as it is.
|
|
376
|
+
*/
|
|
377
|
+
async function trustCodexHooks(cwd, deps, select, { enableDisabled = true } = {}) {
|
|
239
378
|
let client;
|
|
240
379
|
try {
|
|
241
380
|
client = await deps.createCodexClient(cwd);
|
|
@@ -256,17 +395,20 @@ async function trustCodexHooks(cwd, deps, select) {
|
|
|
256
395
|
return { runtime: 'codex', status: 'failed', trusted: 0, reason: selection.fail };
|
|
257
396
|
}
|
|
258
397
|
const selected = selection.hooks;
|
|
259
|
-
// Converge every selected HQ hook to trusted AND
|
|
260
|
-
// already trusted but was toggled off would
|
|
261
|
-
// disabled forever; reindex and install are the
|
|
262
|
-
|
|
398
|
+
// Converge every selected HQ hook to trusted AND (unless the caller opted
|
|
399
|
+
// out) enabled. A hook that is already trusted but was toggled off would
|
|
400
|
+
// otherwise silently stay disabled forever; reindex and install are the
|
|
401
|
+
// convergence points. Rescue opts out and only refreshes trust.
|
|
402
|
+
const pending = selected.filter((hook) => hook.trustStatus === 'untrusted' ||
|
|
403
|
+
hook.trustStatus === 'modified' ||
|
|
404
|
+
(enableDisabled && !hook.enabled));
|
|
263
405
|
if (pending.length === 0) {
|
|
264
406
|
return { runtime: 'codex', status: 'unchanged', trusted: 0 };
|
|
265
407
|
}
|
|
266
408
|
const state = Object.fromEntries(pending.map((hook) => [
|
|
267
409
|
hook.key,
|
|
268
410
|
{
|
|
269
|
-
enabled: true,
|
|
411
|
+
enabled: enableDisabled ? true : hook.enabled,
|
|
270
412
|
trusted_hash: hook.currentHash,
|
|
271
413
|
},
|
|
272
414
|
]));
|
|
@@ -279,7 +421,7 @@ async function trustCodexHooks(cwd, deps, select) {
|
|
|
279
421
|
const stillPending = pending
|
|
280
422
|
.filter((hook) => {
|
|
281
423
|
const after = verifiedByKey.get(hook.key);
|
|
282
|
-
return after?.trustStatus !== 'trusted' || after.enabled !==
|
|
424
|
+
return after?.trustStatus !== 'trusted' || after.enabled !== (enableDisabled || hook.enabled);
|
|
283
425
|
})
|
|
284
426
|
.map((hook) => hook.key);
|
|
285
427
|
if (verified.errors.length > 0 || stillPending.length > 0) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ShippedCodexHookFiles } from './hook-trust.js';
|
|
2
|
+
/** The only repos rescue installs core from: the default and `--staging`. */
|
|
3
|
+
export declare const PROD_SOURCE = "indigoai-us/hq-core";
|
|
4
|
+
export declare const STAGING_SOURCE = "indigoai-us/hq-core-staging";
|
|
5
|
+
/** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
|
|
6
|
+
export declare function resolveGhToken(): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* The upstream repo and commit the last rescue installed, from `core/core.yaml`.
|
|
9
|
+
* Only the HQ core repos are accepted.
|
|
10
|
+
*/
|
|
11
|
+
export declare function readCoreSyncStamp(hqRoot: string): {
|
|
12
|
+
source: string;
|
|
13
|
+
sha: string;
|
|
14
|
+
};
|
|
15
|
+
/** The shipped `.codex/config.toml` and `.codex/hooks.json` for this HQ root's installed core. */
|
|
16
|
+
export declare function loadShippedCodexHookFiles(hqRoot: string, token?: string | undefined, fetchImpl?: typeof fetch): Promise<ShippedCodexHookFiles>;
|
|
17
|
+
//# sourceMappingURL=shipped-codex-hooks.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the Codex hook files HQ core ships, at the exact commit a rescue
|
|
3
|
+
* installed, so the post-rescue re-trust can tell core-shipped hook entries
|
|
4
|
+
* from ones a user added by hand.
|
|
5
|
+
*
|
|
6
|
+
* The commit comes from the sync stamp rescue writes to `core/core.yaml`
|
|
7
|
+
* (`replaced_from_source.source` + `last_sync_sha`); the files come from the
|
|
8
|
+
* GitHub contents API for that repo and commit.
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'child_process';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import * as path from 'path';
|
|
13
|
+
import * as yaml from 'js-yaml';
|
|
14
|
+
/** The only repos rescue installs core from: the default and `--staging`. */
|
|
15
|
+
export const PROD_SOURCE = 'indigoai-us/hq-core';
|
|
16
|
+
export const STAGING_SOURCE = 'indigoai-us/hq-core-staging';
|
|
17
|
+
const CORE_SOURCES = [PROD_SOURCE, STAGING_SOURCE];
|
|
18
|
+
/** Resolve a GitHub token: prefer `gh auth token`, fall back to env. */
|
|
19
|
+
export function resolveGhToken() {
|
|
20
|
+
try {
|
|
21
|
+
const res = spawnSync('gh', ['auth', 'token'], { encoding: 'utf8' });
|
|
22
|
+
if (res.status === 0) {
|
|
23
|
+
const tok = res.stdout.trim();
|
|
24
|
+
if (tok)
|
|
25
|
+
return tok;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// gh not installed — fall through to env.
|
|
30
|
+
}
|
|
31
|
+
return process.env.GH_TOKEN || process.env.GITHUB_TOKEN || undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The upstream repo and commit the last rescue installed, from `core/core.yaml`.
|
|
35
|
+
* Only the HQ core repos are accepted.
|
|
36
|
+
*/
|
|
37
|
+
export function readCoreSyncStamp(hqRoot) {
|
|
38
|
+
const file = path.join(hqRoot, 'core', 'core.yaml');
|
|
39
|
+
const doc = yaml.load(fs.readFileSync(file, 'utf8'));
|
|
40
|
+
const stamp = doc?.replaced_from_source;
|
|
41
|
+
const source = stamp?.source;
|
|
42
|
+
const sha = stamp?.last_sync_sha;
|
|
43
|
+
if (typeof source !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(source)) {
|
|
44
|
+
throw new Error('core/core.yaml has no replaced_from_source.source repo');
|
|
45
|
+
}
|
|
46
|
+
// Anyone who can edit core.yaml could otherwise point the comparison (and
|
|
47
|
+
// the GitHub token) at a repo of their own and make a foreign hook "shipped".
|
|
48
|
+
if (!CORE_SOURCES.includes(source)) {
|
|
49
|
+
throw new Error(`core/core.yaml names ${source}, which is not an HQ core repo (${CORE_SOURCES.join(' or ')})`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof sha !== 'string' || !/^[0-9a-f]{40}$/.test(sha)) {
|
|
52
|
+
throw new Error('core/core.yaml has no replaced_from_source.last_sync_sha commit');
|
|
53
|
+
}
|
|
54
|
+
return { source, sha };
|
|
55
|
+
}
|
|
56
|
+
// Same bound as rescue's own GitHub requests.
|
|
57
|
+
const GITHUB_REQUEST_TIMEOUT_MS = 30_000;
|
|
58
|
+
/** One file's raw text at `sha`, or undefined when the commit has no such file. */
|
|
59
|
+
async function fetchRawFile(repo, sha, file, token, fetchImpl) {
|
|
60
|
+
const headers = {
|
|
61
|
+
Accept: 'application/vnd.github.raw',
|
|
62
|
+
'User-Agent': 'hq-cli-rescue',
|
|
63
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
64
|
+
};
|
|
65
|
+
if (token)
|
|
66
|
+
headers.Authorization = `Bearer ${token}`;
|
|
67
|
+
const url = `https://api.github.com/repos/${repo}/contents/${file}?ref=${sha}`;
|
|
68
|
+
const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(GITHUB_REQUEST_TIMEOUT_MS) });
|
|
69
|
+
if (response.status === 404)
|
|
70
|
+
return undefined;
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
throw new Error(`GitHub contents ${file}@${sha.slice(0, 12)} for ${repo}: HTTP ${response.status}`);
|
|
73
|
+
return response.text();
|
|
74
|
+
}
|
|
75
|
+
/** The shipped `.codex/config.toml` and `.codex/hooks.json` for this HQ root's installed core. */
|
|
76
|
+
export async function loadShippedCodexHookFiles(hqRoot, token = resolveGhToken(), fetchImpl = fetch) {
|
|
77
|
+
const { source, sha } = readCoreSyncStamp(hqRoot);
|
|
78
|
+
const shipped = {};
|
|
79
|
+
for (const name of ['config.toml', 'hooks.json']) {
|
|
80
|
+
const text = await fetchRawFile(source, sha, `.codex/${name}`, token, fetchImpl);
|
|
81
|
+
if (text !== undefined)
|
|
82
|
+
shipped[name] = text;
|
|
83
|
+
}
|
|
84
|
+
return shipped;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=shipped-codex-hooks.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.153",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
53
53
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
54
54
|
"@aws-sdk/dsql-signer": "^3.1146.0",
|
|
55
|
-
"@indigoai-us/hq-cloud": "~6.18.
|
|
55
|
+
"@indigoai-us/hq-cloud": "~6.18.91",
|
|
56
56
|
"@indigoai-us/hq-flags-client": "^0.1.2",
|
|
57
57
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
58
58
|
"@sentry/node": "^10.49.0",
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.153",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.153",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.153",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.153",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.153",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.153"
|
|
123
123
|
}
|
|
124
124
|
}
|