@indigoai-us/hq-cli 5.345.81 → 5.345.83
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 +17 -0
- package/assets/scaffold/core/scripts/lib/forwarder.sh +142 -0
- package/dist/command-catalog.generated.d.ts +4 -0
- package/dist/command-catalog.generated.js +5 -0
- package/dist/commands/core.js +1 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +2 -0
- package/dist/commands/storage.d.ts +50 -0
- package/dist/commands/storage.js +307 -67
- package/dist/lib/doctor/fix/remediation.js +10 -0
- package/dist/lib/doctor/json-output.d.ts +16 -0
- package/dist/lib/doctor/json-output.js +10 -0
- package/dist/lib/lanes/hooks-install.js +204 -28
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.83] — 2026-10-11
|
|
6
|
+
- HQ-root scaffolds now package the shared runtime used by generated shell forwarders.
|
|
7
|
+
- Lanes hook installation now replaces stale hq-cli drain registrations across
|
|
8
|
+
Claude settings and Codex config, including entries whose old install path no
|
|
9
|
+
longer exists. Other hooks are preserved, duplicate drains are removed, and a
|
|
10
|
+
repeat install leaves the registration file byte-identical.
|
|
11
|
+
|
|
12
|
+
## [5.345.82] — 2026-10-11
|
|
13
|
+
- Session-start health no longer treats an ordinary HQ Core upgrade warning
|
|
14
|
+
as an unrepaired doctor finding. `hq doctor` still shows the upgrade;
|
|
15
|
+
`--fix` still cannot apply it.
|
|
16
|
+
- `hq storage status` runs the local report, the offload scan and the cloud
|
|
17
|
+
listing at the same time, and lists cloud file history by folder in parallel
|
|
18
|
+
instead of one page at a time. Cloud credentials are vended right before the
|
|
19
|
+
first listing call and vended again before they expire, so a long scan or
|
|
20
|
+
dry run no longer fails with "The provided token has expired."
|
|
21
|
+
|
|
5
22
|
## [5.345.81] — 2026-10-11
|
|
6
23
|
- HQ Storage (`hq storage status|prune|offload|fetch`) is available behind the Indigo-only `hq-storage` flag and fails closed when the flag is off. Requires @indigoai-us/hq-cloud 6.18.61, whose sync skips `.hq-offload/` and supports cloud-only `.hqcloud` placeholders.
|
|
7
24
|
- `hq storage status` reports local autosave history and cloud file-version usage per company, grouped by age. `hq storage prune` deletes old noncurrent cloud file versions in companies where you are an owner or admin, and never touches the current version. A version's age counts from when it was replaced, so a recent undo copy of an old file is kept. `status --json` marks each company with `can_delete`, and still shows local usage when you are signed out.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared runtime for generated hq CLI forwarders.
|
|
3
|
+
|
|
4
|
+
forwarder_exec() {
|
|
5
|
+
local script_name="$1" min_cli="$2" root_mode="$3" command="$4" stdin_closed="$5"
|
|
6
|
+
shift 5
|
|
7
|
+
local forwarder_library_dir="${BASH_SOURCE[0]%/*}"
|
|
8
|
+
local path_rule index value path_option path_value
|
|
9
|
+
local -a forwarded_args path_rules path_options path_values converted_values command_parts
|
|
10
|
+
forwarded_args=("$@")
|
|
11
|
+
local forwarded_arg_count=$#
|
|
12
|
+
|
|
13
|
+
if [ "$root_mode" = live-project ]; then
|
|
14
|
+
HQ_ROOT="${HQ_ROOT:-${CLAUDE_PROJECT_DIR:-$(cd "$SCRIPT_DIR/../.." && pwd)}}"
|
|
15
|
+
elif [ "$root_mode" = live ]; then
|
|
16
|
+
HQ_ROOT="${HQ_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
if ! command -v hq >/dev/null 2>&1; then
|
|
20
|
+
printf "%s: requires the hq CLI — this script's implementation now ships with it.\n" "$script_name" >&2
|
|
21
|
+
printf 'Install it with: npm install -g @indigoai-us/hq-cli\n' >&2
|
|
22
|
+
exit 127
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
[ "$forwarder_library_dir" != "${BASH_SOURCE[0]}" ] || forwarder_library_dir=.
|
|
26
|
+
if [ -f "$forwarder_library_dir/hq-cli-floor.sh" ]; then
|
|
27
|
+
# shellcheck source=lib/hq-cli-floor.sh
|
|
28
|
+
. "$forwarder_library_dir/hq-cli-floor.sh"
|
|
29
|
+
hq_cli_floor_check "$script_name" "$min_cli"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
if [ "$stdin_closed" = true ]; then
|
|
33
|
+
if ! { true 3<&0; } 2>/dev/null; then
|
|
34
|
+
export HQ_CORE_STDIN_CLOSED=1
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
|
|
39
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
40
|
+
HQ_ROOT="$(cygpath -m "$HQ_ROOT")"
|
|
41
|
+
fi
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
45
|
+
IFS=';' read -r -a path_rules <<< "$PATH_OPERANDS"
|
|
46
|
+
for path_rule in "${path_rules[@]}"; do
|
|
47
|
+
case "$path_rule" in
|
|
48
|
+
none) ;;
|
|
49
|
+
position:*)
|
|
50
|
+
index="${path_rule#position:}"
|
|
51
|
+
if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
|
|
52
|
+
;;
|
|
53
|
+
first-nonoption)
|
|
54
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
55
|
+
case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index"; break ;; esac
|
|
56
|
+
done
|
|
57
|
+
;;
|
|
58
|
+
all-nonoptions)
|
|
59
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
60
|
+
case "${forwarded_args[index]}" in --*) ;; *) convert_forwarded_path "$index" ;; esac
|
|
61
|
+
done
|
|
62
|
+
;;
|
|
63
|
+
options:*)
|
|
64
|
+
IFS=, read -r -a path_options <<< "${path_rule#options:}"
|
|
65
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
66
|
+
for path_option in "${path_options[@]}"; do
|
|
67
|
+
case "${forwarded_args[index]}" in
|
|
68
|
+
"$path_option")
|
|
69
|
+
index=$((index + 1))
|
|
70
|
+
if (( index < forwarded_arg_count )); then convert_forwarded_path "$index"; fi
|
|
71
|
+
break
|
|
72
|
+
;;
|
|
73
|
+
"$path_option"=*)
|
|
74
|
+
value="${forwarded_args[index]#*=}"
|
|
75
|
+
case "$value" in /*) forwarded_args[index]="${path_option}=$(cygpath -m "$value")" ;; esac
|
|
76
|
+
break
|
|
77
|
+
;;
|
|
78
|
+
esac
|
|
79
|
+
done
|
|
80
|
+
done
|
|
81
|
+
;;
|
|
82
|
+
comma:*)
|
|
83
|
+
path_option="${path_rule#comma:}"
|
|
84
|
+
for ((index = 0; index < forwarded_arg_count; index++)); do
|
|
85
|
+
case "${forwarded_args[index]}" in
|
|
86
|
+
"$path_option")
|
|
87
|
+
index=$((index + 1))
|
|
88
|
+
if (( index < forwarded_arg_count )); then
|
|
89
|
+
IFS=, read -r -a path_values <<< "${forwarded_args[index]}"
|
|
90
|
+
converted_values=()
|
|
91
|
+
for path_value in "${path_values[@]}"; do
|
|
92
|
+
case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
|
|
93
|
+
done
|
|
94
|
+
value="$(IFS=,; printf '%s' "${converted_values[*]}")"
|
|
95
|
+
forwarded_args[index]="$value"
|
|
96
|
+
fi
|
|
97
|
+
break
|
|
98
|
+
;;
|
|
99
|
+
"$path_option"=*)
|
|
100
|
+
value="${forwarded_args[index]#*=}"
|
|
101
|
+
case "$value" in
|
|
102
|
+
/*,*|/*)
|
|
103
|
+
IFS=, read -r -a path_values <<< "$value"
|
|
104
|
+
converted_values=()
|
|
105
|
+
for path_value in "${path_values[@]}"; do
|
|
106
|
+
case "$path_value" in /*) converted_values+=("$(cygpath -m "$path_value")") ;; *) converted_values+=("$path_value") ;; esac
|
|
107
|
+
done
|
|
108
|
+
value="$(IFS=,; printf '%s' "${converted_values[*]}")"
|
|
109
|
+
forwarded_args[index]="${path_option}=$value"
|
|
110
|
+
;;
|
|
111
|
+
esac
|
|
112
|
+
break
|
|
113
|
+
;;
|
|
114
|
+
esac
|
|
115
|
+
done
|
|
116
|
+
;;
|
|
117
|
+
*) printf 'unknown path_operands rule: %s\n' "$path_rule" >&2; exit 70 ;;
|
|
118
|
+
esac
|
|
119
|
+
done
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
IFS=' ' read -r -a command_parts <<< "$command"
|
|
123
|
+
if [ "$root_mode" = live ] || [ "$root_mode" = live-project ]; then
|
|
124
|
+
if [ "$forwarded_arg_count" -gt 0 ]; then
|
|
125
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}" "${forwarded_args[@]}"
|
|
126
|
+
else
|
|
127
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core --hq-root "$HQ_ROOT" "${command_parts[@]}"
|
|
128
|
+
fi
|
|
129
|
+
elif [ "$forwarded_arg_count" -gt 0 ]; then
|
|
130
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}" "${forwarded_args[@]}"
|
|
131
|
+
else
|
|
132
|
+
MSYS2_ARG_CONV_EXCL='*' exec hq core "${command_parts[@]}"
|
|
133
|
+
fi
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
convert_forwarded_path() {
|
|
137
|
+
local arg_index="$1"
|
|
138
|
+
local value="${forwarded_args[$arg_index]}"
|
|
139
|
+
case "$value" in
|
|
140
|
+
/*) forwarded_args[$arg_index]="$(cygpath -m "$value")" ;;
|
|
141
|
+
esac
|
|
142
|
+
}
|
|
@@ -2931,6 +2931,10 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2931
2931
|
readonly flags: "--company <slug>";
|
|
2932
2932
|
readonly description: "Only this company (must be one of yours)";
|
|
2933
2933
|
readonly hidden: false;
|
|
2934
|
+
}, {
|
|
2935
|
+
readonly flags: "--no-cache";
|
|
2936
|
+
readonly description: "List cloud file history live instead of using figures from the last 15 minutes";
|
|
2937
|
+
readonly hidden: false;
|
|
2934
2938
|
}];
|
|
2935
2939
|
readonly subcommands: readonly [];
|
|
2936
2940
|
}, {
|
|
@@ -3533,6 +3533,11 @@ export const COMMAND_CATALOG = [
|
|
|
3533
3533
|
"flags": "--company <slug>",
|
|
3534
3534
|
"description": "Only this company (must be one of yours)",
|
|
3535
3535
|
"hidden": false
|
|
3536
|
+
},
|
|
3537
|
+
{
|
|
3538
|
+
"flags": "--no-cache",
|
|
3539
|
+
"description": "List cloud file history live instead of using figures from the last 15 minutes",
|
|
3540
|
+
"hidden": false
|
|
3536
3541
|
}
|
|
3537
3542
|
],
|
|
3538
3543
|
"subcommands": []
|
package/dist/commands/core.js
CHANGED
|
@@ -268,6 +268,7 @@ export const SCAFFOLD_ONLY_ASSETS = [
|
|
|
268
268
|
{ asset: "core/scripts/lib/session-scope-capability.sh", root: "live", summary: "Session scope-capability lib sourced by hq-session" },
|
|
269
269
|
{ asset: "core/scripts/lib/session-id.sh", root: "live", summary: "Session-id resolver lib sourced by hq-session" },
|
|
270
270
|
{ asset: "core/scripts/lib/hq-cli-floor.sh", root: "live", summary: "Shared CLI version-floor helper sourced by generated forwarders" },
|
|
271
|
+
{ asset: "core/scripts/lib/forwarder.sh", root: "live", summary: "Shared runtime sourced by generated scaffold forwarders" },
|
|
271
272
|
{ asset: "core/scripts/lib/work-mesh-enqueue.sh", root: "live", summary: "Work Mesh Live spool enqueue lib sourced by hooks" },
|
|
272
273
|
{ asset: "core/scripts/lib/work-mesh-live-hook.sh", root: "live", summary: "Work Mesh Live hook helpers sourced by rebind" },
|
|
273
274
|
{ asset: "core/scripts/lib/work-mesh-live-rebind.sh", root: "live", summary: "Work Mesh Live session rebind library sourced by hooks and the native rollback command" },
|
|
@@ -59,6 +59,11 @@ export interface RunDoctorOptions {
|
|
|
59
59
|
resolveClientHealthReasonCodesForCompany?: (companySlug: string | null) => Promise<boolean>;
|
|
60
60
|
/** Resolve the separate status-report flag per company in sync results. */
|
|
61
61
|
resolveClientHealthCheckStatusForCompany?: (companySlug: string | null) => Promise<boolean>;
|
|
62
|
+
/**
|
|
63
|
+
* Omit ordinary hq-core upgrade WARNs from `--json` (the SessionStart health
|
|
64
|
+
* hook). Default `--json` and text output still include them.
|
|
65
|
+
*/
|
|
66
|
+
clientHealthReport?: boolean;
|
|
62
67
|
/** In text mode, also print every PASS result. Default: false. */
|
|
63
68
|
verbose?: boolean;
|
|
64
69
|
/** In text mode, emit ANSI colour. Default: false (no escape sequences). */
|
package/dist/commands/doctor.js
CHANGED
|
@@ -181,6 +181,7 @@ export async function runDoctor(options = {}) {
|
|
|
181
181
|
scope,
|
|
182
182
|
clientHealthReasonCodesEnabledCheckIds,
|
|
183
183
|
clientHealthCheckStatusEnabledCheckIds,
|
|
184
|
+
clientHealthReport: options.clientHealthReport === true,
|
|
184
185
|
})));
|
|
185
186
|
}
|
|
186
187
|
else {
|
|
@@ -437,6 +438,7 @@ export function registerDoctorCommand(program) {
|
|
|
437
438
|
only: parseOnly(opts.only),
|
|
438
439
|
resolveClientHealthReasonCodesForCompany: clientHealthReasonCodesResolver,
|
|
439
440
|
resolveClientHealthCheckStatusForCompany: clientHealthCheckStatusResolver,
|
|
441
|
+
clientHealthReport: opts.clientHealthReport === true,
|
|
440
442
|
});
|
|
441
443
|
// Set the exit code rather than calling process.exit, so the CLI's
|
|
442
444
|
// normal shutdown (telemetry flush) still runs. Non-zero means either an
|
|
@@ -51,6 +51,8 @@ export interface CloudCompanyStatus {
|
|
|
51
51
|
delete_markers: number;
|
|
52
52
|
tranches: CloudTranche[];
|
|
53
53
|
error?: string;
|
|
54
|
+
/** When these figures were listed (ISO). Older than now when served from the cache. */
|
|
55
|
+
listed_at?: string;
|
|
54
56
|
}
|
|
55
57
|
export interface StorageStatus {
|
|
56
58
|
local: LocalReport;
|
|
@@ -112,7 +114,24 @@ export interface StorageDeps {
|
|
|
112
114
|
log: (line: string) => void;
|
|
113
115
|
/** Offload section for `status`. Absent in deps that only prune. */
|
|
114
116
|
getOffload?: () => Promise<OffloadStatus>;
|
|
117
|
+
/** Cache of cloud status per company (see cloudStatusCache). Absent = always list. */
|
|
118
|
+
cloudCache?: CloudStatusCache;
|
|
115
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Listing every version of a large vault takes minutes (about 2 for 2.4M
|
|
122
|
+
* versions, most of it parsing S3's XML), so `status` keeps each company's
|
|
123
|
+
* cloud figures for CLOUD_CACHE_TTL_MS. Only complete, successful listings are
|
|
124
|
+
* kept; a real cloud prune drops the company's entry; dry runs and real prunes
|
|
125
|
+
* always list live.
|
|
126
|
+
*/
|
|
127
|
+
export interface CloudStatusCache {
|
|
128
|
+
read(company: CompanyRef): CloudCompanyStatus | undefined;
|
|
129
|
+
write(company: CompanyRef, status: CloudCompanyStatus): void;
|
|
130
|
+
drop(company: CompanyRef): void;
|
|
131
|
+
}
|
|
132
|
+
export declare const CLOUD_CACHE_TTL_MS: number;
|
|
133
|
+
/** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
|
|
134
|
+
export declare function cloudStatusCache(hqRoot: string, now: () => Date, ttlMs?: number): CloudStatusCache;
|
|
116
135
|
export declare function localScriptPath(hqRoot: string): string;
|
|
117
136
|
/**
|
|
118
137
|
* Core `drop-backups --expired` right before a real local prune; undefined
|
|
@@ -148,6 +167,16 @@ export interface VersionEntry {
|
|
|
148
167
|
* to its own LastModified.
|
|
149
168
|
*/
|
|
150
169
|
export declare function annotateNoncurrentSince(entries: VersionEntry[]): VersionEntry[];
|
|
170
|
+
/** Folder levels split into parallel listings (`a/`, then `a/b/`). */
|
|
171
|
+
export declare const LIST_SPLIT_DEPTH = 2;
|
|
172
|
+
/** Listings in flight at once per bucket. */
|
|
173
|
+
export declare const LIST_CONCURRENCY = 32;
|
|
174
|
+
/**
|
|
175
|
+
* A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
|
|
176
|
+
* order, or undefined when none is found. Used to split one long listing into
|
|
177
|
+
* two that run at the same time.
|
|
178
|
+
*/
|
|
179
|
+
export declare function keyBetween(lo: string, hi: string): string | undefined;
|
|
151
180
|
/** Plain message when S3 refuses a version delete for one company. */
|
|
152
181
|
export declare function deleteDeniedMessage(slug: string): string;
|
|
153
182
|
export declare function describeCloudError(err: unknown, action: "list" | "delete", slug?: string): string;
|
|
@@ -162,6 +191,7 @@ export declare function selectPrunable(versions: VersionEntry[], cutoff: Date):
|
|
|
162
191
|
export declare function pruneCloud(company: CompanyRef, cutoff: Date, dryRun: boolean, deps: StorageDeps): Promise<CloudPruneResult>;
|
|
163
192
|
export declare function runStorageStatus(opts: {
|
|
164
193
|
company?: string;
|
|
194
|
+
noCache?: boolean;
|
|
165
195
|
}, deps: StorageDeps): Promise<StorageStatus>;
|
|
166
196
|
export declare function parseCutoff(value: string, flag: string): Date;
|
|
167
197
|
export declare function runStoragePrune(opts: {
|
|
@@ -195,5 +225,25 @@ export declare const NEEDS_YES = "No terminal to confirm in. Re-run with --yes t
|
|
|
195
225
|
export declare function confirmOnTty(message: string, input?: NodeJS.ReadableStream & {
|
|
196
226
|
isTTY?: boolean;
|
|
197
227
|
}, output?: NodeJS.WritableStream): Promise<boolean>;
|
|
228
|
+
/** STS credentials from one vend, with the time they stop working. */
|
|
229
|
+
export interface VendedCredentials {
|
|
230
|
+
accessKeyId: string;
|
|
231
|
+
secretAccessKey: string;
|
|
232
|
+
sessionToken: string;
|
|
233
|
+
expiresAt?: string;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* AWS credential provider that vends fresh credentials on first use and again
|
|
237
|
+
* whenever the previous ones are within `marginMs` of expiring. Each vend calls
|
|
238
|
+
* `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
|
|
239
|
+
* session there), so a cloud listing or delete that starts after a long local
|
|
240
|
+
* scan, or that itself outlasts one STS session, never sends an expired token.
|
|
241
|
+
*/
|
|
242
|
+
export declare function refreshingVendedCredentials(vend: () => Promise<VendedCredentials>, now?: () => number, marginMs?: number): () => Promise<{
|
|
243
|
+
accessKeyId: string;
|
|
244
|
+
secretAccessKey: string;
|
|
245
|
+
sessionToken: string;
|
|
246
|
+
expiration?: Date;
|
|
247
|
+
}>;
|
|
198
248
|
export declare function registerStorageCommand(program: Command): Command;
|
|
199
249
|
//# sourceMappingURL=storage.d.ts.map
|
package/dist/commands/storage.js
CHANGED
|
@@ -56,6 +56,45 @@ export function canDeleteCloudHistory(company) {
|
|
|
56
56
|
return company.role !== undefined && DELETE_ROLES.has(company.role);
|
|
57
57
|
}
|
|
58
58
|
export const DELETE_ROLE_REASON = "Only company owners and admins can delete cloud file history.";
|
|
59
|
+
export const CLOUD_CACHE_TTL_MS = 15 * 60 * 1000;
|
|
60
|
+
/** File cache under <hq>/workspace/.storage-cache/cloud-<company uid>.json. */
|
|
61
|
+
export function cloudStatusCache(hqRoot, now, ttlMs = CLOUD_CACHE_TTL_MS) {
|
|
62
|
+
const dir = path.join(hqRoot, "workspace", ".storage-cache");
|
|
63
|
+
const file = (c) => path.join(dir, `cloud-${c.uid.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
64
|
+
return {
|
|
65
|
+
read(c) {
|
|
66
|
+
try {
|
|
67
|
+
const raw = JSON.parse(fs.readFileSync(file(c), "utf8"));
|
|
68
|
+
const at = Date.parse(raw.listed_at ?? "");
|
|
69
|
+
const age = now().getTime() - at;
|
|
70
|
+
if (raw.uid !== c.uid || raw.available !== true || Number.isNaN(at) || age < 0 || age >= ttlMs)
|
|
71
|
+
return undefined;
|
|
72
|
+
const { uid: _uid, ...status } = raw;
|
|
73
|
+
// Role can change between runs; it comes from the live membership.
|
|
74
|
+
return { ...status, company: c.slug, can_delete: canDeleteCloudHistory(c) };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
write(c, status) {
|
|
81
|
+
if (!status.available)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
const tmp = `${file(c)}.tmp.${process.pid}`;
|
|
86
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...status, uid: c.uid }));
|
|
87
|
+
fs.renameSync(tmp, file(c));
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A cache that cannot be written only costs the next status a listing.
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
drop(c) {
|
|
94
|
+
fs.rmSync(file(c), { force: true });
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
59
98
|
// ── Local (hq-core script) ──────────────────────────────────────────────────
|
|
60
99
|
export function localScriptPath(hqRoot) {
|
|
61
100
|
return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
|
|
@@ -149,52 +188,195 @@ export function annotateNoncurrentSince(entries) {
|
|
|
149
188
|
function noncurrentAge(v) {
|
|
150
189
|
return v.noncurrentSince ?? v.lastModified;
|
|
151
190
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
191
|
+
function toEntries(page) {
|
|
192
|
+
const out = [];
|
|
193
|
+
for (const v of page.Versions ?? []) {
|
|
194
|
+
if (!v.Key || !v.VersionId)
|
|
195
|
+
continue;
|
|
196
|
+
out.push({
|
|
197
|
+
key: v.Key,
|
|
198
|
+
versionId: v.VersionId,
|
|
199
|
+
isLatest: Boolean(v.IsLatest),
|
|
200
|
+
lastModified: v.LastModified ? new Date(v.LastModified) : new Date(0),
|
|
201
|
+
size: v.Size ?? 0,
|
|
202
|
+
isDeleteMarker: false,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
for (const m of page.DeleteMarkers ?? []) {
|
|
206
|
+
if (!m.Key || !m.VersionId)
|
|
207
|
+
continue;
|
|
208
|
+
out.push({
|
|
209
|
+
key: m.Key,
|
|
210
|
+
versionId: m.VersionId,
|
|
211
|
+
isLatest: Boolean(m.IsLatest),
|
|
212
|
+
lastModified: m.LastModified ? new Date(m.LastModified) : new Date(0),
|
|
213
|
+
size: 0,
|
|
214
|
+
isDeleteMarker: true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
/** Folder levels split into parallel listings (`a/`, then `a/b/`). */
|
|
220
|
+
export const LIST_SPLIT_DEPTH = 2;
|
|
221
|
+
/** Listings in flight at once per bucket. */
|
|
222
|
+
export const LIST_CONCURRENCY = 32;
|
|
223
|
+
function utf8Compare(a, b) {
|
|
224
|
+
return Buffer.compare(Buffer.from(a), Buffer.from(b));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A printable-ASCII key strictly between `lo` and `hi` in S3's (UTF-8 byte)
|
|
228
|
+
* order, or undefined when none is found. Used to split one long listing into
|
|
229
|
+
* two that run at the same time.
|
|
230
|
+
*/
|
|
231
|
+
export function keyBetween(lo, hi) {
|
|
232
|
+
const a = Buffer.from(lo);
|
|
233
|
+
const b = Buffer.from(hi);
|
|
234
|
+
const FIRST = 0x20;
|
|
235
|
+
const LAST = 0x7e;
|
|
236
|
+
let k = 0;
|
|
237
|
+
while (k < a.length && k < b.length && a[k] === b[k])
|
|
238
|
+
k++;
|
|
239
|
+
const out = [...a.subarray(0, k)];
|
|
240
|
+
// Walk down from the first differing byte, keeping `lo`'s bytes while the
|
|
241
|
+
// gap is too small, until a byte strictly between both bounds fits.
|
|
242
|
+
for (let i = k; i < k + 8; i++) {
|
|
243
|
+
const ca = i < a.length ? a[i] : FIRST - 1;
|
|
244
|
+
const cb = i === k ? (i < b.length ? b[i] : LAST + 1) : LAST + 1;
|
|
245
|
+
const loB = Math.max(ca, FIRST - 1);
|
|
246
|
+
const hiB = Math.min(cb, LAST + 1);
|
|
247
|
+
if (hiB - loB >= 2) {
|
|
248
|
+
out.push((loB + hiB) >> 1);
|
|
249
|
+
const bytes = Buffer.from(out);
|
|
250
|
+
const mid = bytes.toString("utf8");
|
|
251
|
+
// The kept part of `lo` may end inside a multi-byte character.
|
|
252
|
+
if (!Buffer.from(mid).equals(bytes))
|
|
253
|
+
return undefined;
|
|
254
|
+
return utf8Compare(lo, mid) < 0 && utf8Compare(mid, hi) < 0 ? mid : undefined;
|
|
172
255
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
256
|
+
if (i >= a.length || a[i] < FIRST || a[i] > LAST)
|
|
257
|
+
return undefined;
|
|
258
|
+
out.push(a[i]);
|
|
259
|
+
}
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Every version and delete marker in the bucket. One ListObjectVersions call
|
|
264
|
+
* returns at most 1,000 entries, so a sequential walk of a vault with millions
|
|
265
|
+
* of versions takes many minutes (and outlives a 15-minute STS session). The
|
|
266
|
+
* walk runs up to LIST_CONCURRENCY listings at once:
|
|
267
|
+
* - down to LIST_SPLIT_DEPTH levels a folder is listed with a "/" delimiter
|
|
268
|
+
* (its own entries plus its subfolders, each queued as its own listing);
|
|
269
|
+
* - deeper folders are listed whole, as key ranges: while listings are idle,
|
|
270
|
+
* a long range hands its second half (split at keyBetween) to a new one;
|
|
271
|
+
* - a vault policy can refuse a Prefix listing of one folder (private DM
|
|
272
|
+
* folders) while allowing the plain listing the sequential walk used, so a
|
|
273
|
+
* refused folder is listed as a key range of the plain listing instead.
|
|
274
|
+
* Ranges never overlap and every version of a key falls in exactly one, so the
|
|
275
|
+
* result is the same set as one flat listing. Any other failure fails the walk.
|
|
276
|
+
*/
|
|
277
|
+
async function listAllVersions(store) {
|
|
278
|
+
const all = [];
|
|
279
|
+
const queue = [{ prefix: "", depth: 0, split: LIST_SPLIT_DEPTH > 0, usePrefix: true }];
|
|
280
|
+
let active = 0;
|
|
281
|
+
let failed;
|
|
282
|
+
const runOne = async (w) => {
|
|
283
|
+
const found = [];
|
|
284
|
+
const children = [];
|
|
285
|
+
let hi = w.hi;
|
|
286
|
+
let keyMarker = w.lo ?? (w.usePrefix || !w.prefix ? undefined : w.prefix.slice(0, -1));
|
|
287
|
+
let versionIdMarker;
|
|
288
|
+
let done = false;
|
|
289
|
+
try {
|
|
290
|
+
do {
|
|
291
|
+
const page = await store.s3.send(new ListObjectVersionsCommand({
|
|
292
|
+
Bucket: store.bucket,
|
|
293
|
+
KeyMarker: keyMarker,
|
|
294
|
+
VersionIdMarker: versionIdMarker,
|
|
295
|
+
...(w.usePrefix && w.prefix ? { Prefix: w.prefix } : {}),
|
|
296
|
+
...(w.split ? { Delimiter: "/" } : {}),
|
|
297
|
+
}));
|
|
298
|
+
for (const e of toEntries(page)) {
|
|
299
|
+
if (!e.key.startsWith(w.prefix)) {
|
|
300
|
+
// Only the plain listing returns keys outside the folder.
|
|
301
|
+
if (utf8Compare(e.key, w.prefix) > 0)
|
|
302
|
+
done = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (hi !== undefined && utf8Compare(e.key, hi) > 0) {
|
|
306
|
+
done = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
found.push(e);
|
|
310
|
+
}
|
|
311
|
+
if (w.split) {
|
|
312
|
+
for (const cp of page.CommonPrefixes ?? []) {
|
|
313
|
+
if (cp.Prefix && cp.Prefix !== w.prefix) {
|
|
314
|
+
const depth = w.depth + 1;
|
|
315
|
+
children.push({ prefix: cp.Prefix, depth, split: depth < LIST_SPLIT_DEPTH, usePrefix: w.usePrefix });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (page.IsTruncated && !page.NextKeyMarker) {
|
|
320
|
+
throw new Error("File history listing was cut short (no next marker). Nothing was changed.");
|
|
321
|
+
}
|
|
322
|
+
keyMarker = page.IsTruncated && !done ? page.NextKeyMarker : undefined;
|
|
323
|
+
versionIdMarker = page.IsTruncated && !done ? page.NextVersionIdMarker : undefined;
|
|
324
|
+
// Hand the rest of a long range to an idle slot: this listing keeps
|
|
325
|
+
// keys up to `mid`, the new one starts after it.
|
|
326
|
+
if (keyMarker !== undefined && !w.split && active + queue.length < LIST_CONCURRENCY) {
|
|
327
|
+
const end = hi ?? `${w.prefix}\x7f`;
|
|
328
|
+
const mid = keyBetween(keyMarker, end);
|
|
329
|
+
if (mid !== undefined && mid.startsWith(w.prefix)) {
|
|
330
|
+
queue.push({ ...w, lo: mid, hi });
|
|
331
|
+
hi = mid;
|
|
332
|
+
pump();
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
} while (keyMarker !== undefined && failed === undefined);
|
|
184
336
|
}
|
|
185
|
-
|
|
186
|
-
|
|
337
|
+
catch (err) {
|
|
338
|
+
if (!w.usePrefix || !isAccessDenied(err))
|
|
339
|
+
throw err;
|
|
340
|
+
// Refused for this folder: list it again from the plain listing. Nothing
|
|
341
|
+
// from the refused attempt is kept, so nothing counts twice.
|
|
342
|
+
// Same key range as this attempt (a split-off rest is already queued).
|
|
343
|
+
queue.push({ ...w, split: false, usePrefix: false, hi });
|
|
344
|
+
return;
|
|
187
345
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
346
|
+
for (const e of found)
|
|
347
|
+
all.push(e);
|
|
348
|
+
for (const c of children)
|
|
349
|
+
queue.push(c);
|
|
350
|
+
};
|
|
351
|
+
let finish;
|
|
352
|
+
const finished = new Promise((resolve) => (finish = resolve));
|
|
353
|
+
function pump() {
|
|
354
|
+
if (failed === undefined) {
|
|
355
|
+
while (active < LIST_CONCURRENCY && queue.length > 0) {
|
|
356
|
+
const next = queue.shift();
|
|
357
|
+
active += 1;
|
|
358
|
+
runOne(next)
|
|
359
|
+
.catch((err) => {
|
|
360
|
+
failed ??= err;
|
|
361
|
+
})
|
|
362
|
+
.finally(() => {
|
|
363
|
+
active -= 1;
|
|
364
|
+
pump();
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (active === 0 && (queue.length === 0 || failed !== undefined))
|
|
369
|
+
finish();
|
|
370
|
+
}
|
|
371
|
+
pump();
|
|
372
|
+
await finished;
|
|
373
|
+
if (failed !== undefined)
|
|
374
|
+
throw failed;
|
|
375
|
+
return all;
|
|
191
376
|
}
|
|
192
377
|
/** Every version and delete marker in the bucket, with `noncurrentSince` set. */
|
|
193
378
|
async function listVersions(store) {
|
|
194
|
-
|
|
195
|
-
for await (const v of listAllVersions(store))
|
|
196
|
-
all.push(v);
|
|
197
|
-
return annotateNoncurrentSince(all);
|
|
379
|
+
return annotateNoncurrentSince(await listAllVersions(store));
|
|
198
380
|
}
|
|
199
381
|
function emptyTranches() {
|
|
200
382
|
return TRANCHES.map((t) => ({ id: t.id, label: t.label, count: 0, bytes: 0 }));
|
|
@@ -264,7 +446,7 @@ export async function getCloudStatus(company, deps) {
|
|
|
264
446
|
error: describeCloudError(err, "list"),
|
|
265
447
|
};
|
|
266
448
|
}
|
|
267
|
-
return status;
|
|
449
|
+
return { ...status, listed_at: now.toISOString() };
|
|
268
450
|
}
|
|
269
451
|
/**
|
|
270
452
|
* Versions eligible for prune: noncurrent content versions and noncurrent
|
|
@@ -345,29 +527,42 @@ export async function pruneCloud(company, cutoff, dryRun, deps) {
|
|
|
345
527
|
// ── Orchestrators ───────────────────────────────────────────────────────────
|
|
346
528
|
export async function runStorageStatus(opts, deps) {
|
|
347
529
|
// Read-only: no backup drop, no gc. Pending backup bytes come from report.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
cloud
|
|
359
|
-
cloud_error: err instanceof Error ? err.message : String(err)
|
|
360
|
-
|
|
361
|
-
|
|
530
|
+
// The local report, the offload scan and the cloud listing are independent,
|
|
531
|
+
// so they run at the same time: status takes as long as the slowest one.
|
|
532
|
+
const localP = getLocalReport(deps);
|
|
533
|
+
const offloadP = deps.getOffload ? deps.getOffload().catch(offloadUnavailable) : Promise.resolve(undefined);
|
|
534
|
+
const cloudP = (async () => {
|
|
535
|
+
let memberships;
|
|
536
|
+
try {
|
|
537
|
+
memberships = await deps.listCompanies();
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
// Local sections still report when cloud is unreachable (signed out, API key).
|
|
541
|
+
return { cloud: [], cloud_error: err instanceof Error ? err.message : String(err) };
|
|
542
|
+
}
|
|
543
|
+
const companies = selectCompanies(memberships, opts.company);
|
|
544
|
+
const cached = async (c) => {
|
|
545
|
+
const hit = opts.noCache ? undefined : deps.cloudCache?.read(c);
|
|
546
|
+
if (hit)
|
|
547
|
+
return hit;
|
|
548
|
+
const fresh = await getCloudStatus(c, deps);
|
|
549
|
+
deps.cloudCache?.write(c, fresh);
|
|
550
|
+
return fresh;
|
|
362
551
|
};
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
552
|
+
return { cloud: await Promise.all(companies.map(cached)) };
|
|
553
|
+
})();
|
|
554
|
+
// Settle all three before returning, so no scan is left running behind a throw.
|
|
555
|
+
const [localR, offloadR, cloudR] = await Promise.allSettled([localP, offloadP, cloudP]);
|
|
556
|
+
for (const r of [localR, cloudR])
|
|
557
|
+
if (r.status === "rejected")
|
|
558
|
+
throw r.reason;
|
|
559
|
+
const local = localR.value;
|
|
560
|
+
const offload = offloadR.status === "fulfilled" ? offloadR.value : undefined;
|
|
561
|
+
const { cloud, cloud_error } = cloudR.value;
|
|
368
562
|
return {
|
|
369
563
|
local,
|
|
370
564
|
cloud,
|
|
565
|
+
...(cloud_error !== undefined ? { cloud_error } : {}),
|
|
371
566
|
...(offload ? { offload } : {}),
|
|
372
567
|
generated_at: deps.now().toISOString(),
|
|
373
568
|
};
|
|
@@ -422,8 +617,12 @@ export async function runStoragePrune(opts, deps) {
|
|
|
422
617
|
}
|
|
423
618
|
const cloud = [];
|
|
424
619
|
if (cloudCutoff) {
|
|
425
|
-
for (const c of companies)
|
|
620
|
+
for (const c of companies) {
|
|
621
|
+
// A real prune changes the figures: the next status lists live.
|
|
622
|
+
if (!opts.dryRun)
|
|
623
|
+
deps.cloudCache?.drop(c);
|
|
426
624
|
cloud.push(await pruneCloud(c, cloudCutoff, opts.dryRun, deps));
|
|
625
|
+
}
|
|
427
626
|
for (const c of skipped) {
|
|
428
627
|
cloud.push({
|
|
429
628
|
company: c.slug,
|
|
@@ -774,10 +973,43 @@ async function buildOffloadDeps() {
|
|
|
774
973
|
},
|
|
775
974
|
};
|
|
776
975
|
}
|
|
976
|
+
/**
|
|
977
|
+
* AWS credential provider that vends fresh credentials on first use and again
|
|
978
|
+
* whenever the previous ones are within `marginMs` of expiring. Each vend calls
|
|
979
|
+
* `vend` anew, which signs in again (the CLI refreshes an expiring Cognito
|
|
980
|
+
* session there), so a cloud listing or delete that starts after a long local
|
|
981
|
+
* scan, or that itself outlasts one STS session, never sends an expired token.
|
|
982
|
+
*/
|
|
983
|
+
export function refreshingVendedCredentials(vend, now = Date.now, marginMs = 5 * 60 * 1000) {
|
|
984
|
+
let cached;
|
|
985
|
+
let pending;
|
|
986
|
+
return async () => {
|
|
987
|
+
if (cached && cached.expiration && cached.expiration.getTime() - now() > marginMs)
|
|
988
|
+
return cached;
|
|
989
|
+
pending ??= vend()
|
|
990
|
+
.then((c) => {
|
|
991
|
+
const t = c.expiresAt ? Date.parse(c.expiresAt) : NaN;
|
|
992
|
+
cached = {
|
|
993
|
+
accessKeyId: c.accessKeyId,
|
|
994
|
+
secretAccessKey: c.secretAccessKey,
|
|
995
|
+
sessionToken: c.sessionToken,
|
|
996
|
+
// Unknown expiry: treat as already due so the next call vends again.
|
|
997
|
+
expiration: new Date(Number.isNaN(t) ? now() : t),
|
|
998
|
+
};
|
|
999
|
+
return cached;
|
|
1000
|
+
})
|
|
1001
|
+
.finally(() => {
|
|
1002
|
+
pending = undefined;
|
|
1003
|
+
});
|
|
1004
|
+
return pending;
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
777
1007
|
async function buildDeps() {
|
|
778
|
-
// Sign-in is lazy so `status` can still report local sections when signed out
|
|
779
|
-
|
|
780
|
-
|
|
1008
|
+
// Sign-in is lazy so `status` can still report local sections when signed out,
|
|
1009
|
+
// and it is resolved again for every cloud call (never once at the start): the
|
|
1010
|
+
// local scan before the cloud listing can outlast a session token, and
|
|
1011
|
+
// resolveVaultCredential refreshes an expiring Cognito session.
|
|
1012
|
+
const signedIn = () => signedInVault();
|
|
781
1013
|
const offloadDeps = await buildOffloadDeps();
|
|
782
1014
|
return {
|
|
783
1015
|
getOffload: () => getOffloadStatus(offloadDeps, {
|
|
@@ -786,6 +1018,7 @@ async function buildDeps() {
|
|
|
786
1018
|
}),
|
|
787
1019
|
hqRoot: findHqRoot(),
|
|
788
1020
|
now: () => new Date(),
|
|
1021
|
+
cloudCache: cloudStatusCache(findHqRoot(), () => new Date()),
|
|
789
1022
|
runScript: defaultRunScript,
|
|
790
1023
|
confirm: (message) => confirmOnTty(message),
|
|
791
1024
|
log: (line) => console.log(line),
|
|
@@ -799,10 +1032,16 @@ async function buildDeps() {
|
|
|
799
1032
|
},
|
|
800
1033
|
openCompany: async (company) => {
|
|
801
1034
|
const { vault } = await signedIn();
|
|
802
|
-
const vend = await vault.sts.vend({ companyUid: company.uid });
|
|
803
1035
|
const entity = await vault.entity.get(company.uid);
|
|
804
1036
|
const bucket = entity.bucketName ?? `hq-vault-cmp-${company.slug}`;
|
|
805
|
-
|
|
1037
|
+
// Credentials are vended right before the first S3 call and re-vended
|
|
1038
|
+
// (with a freshly refreshed sign-in) before they expire.
|
|
1039
|
+
const credentials = refreshingVendedCredentials(async () => {
|
|
1040
|
+
const { vault: fresh } = await signedIn();
|
|
1041
|
+
const vend = await fresh.sts.vend({ companyUid: company.uid });
|
|
1042
|
+
return { ...vend.credentials, expiresAt: vend.expiresAt };
|
|
1043
|
+
});
|
|
1044
|
+
const s3 = new S3Client({ region: DEFAULT_COGNITO.region, credentials });
|
|
806
1045
|
return { bucket, s3 };
|
|
807
1046
|
},
|
|
808
1047
|
};
|
|
@@ -817,9 +1056,10 @@ export function registerStorageCommand(program) {
|
|
|
817
1056
|
.description("Show local autosave history and cloud file history sizes")
|
|
818
1057
|
.option("--json", "Print JSON")
|
|
819
1058
|
.option("--company <slug>", "Only this company (must be one of yours)")
|
|
1059
|
+
.option("--no-cache", "List cloud file history live instead of using figures from the last 15 minutes")
|
|
820
1060
|
.action(async (opts) => {
|
|
821
1061
|
try {
|
|
822
|
-
const status = await runStorageStatus({ company: opts.company }, await buildDeps());
|
|
1062
|
+
const status = await runStorageStatus({ company: opts.company, noCache: opts.cache === false }, await buildDeps());
|
|
823
1063
|
console.log(opts.json ? JSON.stringify(status, null, 2) : formatStorageStatus(status));
|
|
824
1064
|
}
|
|
825
1065
|
catch (err) {
|
|
@@ -119,6 +119,16 @@ export function deriveRemediation(result) {
|
|
|
119
119
|
command: remediation ?? `hq bot start ${target}`,
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
+
// Available hq-core upgrade. WARN is intentional in `hq doctor` and is
|
|
123
|
+
// not a broken sync; --fix has no mechanical repair for it.
|
|
124
|
+
if (checkId === "sync.update.core") {
|
|
125
|
+
return {
|
|
126
|
+
autoFixable: false,
|
|
127
|
+
fixClass: null,
|
|
128
|
+
action: "Ordinary hq-core upgrade, not a failed repair. hq doctor --fix will leave this WARN in place. Run /update-hq in a fresh session.",
|
|
129
|
+
command: "",
|
|
130
|
+
};
|
|
131
|
+
}
|
|
122
132
|
// refresh-sync — a company journal that is stale or never-synced, or a
|
|
123
133
|
// live personal / personal-vault journal. Leftover shards are NA at the
|
|
124
134
|
// check layer, so they never reach this classifier. A SessionStart
|
|
@@ -104,7 +104,23 @@ export interface BuildDoctorJsonInput {
|
|
|
104
104
|
clientHealthReasonCodesEnabledCheckIds?: ReadonlySet<string>;
|
|
105
105
|
/** Check ids whose own company enabled per-check status reporting in hq-flags. */
|
|
106
106
|
clientHealthCheckStatusEnabledCheckIds?: ReadonlySet<string>;
|
|
107
|
+
/**
|
|
108
|
+
* JSON consumed by the SessionStart health hook (`--client-health-report`).
|
|
109
|
+
* Ordinary hq-core upgrade WARNs stay in human `hq doctor` output and in
|
|
110
|
+
* default `--json`, but they are omitted here: `--fix` cannot apply a
|
|
111
|
+
* release, and older hooks treated the leftover WARN as an unrepaired
|
|
112
|
+
* failure.
|
|
113
|
+
*/
|
|
114
|
+
clientHealthReport?: boolean;
|
|
107
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* True when a sync finding is an ordinary, non-repairable hq-core upgrade.
|
|
118
|
+
* FAIL stays visible: that is a broken local core, not "a newer release exists".
|
|
119
|
+
*/
|
|
120
|
+
export declare function isOrdinaryCoreUpdateWarn(result: {
|
|
121
|
+
checkId: string;
|
|
122
|
+
status: DoctorStatus;
|
|
123
|
+
}): boolean;
|
|
108
124
|
/** Assemble the machine-readable document from a completed run. */
|
|
109
125
|
export declare function buildDoctorJson(input: BuildDoctorJsonInput): DoctorJsonDocument;
|
|
110
126
|
/** Serialise the document as pretty-printed JSON with a trailing newline. */
|
|
@@ -29,11 +29,21 @@ import { deriveRemediation } from "./fix/remediation.js";
|
|
|
29
29
|
* was passed, so a consumer can see what `--fix` would do without invoking it.
|
|
30
30
|
*/
|
|
31
31
|
export const DOCTOR_JSON_SCHEMA_VERSION = 2;
|
|
32
|
+
/**
|
|
33
|
+
* True when a sync finding is an ordinary, non-repairable hq-core upgrade.
|
|
34
|
+
* FAIL stays visible: that is a broken local core, not "a newer release exists".
|
|
35
|
+
*/
|
|
36
|
+
export function isOrdinaryCoreUpdateWarn(result) {
|
|
37
|
+
return result.checkId === "sync.update.core" && result.status === "WARN";
|
|
38
|
+
}
|
|
32
39
|
/** Assemble the machine-readable document from a completed run. */
|
|
33
40
|
export function buildDoctorJson(input) {
|
|
34
41
|
const results = [];
|
|
35
42
|
for (const run of input.families) {
|
|
36
43
|
for (const result of run.results) {
|
|
44
|
+
if (input.clientHealthReport === true && isOrdinaryCoreUpdateWarn(result)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
37
47
|
results.push(toJsonResult(run.family.id, result, input.clientHealthReasonCodesEnabledCheckIds, input.clientHealthCheckStatusEnabledCheckIds));
|
|
38
48
|
}
|
|
39
49
|
}
|
|
@@ -149,9 +149,18 @@ function isInstalledDrainEntry(entryPath) {
|
|
|
149
149
|
* Both paths target the same hq-cli drain entry, so recognize an installed
|
|
150
150
|
* package entry alongside the exact entry belonging to this CLI invocation.
|
|
151
151
|
*/
|
|
152
|
-
function drainCommandsMatch(candidate, expected) {
|
|
153
|
-
if (candidate === expected)
|
|
154
|
-
|
|
152
|
+
function drainCommandsMatch(candidate, expected, requireInstalledPaths = false) {
|
|
153
|
+
if (candidate === expected) {
|
|
154
|
+
if (!requireInstalledPaths)
|
|
155
|
+
return true;
|
|
156
|
+
const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
|
|
157
|
+
? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
|
|
158
|
+
: candidate;
|
|
159
|
+
const currentArgv = parseShellQuotedArguments(withoutProviderEnv);
|
|
160
|
+
return currentArgv !== null && currentArgv.length === 2 &&
|
|
161
|
+
path.parse(currentArgv[0]).name.toLowerCase() === "node" &&
|
|
162
|
+
fs.existsSync(currentArgv[0]) && fs.existsSync(currentArgv[1]);
|
|
163
|
+
}
|
|
155
164
|
const withoutProviderEnv = candidate.startsWith("HQ_LANE_PROVIDER=grok ")
|
|
156
165
|
? candidate.slice("HQ_LANE_PROVIDER=grok ".length)
|
|
157
166
|
: candidate;
|
|
@@ -162,9 +171,9 @@ function drainCommandsMatch(candidate, expected) {
|
|
|
162
171
|
return false;
|
|
163
172
|
if (!isInstalledDrainEntry(argv[1]))
|
|
164
173
|
return false;
|
|
165
|
-
return fs.existsSync(argv[0]) && fs.existsSync(argv[1]);
|
|
174
|
+
return !requireInstalledPaths || (fs.existsSync(argv[0]) && fs.existsSync(argv[1]));
|
|
166
175
|
}
|
|
167
|
-
function groupsContainDrainCommand(groups, expected) {
|
|
176
|
+
function groupsContainDrainCommand(groups, expected, requireInstalledPaths = false) {
|
|
168
177
|
if (!Array.isArray(groups))
|
|
169
178
|
return false;
|
|
170
179
|
return groups.some((group) => {
|
|
@@ -173,29 +182,51 @@ function groupsContainDrainCommand(groups, expected) {
|
|
|
173
182
|
return group.hooks.some((hook) => isPlainObject(hook) &&
|
|
174
183
|
hook.type === "command" &&
|
|
175
184
|
typeof hook.command === "string" &&
|
|
176
|
-
drainCommandsMatch(hook.command, expected));
|
|
185
|
+
drainCommandsMatch(hook.command, expected, requireInstalledPaths));
|
|
177
186
|
});
|
|
178
187
|
}
|
|
179
|
-
function tomlHasDrainCommand(parsed, expected) {
|
|
188
|
+
function tomlHasDrainCommand(parsed, expected, requireInstalledPaths = false) {
|
|
180
189
|
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : null;
|
|
181
190
|
return hooks !== null &&
|
|
182
|
-
["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected));
|
|
191
|
+
["PreToolUse", "PostToolUse", "Stop"].every((event) => groupsContainDrainCommand(hooks[event], expected, requireInstalledPaths));
|
|
183
192
|
}
|
|
184
|
-
function
|
|
193
|
+
function removeDrainCommandsFromGroups(groups, expected, legacyCommand) {
|
|
185
194
|
let changed = false;
|
|
195
|
+
let keptCurrent = false;
|
|
186
196
|
const next = groups.flatMap((group) => {
|
|
187
197
|
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
188
198
|
return [group];
|
|
189
|
-
const hooks = group.hooks.filter((hook) =>
|
|
190
|
-
hook.type
|
|
191
|
-
|
|
199
|
+
const hooks = group.hooks.filter((hook) => {
|
|
200
|
+
if (!isPlainObject(hook) || hook.type !== "command" || typeof hook.command !== "string")
|
|
201
|
+
return true;
|
|
202
|
+
const isCurrent = hook.command === expected;
|
|
203
|
+
const isDrain = isCurrent || drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand;
|
|
204
|
+
if (isCurrent && !keptCurrent) {
|
|
205
|
+
keptCurrent = true;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
if (isDrain)
|
|
209
|
+
changed = true;
|
|
210
|
+
return !isDrain;
|
|
211
|
+
});
|
|
192
212
|
if (hooks.length === group.hooks.length)
|
|
193
213
|
return [group];
|
|
194
|
-
changed = true;
|
|
195
214
|
return hooks.length === 0 ? [] : [{ ...group, hooks }];
|
|
196
215
|
});
|
|
197
216
|
return { groups: next, changed };
|
|
198
217
|
}
|
|
218
|
+
function groupHasExactlyOneCommand(groups, command) {
|
|
219
|
+
let count = 0;
|
|
220
|
+
for (const group of groups) {
|
|
221
|
+
if (!isPlainObject(group) || !Array.isArray(group.hooks))
|
|
222
|
+
continue;
|
|
223
|
+
for (const hook of group.hooks) {
|
|
224
|
+
if (isPlainObject(hook) && hook.type === "command" && hook.command === command)
|
|
225
|
+
count += 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return count === 1;
|
|
229
|
+
}
|
|
199
230
|
export function renderDrainHookJson(shPath, provider = "grok") {
|
|
200
231
|
const command = drainHookCommand(shPath, provider);
|
|
201
232
|
const registration = {
|
|
@@ -237,21 +268,18 @@ export function mergeClaudeSettingsHook(existingText, command, settingsPath, leg
|
|
|
237
268
|
}
|
|
238
269
|
eventGroups.set(event, groups);
|
|
239
270
|
}
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
: { groups: pre, changed: false };
|
|
247
|
-
const allRegistered = events.every((event) => groupsContainCommand(eventGroups.get(event), command));
|
|
248
|
-
if (allRegistered && !migration.changed) {
|
|
271
|
+
const cleanups = new Map(events.map((event) => [
|
|
272
|
+
event,
|
|
273
|
+
removeDrainCommandsFromGroups(eventGroups.get(event), command, legacyCommand),
|
|
274
|
+
]));
|
|
275
|
+
if (events.every((event) => groupHasExactlyOneCommand(cleanups.get(event).groups, command) &&
|
|
276
|
+
!cleanups.get(event).changed)) {
|
|
249
277
|
return { text: existingText ?? "", changed: false };
|
|
250
278
|
}
|
|
251
279
|
const nextHooks = { ...hooks };
|
|
252
280
|
for (const event of events) {
|
|
253
|
-
const groups =
|
|
254
|
-
nextHooks[event] =
|
|
281
|
+
const groups = cleanups.get(event).groups;
|
|
282
|
+
nextHooks[event] = groupHasExactlyOneCommand(groups, command) ? groups : [
|
|
255
283
|
...groups,
|
|
256
284
|
{ hooks: [{ type: "command", command, timeout: DRAIN_HOOK_TIMEOUT_SECONDS }] },
|
|
257
285
|
];
|
|
@@ -405,6 +433,145 @@ function parseTomlOrThrow(text, configPath, stage) {
|
|
|
405
433
|
}
|
|
406
434
|
return parsed;
|
|
407
435
|
}
|
|
436
|
+
function renderTomlInline(value) {
|
|
437
|
+
if (typeof value === "string")
|
|
438
|
+
return tomlBasicString(value);
|
|
439
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
440
|
+
return String(value);
|
|
441
|
+
if (Array.isArray(value))
|
|
442
|
+
return `[${value.map(renderTomlInline).join(", ")}]`;
|
|
443
|
+
if (isPlainObject(value)) {
|
|
444
|
+
const fields = Object.entries(value).map(([key, entry]) => `${JSON.stringify(key)} = ${renderTomlInline(entry)}`);
|
|
445
|
+
return `{ ${fields.join(", ")} }`;
|
|
446
|
+
}
|
|
447
|
+
throw new LanesError("config_toml_migration_failed", "Could not safely render an inline Codex hook while removing stale lane drains");
|
|
448
|
+
}
|
|
449
|
+
function isDrainHookValue(hook, expected, legacyCommand) {
|
|
450
|
+
return isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string" &&
|
|
451
|
+
(drainCommandsMatch(hook.command, expected) || hook.command === legacyCommand);
|
|
452
|
+
}
|
|
453
|
+
function removeStaleCodexDrainHooks(text, parsed, expected, legacyCommand, configPath) {
|
|
454
|
+
const hooks = isPlainObject(parsed.hooks) ? parsed.hooks : {};
|
|
455
|
+
const lines = text.split("\n");
|
|
456
|
+
const spans = [];
|
|
457
|
+
let changed = false;
|
|
458
|
+
for (const event of ["PreToolUse", "PostToolUse", "Stop"]) {
|
|
459
|
+
const groups = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
460
|
+
const parentIndexes = lines.flatMap((line, index) => {
|
|
461
|
+
const header = tomlHeader(line);
|
|
462
|
+
return header?.array && header.path === `hooks.${event}` ? [index] : [];
|
|
463
|
+
});
|
|
464
|
+
if (parentIndexes.length !== groups.length) {
|
|
465
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} drain hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groups: groups.length, headers: parentIndexes.length });
|
|
466
|
+
}
|
|
467
|
+
let keptCurrent = false;
|
|
468
|
+
for (let groupIndex = 0; groupIndex < parentIndexes.length; groupIndex += 1) {
|
|
469
|
+
const start = parentIndexes[groupIndex];
|
|
470
|
+
let end = start + 1;
|
|
471
|
+
for (; end < lines.length; end += 1) {
|
|
472
|
+
const header = tomlHeader(lines[end]);
|
|
473
|
+
if (!header)
|
|
474
|
+
continue;
|
|
475
|
+
if (header.array && header.path === `hooks.${event}`)
|
|
476
|
+
break;
|
|
477
|
+
if (header.path.startsWith(`hooks.${event}.`))
|
|
478
|
+
continue;
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
const hookHeaders = lines.flatMap((line, index) => {
|
|
482
|
+
if (index <= start || index >= end)
|
|
483
|
+
return [];
|
|
484
|
+
const header = tomlHeader(line);
|
|
485
|
+
return header?.array && header.path === `hooks.${event}.hooks` ? [index] : [];
|
|
486
|
+
});
|
|
487
|
+
const group = groups[groupIndex];
|
|
488
|
+
const groupHooks = isPlainObject(group) && Array.isArray(group.hooks) ? group.hooks : [];
|
|
489
|
+
if (hookHeaders.length !== groupHooks.length) {
|
|
490
|
+
const keptInlineHooks = [];
|
|
491
|
+
let inlineNeedsEdit = false;
|
|
492
|
+
for (const hook of groupHooks) {
|
|
493
|
+
const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
|
|
494
|
+
? hook.command
|
|
495
|
+
: undefined;
|
|
496
|
+
if (!command) {
|
|
497
|
+
keptInlineHooks.push(hook);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const current = command === expected;
|
|
501
|
+
const drain = isDrainHookValue(hook, expected, legacyCommand);
|
|
502
|
+
if (!drain || (current && !keptCurrent)) {
|
|
503
|
+
if (current)
|
|
504
|
+
keptCurrent = true;
|
|
505
|
+
keptInlineHooks.push(hook);
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
inlineNeedsEdit = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if (!inlineNeedsEdit)
|
|
512
|
+
continue;
|
|
513
|
+
if (hookHeaders.length === 0) {
|
|
514
|
+
const hooksLine = lines.findIndex((line, index) => index > start && index < end && /^\s*hooks\s*=/.test(line));
|
|
515
|
+
if (hooksLine === -1) {
|
|
516
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely find inline ${event} hooks in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex });
|
|
517
|
+
}
|
|
518
|
+
changed = true;
|
|
519
|
+
if (keptInlineHooks.length === 0) {
|
|
520
|
+
spans.push({ start, end });
|
|
521
|
+
}
|
|
522
|
+
else {
|
|
523
|
+
const indent = /^\s*/.exec(lines[hooksLine])?.[0] ?? "";
|
|
524
|
+
lines[hooksLine] = `${indent}hooks = ${renderTomlInline(keptInlineHooks)}`;
|
|
525
|
+
}
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
throw new LanesError("config_toml_migration_failed", `Could not safely map ${event} hook entries in ${configPath}; refusing to add a second drain`, { path: configPath, event, groupIndex, hooks: groupHooks.length, headers: hookHeaders.length });
|
|
529
|
+
}
|
|
530
|
+
const removeHooks = [];
|
|
531
|
+
for (let hookIndex = 0; hookIndex < hookHeaders.length; hookIndex += 1) {
|
|
532
|
+
const hookStart = hookHeaders[hookIndex];
|
|
533
|
+
let hookEnd = hookStart + 1;
|
|
534
|
+
for (; hookEnd < end; hookEnd += 1) {
|
|
535
|
+
const header = tomlHeader(lines[hookEnd]);
|
|
536
|
+
if (header?.array && header.path === `hooks.${event}.hooks`)
|
|
537
|
+
break;
|
|
538
|
+
if (header && !header.path.startsWith(`hooks.${event}.hooks.`))
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
const hook = groupHooks[hookIndex];
|
|
542
|
+
const command = isPlainObject(hook) && hook.type === "command" && typeof hook.command === "string"
|
|
543
|
+
? hook.command
|
|
544
|
+
: undefined;
|
|
545
|
+
if (!command)
|
|
546
|
+
continue;
|
|
547
|
+
const current = command === expected;
|
|
548
|
+
const drain = drainCommandsMatch(command, expected) || command === legacyCommand;
|
|
549
|
+
if (!drain || (current && !keptCurrent)) {
|
|
550
|
+
if (current)
|
|
551
|
+
keptCurrent = true;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
removeHooks.push({ start: hookStart, end: hookEnd });
|
|
555
|
+
}
|
|
556
|
+
if (removeHooks.length === 0)
|
|
557
|
+
continue;
|
|
558
|
+
changed = true;
|
|
559
|
+
if (removeHooks.length === hookHeaders.length) {
|
|
560
|
+
spans.push({ start, end });
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
spans.push(...removeHooks);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (!changed)
|
|
568
|
+
return { text, changed: false };
|
|
569
|
+
for (const span of spans.sort((a, b) => b.start - a.start)) {
|
|
570
|
+
lines.splice(span.start, span.end - span.start);
|
|
571
|
+
}
|
|
572
|
+
const cleaned = lines.join("\n");
|
|
573
|
+
return { text: cleaned, changed: true };
|
|
574
|
+
}
|
|
408
575
|
/**
|
|
409
576
|
* Set `hooks = true` inside `[features]` by editing lines, so the operator's
|
|
410
577
|
* comments and layout survive. A `[features]` header that exists gets the
|
|
@@ -442,12 +609,21 @@ export function mergeCodexConfigHook(existingText, command, configPath, legacyCo
|
|
|
442
609
|
const original = existingText ?? "";
|
|
443
610
|
const parsed = parseTomlOrThrow(original, configPath, "before merge");
|
|
444
611
|
const migration = removeLegacyCodexHook(original, parsed, legacyCommand, configPath);
|
|
445
|
-
const
|
|
612
|
+
const parsedAfterMigration = parseTomlOrThrow(migration.text, configPath, "after legacy hook removal");
|
|
613
|
+
const drainCleanup = removeStaleCodexDrainHooks(migration.text, parsedAfterMigration, command, legacyCommand, configPath);
|
|
614
|
+
const changedByCleanup = migration.changed || drainCleanup.changed;
|
|
615
|
+
const textAfterMigration = drainCleanup.text;
|
|
446
616
|
const current = parseTomlOrThrow(textAfterMigration, configPath, "after legacy hook removal");
|
|
447
617
|
const hasPreToolCommand = tomlHasCommand(current, command);
|
|
448
618
|
const hasCommand = tomlHasDrainCommand(current, command);
|
|
449
619
|
const featureOn = tomlHooksFeatureEnabled(current);
|
|
450
|
-
if (hasCommand && featureOn && !
|
|
620
|
+
if (hasCommand && featureOn && !changedByCleanup &&
|
|
621
|
+
["PreToolUse", "PostToolUse", "Stop"].every((event) => {
|
|
622
|
+
const groups = isPlainObject(current.hooks) && Array.isArray(current.hooks[event])
|
|
623
|
+
? current.hooks[event]
|
|
624
|
+
: [];
|
|
625
|
+
return groupHasExactlyOneCommand(groups, command);
|
|
626
|
+
})) {
|
|
451
627
|
return { text: original, changed: false };
|
|
452
628
|
}
|
|
453
629
|
let text = textAfterMigration;
|
|
@@ -509,7 +685,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
|
|
|
509
685
|
const toml = parseToml(text);
|
|
510
686
|
return {
|
|
511
687
|
...base,
|
|
512
|
-
registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command) && tomlHooksFeatureEnabled(toml),
|
|
688
|
+
registered: isPlainObject(toml) && tomlHasDrainCommand(toml, command, true) && tomlHooksFeatureEnabled(toml),
|
|
513
689
|
};
|
|
514
690
|
}
|
|
515
691
|
const json = JSON.parse(text);
|
|
@@ -517,7 +693,7 @@ export function probeDrainHookRegistration(provider, providerHome) {
|
|
|
517
693
|
return {
|
|
518
694
|
...base,
|
|
519
695
|
registered: hooks !== null &&
|
|
520
|
-
(parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command)),
|
|
696
|
+
(parsed === "grok" ? ["PreToolUse"] : ["PreToolUse", "PostToolUse", "Stop"]).every((event) => groupsContainDrainCommand(hooks[event], command, true)),
|
|
521
697
|
};
|
|
522
698
|
}
|
|
523
699
|
catch (err) {
|