@indigoai-us/hq-cli 5.290.0 → 5.291.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/CHANGELOG.md +44 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/dist/command-catalog.generated.d.ts +59 -0
- package/dist/command-catalog.generated.js +73 -0
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-jobs-native.d.ts +8 -0
- package/dist/commands/core-jobs-native.js +624 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +7 -0
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.js +11 -6
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.291.0] — 2026-09-29
|
|
6
|
+
- Add native `hq core jobs schedule-parse` and `validate` commands behind the default-off `core-native-jobs` gate, retaining their shell rollback companions.
|
|
7
|
+
|
|
8
|
+
- Offline mesh reconcile does not refresh person tokens or start interactive sign-in; valid cached tokens still validate device defaults.
|
|
9
|
+
- Retry daemon state replacement briefly when Windows reports transient rename contention, while keeping the previous state file intact on failure.
|
|
10
|
+
- When a lane has no Board story ID, `hq lanes` records the lane event without sending a story PATCH.
|
|
11
|
+
- `hq core policy age-report` now matches the legacy script when a missing reference has no hidden sibling under a dot directory.
|
|
12
|
+
- Keep bundled scaffold scripts current with staging and check their pinned source revision in CI.
|
|
13
|
+
- Starter plan-limit refusals now show why and how to fix them. The CLI reads
|
|
14
|
+
hq-pro's hard-stop 402 body (and the older shapes) and prints the server
|
|
15
|
+
message, the usage, the fix options, the free-month offer when one stands,
|
|
16
|
+
and the upgrade link, including in non-interactive runs. `hq secrets set`,
|
|
17
|
+
`hq secrets generate-link`, `hq members invite` and `hq integrations connect`
|
|
18
|
+
all use this renderer and exit non-zero.
|
|
19
|
+
- `hq sync push`, `hq sync push --all`, `hq sync now`, `hq sync now --all` and
|
|
20
|
+
`hq skill create` no longer print a success line when a plan limit refused a
|
|
21
|
+
file. They list the refused files the runner names, show the upgrade link and
|
|
22
|
+
exit non-zero. `hq sync push --json` adds `planLimitRefused` to its `complete`
|
|
23
|
+
event instead.
|
|
24
|
+
|
|
5
25
|
## [5.290.0] — 2026-09-29
|
|
26
|
+
- Ignore stale but well-formed worker-profile queue details when another valid queue reason is stored, and remove them on the lane's next write.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Gate `hq sync push --personal <paths>` behind the default-off `cli.personal-targeted-push-paths` flag; explicit paths are normalized and checked against personal-vault scope and exclusions before upload.
|
|
31
|
+
|
|
32
|
+
## [5.289.0] — 2026-09-29
|
|
6
33
|
- A failed `hq update` restores the previous working hq-cli install when it could be backed up, instead of leaving the CLI partially updated.
|
|
7
34
|
|
|
8
35
|
## [5.288.0] — 2026-09-29
|
|
@@ -20,6 +47,21 @@
|
|
|
20
47
|
- `merge-copies --rewrite-refs` no longer holds a survivor as `merge_invalid` when it gains external ids but has no `web_domains` key (or the reverse).
|
|
21
48
|
- `merge-copies` treats an annotation list item whose first key is `path:` as a link to the copy, so it no longer deletes a copy such an annotation still names.
|
|
22
49
|
|
|
50
|
+
### Added
|
|
51
|
+
|
|
52
|
+
- Add lead autopilot prompts to the lanes reconcile pass behind
|
|
53
|
+
`lanes.lead-autopilot` (off by default) and expose
|
|
54
|
+
`hq lanes autopilot run --dry-run --json`. It asks registered leads to review
|
|
55
|
+
finished, blocked, or missing workers or fill unused grant slots, starts a
|
|
56
|
+
ready worker after an
|
|
57
|
+
unanswered fill prompt ages 15 minutes, and relies on keep-alive to resume
|
|
58
|
+
completed leads with open work.
|
|
59
|
+
|
|
60
|
+
### Fixed
|
|
61
|
+
|
|
62
|
+
- Retry daemon state replacement briefly when Windows reports transient rename
|
|
63
|
+
contention, while keeping the previous state file intact on failure.
|
|
64
|
+
|
|
23
65
|
## [5.287.0] — 2026-09-29
|
|
24
66
|
- Group hook-timeout events by script while preserving triage diagnostics.
|
|
25
67
|
- Preserve safe slow-child script basenames in hook-timeout diagnostics.
|
|
@@ -29,6 +71,8 @@
|
|
|
29
71
|
handoff scripts behind the default-off `core-native-handoff` flag. The
|
|
30
72
|
original shell assets remain bundled as the rollback path.
|
|
31
73
|
- Add default-off native `hq core handoff finalize`, `post`, and `open-steps` commands with the staging scripts retained as the rollback path.
|
|
74
|
+
- Add a default-off `core-native-jobs` rollout gate for native `hq core jobs
|
|
75
|
+
schedule-parse` and `validate` commands.
|
|
32
76
|
|
|
33
77
|
### Fixed
|
|
34
78
|
|
|
@@ -23,6 +23,26 @@
|
|
|
23
23
|
|
|
24
24
|
HQ_LIB_JQ="$(command -v jq 2>/dev/null || true)"
|
|
25
25
|
HQ_LIB_NODE="$(command -v node 2>/dev/null || true)"
|
|
26
|
+
|
|
27
|
+
# Is "\" a path SEPARATOR on this host, or an ordinary filename character?
|
|
28
|
+
#
|
|
29
|
+
# On Windows-family shells (Git Bash/MSYS/Cygwin) it is a separator and must be
|
|
30
|
+
# folded to "/". On POSIX it is a perfectly legal character IN a filename, and
|
|
31
|
+
# folding it corrupts real paths — dangerously so in a guard: with a symlink
|
|
32
|
+
# literally named `link\name` pointing into repos/, rewriting the input yields
|
|
33
|
+
# the non-existent lexical path workspace/link/name/file.txt, which resolves to
|
|
34
|
+
# nothing under repos/, so the hook exits 0 while the actual write follows the
|
|
35
|
+
# symlink and lands in the checkout.
|
|
36
|
+
#
|
|
37
|
+
# Detected ONCE at source time — hq_normpath is on the hook hot path and must
|
|
38
|
+
# not fork `uname` per call. Override with HQ_LIB_WINSEP=1|0 for tests.
|
|
39
|
+
if [ -z "${HQ_LIB_WINSEP:-}" ]; then
|
|
40
|
+
HQ_LIB_WINSEP=0
|
|
41
|
+
case "${OSTYPE:-}" in msys*|cygwin*|win32*) HQ_LIB_WINSEP=1 ;; esac
|
|
42
|
+
if [ "$HQ_LIB_WINSEP" = "0" ]; then
|
|
43
|
+
case "$(uname -s 2>/dev/null || true)" in MINGW*|MSYS*|CYGWIN*) HQ_LIB_WINSEP=1 ;; esac
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
26
46
|
case "${HQ_HOOK_ENGINE:-}" in
|
|
27
47
|
jq) HQ_LIB_NODE="" ;;
|
|
28
48
|
node) HQ_LIB_JQ="" ;;
|
|
@@ -79,35 +99,214 @@ hq_json_encode() {
|
|
|
79
99
|
|
|
80
100
|
# hq_normpath <path>
|
|
81
101
|
# Lexical normalization (no filesystem access): backslashes -> "/", collapse
|
|
82
|
-
# "//" and "/./", resolve "x/..", trim trailing "/" (keeps root).
|
|
83
|
-
# engines produce IDENTICAL output, so equality checks between two
|
|
102
|
+
# interior "//" and "/./", resolve "x/..", trim trailing "/" (keeps root).
|
|
103
|
+
# Both engines produce IDENTICAL output, so equality checks between two
|
|
84
104
|
# hq_normpath results are stable regardless of engine or platform.
|
|
105
|
+
#
|
|
106
|
+
# A LEADING "//" is preserved ONLY where it names a genuinely DISTINCT root:
|
|
107
|
+
# Windows Git Bash/MSYS/Cygwin UNC shares (//server/share). POSIX leaves the
|
|
108
|
+
# exactly-two-slash case implementation-defined; Linux resolves //tmp and /tmp
|
|
109
|
+
# to the SAME directory, so preserving the distinction there protects nothing
|
|
110
|
+
# and instead guarantees a mismatch whenever the two sides of a prefix check
|
|
111
|
+
# are spelled differently. Both directions are live bypasses: a "//" project
|
|
112
|
+
# root with a "/" file path, and a "/" root with a "//" file path, each let a
|
|
113
|
+
# write into repos/ through. `pwd -P` does preserve "//", so the collapse must
|
|
114
|
+
# happen HERE, after the physical resolution both helpers end with -- that is
|
|
115
|
+
# what makes every spelling of one physical path compare equal, which is the
|
|
116
|
+
# only property the repo guard actually depends on.
|
|
85
117
|
hq_normpath() {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
118
|
+
local input="$1" p line remaining s out last r drive root
|
|
119
|
+
local isabs winsep
|
|
120
|
+
local nl='
|
|
121
|
+
'
|
|
122
|
+
[ -n "$input" ] || return 0
|
|
123
|
+
winsep="${HQ_LIB_WINSEP:-0}"
|
|
124
|
+
|
|
125
|
+
# Preserve the prior record-by-record behavior for embedded newlines while
|
|
126
|
+
# keeping this hot path entirely within bash.
|
|
127
|
+
while :; do
|
|
128
|
+
case "$input" in
|
|
129
|
+
*"$nl"*)
|
|
130
|
+
line=${input%%"$nl"*}
|
|
131
|
+
input=${input#*"$nl"}
|
|
132
|
+
;;
|
|
133
|
+
*)
|
|
134
|
+
line=$input
|
|
135
|
+
input=""
|
|
136
|
+
;;
|
|
137
|
+
esac
|
|
138
|
+
|
|
139
|
+
p=$line
|
|
140
|
+
if [ "$winsep" = "1" ]; then
|
|
141
|
+
p=${p//\\//}
|
|
142
|
+
fi
|
|
143
|
+
isabs=0
|
|
144
|
+
drive=""
|
|
145
|
+
case "$p" in
|
|
146
|
+
/*) isabs=1 ;;
|
|
147
|
+
esac
|
|
148
|
+
case "$p" in
|
|
149
|
+
[A-Za-z]:*)
|
|
150
|
+
drive=${p:0:2}
|
|
151
|
+
p=${p:2}
|
|
152
|
+
case "$p" in
|
|
153
|
+
/*) isabs=1 ;;
|
|
154
|
+
*) isabs=0 ;;
|
|
155
|
+
esac
|
|
156
|
+
;;
|
|
157
|
+
esac
|
|
158
|
+
root=""
|
|
159
|
+
if [ "$isabs" = "1" ]; then
|
|
160
|
+
if [ "$winsep" = "1" ]; then
|
|
161
|
+
case "$p" in
|
|
162
|
+
//|//[!/]*) root="//" ;;
|
|
163
|
+
*) root="/" ;;
|
|
164
|
+
esac
|
|
165
|
+
else
|
|
166
|
+
root="/"
|
|
167
|
+
fi
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
out=""
|
|
171
|
+
remaining=$p
|
|
172
|
+
while :; do
|
|
173
|
+
case "$remaining" in
|
|
174
|
+
*/*)
|
|
175
|
+
s=${remaining%%/*}
|
|
176
|
+
remaining=${remaining#*/}
|
|
177
|
+
;;
|
|
178
|
+
*)
|
|
179
|
+
s=$remaining
|
|
180
|
+
remaining=""
|
|
181
|
+
;;
|
|
182
|
+
esac
|
|
183
|
+
case "$s" in
|
|
184
|
+
''|.) ;;
|
|
185
|
+
..)
|
|
186
|
+
if [ -n "$out" ]; then
|
|
187
|
+
last=${out##*/}
|
|
188
|
+
else
|
|
189
|
+
last=""
|
|
190
|
+
fi
|
|
191
|
+
if [ -n "$out" ] && [ "$last" != ".." ]; then
|
|
192
|
+
case "$out" in
|
|
193
|
+
*/*) out=${out%/*} ;;
|
|
194
|
+
*) out="" ;;
|
|
195
|
+
esac
|
|
196
|
+
elif [ "$isabs" != "1" ]; then
|
|
197
|
+
if [ -n "$out" ]; then
|
|
198
|
+
out="$out/.."
|
|
199
|
+
else
|
|
200
|
+
out=".."
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
;;
|
|
204
|
+
*)
|
|
205
|
+
if [ -n "$out" ]; then
|
|
206
|
+
out="$out/$s"
|
|
207
|
+
else
|
|
208
|
+
out=$s
|
|
209
|
+
fi
|
|
210
|
+
;;
|
|
211
|
+
esac
|
|
212
|
+
[ -n "$remaining" ] || break
|
|
213
|
+
done
|
|
214
|
+
|
|
215
|
+
r=$out
|
|
216
|
+
if [ "$isabs" = "1" ]; then
|
|
217
|
+
r="$root$r"
|
|
218
|
+
fi
|
|
219
|
+
if [ -n "$drive" ]; then
|
|
220
|
+
r="$drive$r"
|
|
221
|
+
fi
|
|
222
|
+
if [ -z "$r" ]; then
|
|
223
|
+
if [ "$isabs" = "1" ]; then
|
|
224
|
+
r=$root
|
|
225
|
+
else
|
|
226
|
+
r="."
|
|
227
|
+
fi
|
|
228
|
+
fi
|
|
229
|
+
printf '%s\n' "$r"
|
|
230
|
+
[ -n "$input" ] || break
|
|
231
|
+
done
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# hq_realpath_lenient <path>
|
|
235
|
+
# Symlink-resolved path that tolerates a leaf which does not exist yet, and
|
|
236
|
+
# an arbitrarily deep tail of missing parents. Mirrors the semantics the repo
|
|
237
|
+
# guard previously got from `python3 os.path.realpath(dirname(p))` + join
|
|
238
|
+
# leaf: the PARENT chain is symlink-resolved, the leaf is kept verbatim.
|
|
239
|
+
#
|
|
240
|
+
# Implementation is `cd -P` + POSIX shell only. `readlink -f` is deliberately
|
|
241
|
+
# NOT used: macOS ships BSD readlink, which has no -f. python3 is deliberately
|
|
242
|
+
# NOT used: it is absent on many Windows machines, and there the Store alias
|
|
243
|
+
# stub resolves on PATH while failing every call, so a `command -v python3`
|
|
244
|
+
# guard around it silently disables whatever the resolution was protecting.
|
|
245
|
+
#
|
|
246
|
+
# ORDER IS LOAD-BEARING. The existing part of the path is handed to `cd -P`
|
|
247
|
+
# with its `..` segments INTACT, because `..` must be applied by the OS after
|
|
248
|
+
# symlinks are resolved, not collapsed lexically first. Given
|
|
249
|
+
# workspace/link -> repos/private/app/dir, the path
|
|
250
|
+
# workspace/link/../secret.txt really means repos/private/app/secret.txt;
|
|
251
|
+
# normalizing first yields workspace/secret.txt, which escapes a repos/ prefix
|
|
252
|
+
# check entirely. For the same reason `cd` must be `cd -P` — bash's default
|
|
253
|
+
# logical `cd` collapses link/.. to the link's parent, reintroducing the bug.
|
|
254
|
+
# Only the not-yet-existing tail is normalized lexically, since there is no
|
|
255
|
+
# filesystem there to consult (this is what python's realpath does too).
|
|
256
|
+
hq_realpath_lenient() {
|
|
257
|
+
local p="$1" leaf parent head tail resolved
|
|
258
|
+
[ -n "$p" ] || { printf '%s' "$p"; return 0; }
|
|
259
|
+
# Separator folding ONLY where "\" is actually a separator; NO `..` collapsing
|
|
260
|
+
# here — see above. On POSIX a backslash is part of the filename and folding it
|
|
261
|
+
# would make this resolve a path the write will never touch.
|
|
262
|
+
if [ "${HQ_LIB_WINSEP:-0}" = "1" ]; then
|
|
263
|
+
case "$p" in
|
|
264
|
+
*\\*) p="$(printf '%s' "$p" | tr '\\' '/')" ;;
|
|
265
|
+
esac
|
|
266
|
+
fi
|
|
267
|
+
case "$p" in
|
|
268
|
+
*/*) leaf="${p##*/}"; parent="${p%/*}"; [ -n "$parent" ] || parent="/" ;;
|
|
269
|
+
*) leaf="$p"; parent="." ;;
|
|
270
|
+
esac
|
|
271
|
+
# Walk up to the deepest ancestor that actually exists; remember the rest.
|
|
272
|
+
# `[ -d ]` follows symlinks, so a head ending in `link/..` tests the real dir.
|
|
273
|
+
head="$parent"; tail=""
|
|
274
|
+
while [ "$head" != "/" ] && [ "$head" != "." ] && [ ! -d "$head" ]; do
|
|
275
|
+
tail="${head##*/}${tail:+/}${tail}"
|
|
276
|
+
case "$head" in
|
|
277
|
+
*/*) head="${head%/*}"; [ -n "$head" ] || head="/" ;;
|
|
278
|
+
*) head="." ;;
|
|
279
|
+
esac
|
|
280
|
+
done
|
|
281
|
+
resolved="$(cd -P "$head" 2>/dev/null && pwd -P)" || resolved="$(hq_normpath "$head")"
|
|
282
|
+
[ -n "$tail" ] && resolved="${resolved%/}/$tail"
|
|
283
|
+
hq_normpath "${resolved%/}/$leaf"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
# hq_realpath_dir <dir>
|
|
287
|
+
# FULL symlink resolution for a directory that exists, including its own leaf;
|
|
288
|
+
# echoes the input unchanged if it is not a directory.
|
|
289
|
+
#
|
|
290
|
+
# Use this for the ROOT side of a prefix comparison. hq_realpath_lenient
|
|
291
|
+
# deliberately leaves the leaf alone, which is right for a write target but
|
|
292
|
+
# wrong for a root: when HQ itself is reached through a symlink (/home/me/hq
|
|
293
|
+
# -> /mnt/data/hq), a leaf-preserving root stays "/home/me/hq" while every
|
|
294
|
+
# fully-resolved file path under it reads "/mnt/data/hq/...", so the prefix
|
|
295
|
+
# test silently never matches and the guard passes everything.
|
|
296
|
+
# `cd -P`, not plain `cd`, for the same reason as hq_realpath_lenient — and it
|
|
297
|
+
# matters MORE here, because the two sides of the prefix test must agree. With
|
|
298
|
+
# link -> real/other and both real/hq and hq existing, a logical `cd` on
|
|
299
|
+
# link/../hq lands on hq while the file side resolves to real/hq/..., the
|
|
300
|
+
# prefix comparison misses, and the guard exits 0.
|
|
301
|
+
# The result is passed through hq_normpath for the same reason: BOTH operands
|
|
302
|
+
# of a prefix comparison must go through the identical normalizer, or a form
|
|
303
|
+
# one side preserves and the other rewrites (a leading "//", a trailing "/")
|
|
304
|
+
# silently breaks the match. Do not "simplify" this by returning pwd -P raw.
|
|
305
|
+
hq_realpath_dir() {
|
|
306
|
+
local d="$1" p
|
|
307
|
+
[ -d "$d" ] || { hq_normpath "$d"; return 0; }
|
|
308
|
+
p="$(cd -P "$d" 2>/dev/null && pwd -P)" || p="$d"
|
|
309
|
+
hq_normpath "$p"
|
|
111
310
|
}
|
|
112
311
|
|
|
113
312
|
# hq_shell_simple_commands <command>
|
|
@@ -129,8 +328,12 @@ hq_shell_simple_commands() {
|
|
|
129
328
|
for (j in argv) delete argv[j]
|
|
130
329
|
argc = 0
|
|
131
330
|
}
|
|
132
|
-
{
|
|
133
|
-
|
|
331
|
+
{ source = source $0 "\n" }
|
|
332
|
+
END {
|
|
333
|
+
# Parse the complete payload, not one physical input line at a time.
|
|
334
|
+
# A quoted `bash -c` program and a heredoc can span newlines; resetting
|
|
335
|
+
# quote state at each line silently discarded the command before it.
|
|
336
|
+
text = source
|
|
134
337
|
n = length(text); quote = ""; escaped = 0; word = ""; argc = 0
|
|
135
338
|
for (i = 1; i <= n; i++) {
|
|
136
339
|
c = substr(text, i, 1)
|
|
@@ -155,7 +358,12 @@ hq_shell_simple_commands() {
|
|
|
155
358
|
}
|
|
156
359
|
if (c == ">" || c == "<") {
|
|
157
360
|
push_word(); argv[++argc] = c
|
|
158
|
-
|
|
361
|
+
# Keep the Bash noclobber-override redirection (>|) with its target.
|
|
362
|
+
# Treating the pipe byte as a command boundary would otherwise split
|
|
363
|
+
# `printf x >| target` into two harmless-looking records.
|
|
364
|
+
if (substr(text, i + 1, 1) == c || (c == ">" && substr(text, i + 1, 1) == "|")) {
|
|
365
|
+
argv[argc] = argv[argc] substr(text, i + 1, 1); i++
|
|
366
|
+
}
|
|
159
367
|
continue
|
|
160
368
|
}
|
|
161
369
|
word = word c
|
|
@@ -174,14 +382,18 @@ hq_shell_command_executable() {
|
|
|
174
382
|
local i=0
|
|
175
383
|
while [ "$i" -lt "${#argv[@]}" ]; do
|
|
176
384
|
token="${argv[$i]}"
|
|
385
|
+
if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
|
|
386
|
+
i=$((i + 1)); continue
|
|
387
|
+
fi
|
|
177
388
|
case "$token" in
|
|
178
|
-
[A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
|
|
179
389
|
env)
|
|
180
390
|
i=$((i + 1))
|
|
181
391
|
while [ "$i" -lt "${#argv[@]}" ]; do
|
|
182
392
|
token="${argv[$i]}"
|
|
393
|
+
if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
|
|
394
|
+
i=$((i + 1)); continue
|
|
395
|
+
fi
|
|
183
396
|
case "$token" in
|
|
184
|
-
[A-Za-z_][A-Za-z0-9_]*=*) i=$((i + 1)); continue ;;
|
|
185
397
|
-*) i=$((i + 1)); continue ;;
|
|
186
398
|
esac
|
|
187
399
|
break
|
|
@@ -354,6 +566,34 @@ hq_path_matches_core_yaml_exclude() {
|
|
|
354
566
|
return 1
|
|
355
567
|
}
|
|
356
568
|
|
|
569
|
+
# hq_strip_tokens_containing_literals <command> <literal>...
|
|
570
|
+
# Remove whitespace-delimited command tokens containing any literal needle,
|
|
571
|
+
# preserving separators. This stays in Bash so hook hot paths do not spawn a
|
|
572
|
+
# sed process per configured exclude entry.
|
|
573
|
+
hq_strip_tokens_containing_literals() {
|
|
574
|
+
local input="$1" remaining token after_token separator consumed needle match
|
|
575
|
+
local whitespace_chars=$' \t\n\r'
|
|
576
|
+
shift
|
|
577
|
+
local -a needles=("$@")
|
|
578
|
+
local -a output_parts=()
|
|
579
|
+
remaining="$input"
|
|
580
|
+
while [ -n "$remaining" ]; do
|
|
581
|
+
token="${remaining%%[$whitespace_chars]*}"
|
|
582
|
+
after_token="${remaining:${#token}}"
|
|
583
|
+
separator="${after_token%%[!$whitespace_chars]*}"
|
|
584
|
+
match=no
|
|
585
|
+
for needle in "${needles[@]}"; do
|
|
586
|
+
[ -n "$needle" ] || continue
|
|
587
|
+
case "$token" in *"$needle"*) match=yes; break ;; esac
|
|
588
|
+
done
|
|
589
|
+
[ "$match" = yes ] || output_parts+=("$token")
|
|
590
|
+
output_parts+=("$separator")
|
|
591
|
+
consumed=$((${#token} + ${#separator}))
|
|
592
|
+
remaining="${remaining:$consumed}"
|
|
593
|
+
done
|
|
594
|
+
printf '%s' "${output_parts[@]}"
|
|
595
|
+
}
|
|
596
|
+
|
|
357
597
|
# hq_bash_strip_core_yaml_exclude_tokens <command> <hq_root> [<core_yaml>]
|
|
358
598
|
# Best-effort removal of command tokens that reference rules.exclude paths.
|
|
359
599
|
# Match full root-relative (and absolute) exclude paths — never basename
|
|
@@ -363,35 +603,44 @@ hq_bash_strip_core_yaml_exclude_tokens() {
|
|
|
363
603
|
local cmd="$1"
|
|
364
604
|
local hq_root="$2"
|
|
365
605
|
local core_yaml="${3:-$hq_root/core/core.yaml}"
|
|
366
|
-
local exc_path rel abs
|
|
606
|
+
local exc_path rel abs exclude_paths stripped abs_paths_text normalized_abs_paths
|
|
607
|
+
local -a exclude_needles=()
|
|
367
608
|
|
|
368
609
|
[ -n "$cmd" ] || { printf '%s' "$cmd"; return 0; }
|
|
369
610
|
[ -f "$core_yaml" ] || { printf '%s' "$cmd"; return 0; }
|
|
370
611
|
command -v yq >/dev/null 2>&1 || { printf '%s' "$cmd"; return 0; }
|
|
371
612
|
|
|
372
613
|
hq_root="$(hq_canonical_path "$hq_root")"
|
|
373
|
-
sed_script=''
|
|
374
614
|
exclude_paths="$(yq eval '.rules.exclude[]' "$core_yaml" 2>/dev/null)" || exclude_paths=""
|
|
615
|
+
abs_paths_text=""
|
|
375
616
|
while IFS= read -r exc_path; do
|
|
376
617
|
[ -n "$exc_path" ] || continue
|
|
377
618
|
rel="${exc_path%/}"
|
|
378
619
|
[ -n "$rel" ] || continue
|
|
379
|
-
|
|
380
|
-
esc="$(printf '%s' "$rel" | sed 's/[][\\.*^$(){}?+|/]/\\&/g')"
|
|
381
|
-
sed_script="${sed_script}s|[^[:space:]]*${esc}[^[:space:]]*||g; s|${esc}||g;"
|
|
382
|
-
|
|
620
|
+
exclude_needles+=("$rel")
|
|
383
621
|
if [[ "$exc_path" == */ ]]; then
|
|
384
|
-
|
|
385
|
-
sed_script="${sed_script}s|[^[:space:]]*${esc_dir}[^[:space:]]*||g; s|${esc_dir}||g;"
|
|
622
|
+
exclude_needles+=("$exc_path")
|
|
386
623
|
fi
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
624
|
+
# hq_root is already in canonical slash form. Normalize all absolute
|
|
625
|
+
# exclude paths in one Bash pass below instead of launching one command
|
|
626
|
+
# substitution (and, on MSYS, cygpath process) per entry.
|
|
627
|
+
abs_paths_text+="${hq_root%/}/${rel}"$'\n'
|
|
391
628
|
done <<< "$exclude_paths"
|
|
392
629
|
|
|
393
|
-
[
|
|
394
|
-
|
|
630
|
+
[ "${#exclude_needles[@]}" -gt 0 ] || { printf '%s' "$cmd"; return 0; }
|
|
631
|
+
normalized_abs_paths="$(hq_normpath "$abs_paths_text")" || normalized_abs_paths=""
|
|
632
|
+
while IFS= read -r abs; do
|
|
633
|
+
[ -n "$abs" ] && exclude_needles+=("$abs")
|
|
634
|
+
done <<< "$normalized_abs_paths"
|
|
635
|
+
stripped="$(hq_strip_tokens_containing_literals "$cmd" "${exclude_needles[@]}")" || stripped=""
|
|
636
|
+
# Never fail open. An empty helper result would make block-core-writes-bash
|
|
637
|
+
# treat the command as touching no protected paths and allow the write.
|
|
638
|
+
# Hand the original command back so the caller still sees the real targets.
|
|
639
|
+
if [ -z "$stripped" ]; then
|
|
640
|
+
printf '%s' "$cmd"
|
|
641
|
+
return 0
|
|
642
|
+
fi
|
|
643
|
+
printf '%s' "$stripped"
|
|
395
644
|
}
|
|
396
645
|
|
|
397
646
|
|
|
@@ -434,6 +683,36 @@ hq_path_label() {
|
|
|
434
683
|
esac
|
|
435
684
|
}
|
|
436
685
|
|
|
686
|
+
# Git Bash/MSYS chmod rewrites NTFS ACLs with DENY ACEs. That can make the
|
|
687
|
+
# owner unable to read a file whose POSIX mode still looks like -rw-r--r--.
|
|
688
|
+
# Never chmod HQ files on Windows; launch with bash instead.
|
|
689
|
+
hq_windows_native_path() {
|
|
690
|
+
local p="${1:-}"
|
|
691
|
+
[ -n "$p" ] || return 1
|
|
692
|
+
if command -v cygpath >/dev/null 2>&1; then
|
|
693
|
+
cygpath -w "$p" 2>/dev/null || printf '%s' "$p"
|
|
694
|
+
return 0
|
|
695
|
+
fi
|
|
696
|
+
printf '%s' "$p"
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
hq_repair_windows_acl() {
|
|
700
|
+
local path="${1:-}" native user
|
|
701
|
+
[ -n "$path" ] || return 1
|
|
702
|
+
[ -e "$path" ] || return 1
|
|
703
|
+
[ -r "$path" ] && return 0
|
|
704
|
+
[ "${HQ_LIB_WINSEP:-0}" = 1 ] || return 1
|
|
705
|
+
command -v icacls >/dev/null 2>&1 || return 1
|
|
706
|
+
native="$(hq_windows_native_path "$path")" || return 1
|
|
707
|
+
user="${USERNAME:-${USER:-}}"
|
|
708
|
+
icacls "$native" /reset >/dev/null 2>&1 || true
|
|
709
|
+
if [ -n "$user" ]; then
|
|
710
|
+
icacls "$native" /remove:d "$user" >/dev/null 2>&1 || true
|
|
711
|
+
icacls "$native" /grant:r "${user}:(RX)" >/dev/null 2>&1 || true
|
|
712
|
+
fi
|
|
713
|
+
[ -r "$path" ]
|
|
714
|
+
}
|
|
715
|
+
|
|
437
716
|
hq_hook_repair_command() {
|
|
438
717
|
local root="$1"
|
|
439
718
|
local path="$2"
|
|
@@ -443,6 +722,10 @@ hq_hook_repair_command() {
|
|
|
443
722
|
fi
|
|
444
723
|
rel="$(hq_path_label "$root" "$path")"
|
|
445
724
|
[ -n "$rel" ] || return 0
|
|
725
|
+
if [ "${HQ_LIB_WINSEP:-0}" = 1 ]; then
|
|
726
|
+
printf 'icacls "$(cygpath -w "$HQ_ROOT/%s")" /grant:r "%%USERNAME%%:(RX)"' "$rel"
|
|
727
|
+
return 0
|
|
728
|
+
fi
|
|
446
729
|
printf 'chmod u+x "$HQ_ROOT/%s"' "$rel"
|
|
447
730
|
}
|
|
448
731
|
|
|
@@ -520,13 +803,24 @@ hq_launch_shell_path() {
|
|
|
520
803
|
return 127
|
|
521
804
|
fi
|
|
522
805
|
|
|
523
|
-
if [ ! -
|
|
806
|
+
if [ ! -r "$path" ] && hq_path_within_root "$root" "$path"; then
|
|
807
|
+
hq_repair_windows_acl "$path" || true
|
|
808
|
+
fi
|
|
809
|
+
|
|
810
|
+
# chmod on Git Bash writes NTFS DENY ACEs; skip it on Windows.
|
|
811
|
+
if [ ! -x "$path" ] && hq_path_within_root "$root" "$path" \
|
|
812
|
+
&& [ "${HQ_LIB_WINSEP:-0}" != 1 ]; then
|
|
524
813
|
chmod u+x "$path" 2>/dev/null || true
|
|
525
814
|
fi
|
|
526
815
|
|
|
527
816
|
if [ -x "$path" ]; then
|
|
528
817
|
printf '%s' "$payload" | "$path" "$@"
|
|
529
|
-
|
|
818
|
+
# PIPESTATUS[1], never $?. A hook that exits before reading stdin (the
|
|
819
|
+
# `[ -n "${HQ_CONDUCT_RUN_DIR:-}" ] || exit 0` guard shape) closes the read
|
|
820
|
+
# end while printf is still writing, so printf dies with SIGPIPE. Under the
|
|
821
|
+
# `pipefail` that every HQ dispatcher sets, $? is then 141 and a hook that
|
|
822
|
+
# exited 0 is reported as having failed. Read the hook's own element.
|
|
823
|
+
HQ_HOOK_LAST_STATUS=${PIPESTATUS[1]}
|
|
530
824
|
return "$HQ_HOOK_LAST_STATUS"
|
|
531
825
|
fi
|
|
532
826
|
|
|
@@ -536,13 +830,14 @@ hq_launch_shell_path() {
|
|
|
536
830
|
fi
|
|
537
831
|
if [ -r "$path" ] && [ -n "$bash_bin" ]; then
|
|
538
832
|
printf '%s' "$payload" | "$bash_bin" "$path" "$@"
|
|
539
|
-
|
|
833
|
+
# Same contract as the exec path above: the hook's status, not the writer's.
|
|
834
|
+
HQ_HOOK_LAST_STATUS=${PIPESTATUS[1]}
|
|
540
835
|
return "$HQ_HOOK_LAST_STATUS"
|
|
541
836
|
fi
|
|
542
837
|
|
|
543
838
|
if [ ! -r "$path" ]; then
|
|
544
839
|
if hq_path_within_root "$root" "$path"; then
|
|
545
|
-
HQ_HOOK_LAST_CAUSE="
|
|
840
|
+
HQ_HOOK_LAST_CAUSE="the file is not readable (on Windows, NTFS DENY ACEs can hide it while ls still shows -rw-r--r--)"
|
|
546
841
|
else
|
|
547
842
|
HQ_HOOK_LAST_CAUSE="file is not executable or readable"
|
|
548
843
|
fi
|