@indigoai-us/hq-cli 5.196.0 → 5.198.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.
@@ -0,0 +1,277 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { LanesError } from "./errors.js";
5
+ const KIND_SET = new Set(["blocker", "kpi", "okr", "directive"]);
6
+ const ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
7
+ function storeError(error, message, details = {}) {
8
+ return new LanesError(error, message, details);
9
+ }
10
+ function validateId(id) {
11
+ if (!ID_RE.test(id) || id === "." || id === "..") {
12
+ throw storeError("tracker_id_invalid", "Tracker record id must be a safe path segment", { id });
13
+ }
14
+ }
15
+ function recordPath(root, kind, id) {
16
+ validateId(id);
17
+ if (!KIND_SET.has(kind))
18
+ throw storeError("tracker_kind_invalid", "Unknown tracker record kind", { kind });
19
+ return path.join(root, kind, `${id}.json`);
20
+ }
21
+ function assertRealDirectory(directory) {
22
+ try {
23
+ const stat = fs.lstatSync(directory);
24
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
25
+ throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: directory });
26
+ }
27
+ }
28
+ catch (err) {
29
+ if (err.code !== "ENOENT")
30
+ throw err;
31
+ fs.mkdirSync(directory, { mode: 0o700 });
32
+ }
33
+ }
34
+ function ensureKindDirectory(root, kind) {
35
+ assertRealDirectory(root);
36
+ const directory = path.join(root, kind);
37
+ assertRealDirectory(directory);
38
+ return directory;
39
+ }
40
+ function existingKindDirectory(root, kind) {
41
+ try {
42
+ const rootStat = fs.lstatSync(root);
43
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
44
+ throw storeError("tracker_store_path_invalid", "Tracker storage path must be a real directory", { path: root });
45
+ }
46
+ }
47
+ catch (err) {
48
+ if (err.code === "ENOENT")
49
+ return null;
50
+ throw err;
51
+ }
52
+ const directory = path.join(root, kind);
53
+ try {
54
+ const kindStat = fs.lstatSync(directory);
55
+ if (!kindStat.isDirectory() || kindStat.isSymbolicLink()) {
56
+ throw storeError("tracker_store_path_invalid", "Tracker kind path must be a real directory", { path: directory });
57
+ }
58
+ return directory;
59
+ }
60
+ catch (err) {
61
+ if (err.code === "ENOENT")
62
+ return null;
63
+ throw err;
64
+ }
65
+ }
66
+ function parseRecord(text, expectedKind, id, file) {
67
+ let value;
68
+ try {
69
+ value = JSON.parse(text);
70
+ }
71
+ catch {
72
+ throw storeError("tracker_record_malformed", "Tracker record contains invalid JSON", { kind: expectedKind, id, path: file });
73
+ }
74
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
75
+ throw storeError("tracker_record_invalid", "Tracker record must be a JSON object", { kind: expectedKind, id, path: file });
76
+ }
77
+ const record = value;
78
+ if (record.schema_version !== "hq-lanes-tracker-record.v1" ||
79
+ record.kind !== expectedKind ||
80
+ record.id !== id ||
81
+ typeof record.company !== "string" || !record.company ||
82
+ typeof record.project_id !== "string" || !record.project_id ||
83
+ !Number.isSafeInteger(record.version) || (record.version ?? 0) < 1 ||
84
+ typeof record.created_at !== "string" || typeof record.updated_at !== "string") {
85
+ throw storeError("tracker_record_invalid", "Tracker record is missing valid common fields", { kind: expectedKind, id, path: file });
86
+ }
87
+ return record;
88
+ }
89
+ function writeExclusive(file, contents) {
90
+ const dir = path.dirname(file);
91
+ const temp = path.join(dir, `.${path.basename(file)}.${randomUUID()}.tmp`);
92
+ try {
93
+ fs.writeFileSync(temp, contents, { encoding: "utf8", flag: "wx", mode: 0o600 });
94
+ // link is an atomic create-if-absent operation; rename would overwrite a
95
+ // concurrent creator's record if a generated id ever collided.
96
+ fs.linkSync(temp, file);
97
+ }
98
+ catch (err) {
99
+ if (err.code === "EEXIST") {
100
+ throw storeError("tracker_record_exists", "Tracker record id already exists", { path: file });
101
+ }
102
+ throw err;
103
+ }
104
+ finally {
105
+ removeTempFile(temp);
106
+ }
107
+ }
108
+ function removeTempFile(file) {
109
+ try {
110
+ fs.unlinkSync(file);
111
+ }
112
+ catch (err) {
113
+ if (err.code !== "ENOENT")
114
+ throw err;
115
+ }
116
+ }
117
+ function withRecordLock(file, action) {
118
+ const lock = `${file}.lock`;
119
+ let fd;
120
+ for (let attempt = 0; attempt < 2; attempt += 1) {
121
+ try {
122
+ fd = fs.openSync(lock, "wx", 0o600);
123
+ fs.writeSync(fd, `${process.pid}\n`);
124
+ break;
125
+ }
126
+ catch (err) {
127
+ if (err.code !== "EEXIST")
128
+ throw err;
129
+ // Recover a lock only when it is old and its owner is gone. Rechecking
130
+ // inode and mtime before unlinking avoids deleting a freshly replaced lock.
131
+ let first;
132
+ try {
133
+ first = fs.lstatSync(lock);
134
+ }
135
+ catch (statError) {
136
+ if (statError.code === "ENOENT")
137
+ continue;
138
+ throw statError;
139
+ }
140
+ if (Date.now() - first.mtimeMs < 5 * 60_000) {
141
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
142
+ }
143
+ let owner = null;
144
+ try {
145
+ const raw = fs.readFileSync(lock, "utf8").trim();
146
+ const parsed = Number(raw);
147
+ if (Number.isSafeInteger(parsed) && parsed > 0)
148
+ owner = parsed;
149
+ }
150
+ catch (readError) {
151
+ if (readError.code !== "ENOENT")
152
+ throw readError;
153
+ }
154
+ if (owner !== null) {
155
+ try {
156
+ process.kill(owner, 0);
157
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
158
+ }
159
+ catch (probeError) {
160
+ if (probeError instanceof LanesError)
161
+ throw probeError;
162
+ if (probeError.code !== "ESRCH")
163
+ throw probeError;
164
+ }
165
+ }
166
+ try {
167
+ const second = fs.lstatSync(lock);
168
+ if (second.ino === first.ino && second.dev === first.dev && second.mtimeMs === first.mtimeMs)
169
+ fs.unlinkSync(lock);
170
+ }
171
+ catch (unlinkError) {
172
+ if (unlinkError.code !== "ENOENT")
173
+ throw unlinkError;
174
+ }
175
+ }
176
+ }
177
+ if (fd === undefined)
178
+ throw storeError("tracker_write_busy", "Tracker record is being updated; retry the command", { path: file });
179
+ try {
180
+ return action();
181
+ }
182
+ finally {
183
+ fs.closeSync(fd);
184
+ fs.unlinkSync(lock);
185
+ }
186
+ }
187
+ export function createLocalTrackerStore(projectDirectory, now = () => new Date()) {
188
+ const root = path.join(projectDirectory, "tracker");
189
+ const read = (kind, id) => {
190
+ const file = recordPath(root, kind, id);
191
+ let text;
192
+ try {
193
+ const stat = fs.lstatSync(file);
194
+ if (!stat.isFile() || stat.isSymbolicLink()) {
195
+ throw storeError("tracker_record_invalid", "Tracker record path must be a regular file", { kind, id, path: file });
196
+ }
197
+ text = fs.readFileSync(file, "utf8");
198
+ }
199
+ catch (err) {
200
+ if (err.code === "ENOENT")
201
+ return null;
202
+ throw err;
203
+ }
204
+ return parseRecord(text, kind, id, file);
205
+ };
206
+ return {
207
+ read,
208
+ list(kind) {
209
+ const directory = existingKindDirectory(root, kind);
210
+ if (directory === null)
211
+ return [];
212
+ return fs.readdirSync(directory)
213
+ .filter((name) => name.endsWith(".json"))
214
+ .sort()
215
+ .map((name) => {
216
+ const id = name.slice(0, -5);
217
+ return read(kind, id);
218
+ })
219
+ .filter((record) => record !== null)
220
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at) || a.id.localeCompare(b.id));
221
+ },
222
+ create(input) {
223
+ const kind = input.kind;
224
+ const directory = ensureKindDirectory(root, kind);
225
+ const id = typeof input.id === "string" && input.id ? input.id : randomUUID();
226
+ const time = now().toISOString();
227
+ const record = {
228
+ ...input,
229
+ schema_version: "hq-lanes-tracker-record.v1",
230
+ kind,
231
+ id,
232
+ company: input.company,
233
+ project_id: input.project_id,
234
+ version: 1,
235
+ created_at: time,
236
+ updated_at: time,
237
+ };
238
+ writeExclusive(path.join(directory, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`);
239
+ return record;
240
+ },
241
+ update(kind, id, expectedVersion, change) {
242
+ const file = recordPath(root, kind, id);
243
+ const directory = ensureKindDirectory(root, kind);
244
+ return withRecordLock(file, () => {
245
+ const current = read(kind, id);
246
+ if (!current)
247
+ throw storeError("tracker_record_not_found", `Tracker ${kind} record ${id} was not found`, { kind, id });
248
+ if (current.version !== expectedVersion) {
249
+ throw storeError("tracker_version_conflict", `Tracker record version conflict: expected ${expectedVersion}, found ${current.version}`, {
250
+ kind, id, expected: expectedVersion, actual: current.version,
251
+ });
252
+ }
253
+ const changed = change(current);
254
+ if (changed.id !== current.id || changed.kind !== current.kind || changed.company !== current.company || changed.project_id !== current.project_id || changed.created_at !== current.created_at) {
255
+ throw storeError("tracker_update_invalid", "Tracker update cannot change record identity", { kind, id });
256
+ }
257
+ const next = {
258
+ ...changed,
259
+ schema_version: "hq-lanes-tracker-record.v1",
260
+ version: current.version + 1,
261
+ created_at: current.created_at,
262
+ updated_at: now().toISOString(),
263
+ };
264
+ const temp = path.join(directory, `.${id}.${randomUUID()}.tmp`);
265
+ try {
266
+ fs.writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
267
+ fs.renameSync(temp, file);
268
+ }
269
+ finally {
270
+ removeTempFile(temp);
271
+ }
272
+ return next;
273
+ });
274
+ },
275
+ };
276
+ }
277
+ //# sourceMappingURL=tracker-store.js.map
@@ -12,9 +12,10 @@ export type EmbedLockProcessEvents = Pick<NodeJS.Process, 'once'> & {
12
12
  };
13
13
  export type EmbedLockRunResult = 'ran' | 'skipped' | 'busy' | 'unavailable';
14
14
  /**
15
- * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
16
- * another tool that does not call this module) cannot participate, because qmd
17
- * is a third-party package and does not know about HQ's lock protocol.
15
+ * Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
16
+ * update` / `qmd embed` (or another tool that does not call this module) cannot
17
+ * participate, because qmd is a third-party package and does not know about
18
+ * HQ's lock protocol.
18
19
  */
19
20
  export declare const EMBED_LOCK_NAME = "qmd-reindex-bg.lock";
20
21
  export declare function defaultEmbedLockDependencies(env?: NodeJS.ProcessEnv): EmbedLockDependencies;
@@ -26,7 +27,7 @@ export declare function releaseEmbedLock(home: string, dependencies: EmbedLockDe
26
27
  /** Install signal and exit cleanup for a lock owner, returning an optional unregister hook. */
27
28
  export declare function installEmbedLockCleanup(cleanup: () => void, processEvents?: EmbedLockProcessEvents, exit?: (code: number) => void): () => void;
28
29
  /**
29
- * Run one synchronous embed while owning the shared lock.
30
+ * Run one synchronous QMD write or reindex pass while owning the shared lock.
30
31
  *
31
32
  * This deliberately does not install signal handlers. `run` is a synchronous
32
33
  * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
@@ -1,9 +1,10 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  /**
4
- * Serializes embeddings started through hq-cli only. A bare `qmd embed` (or
5
- * another tool that does not call this module) cannot participate, because qmd
6
- * is a third-party package and does not know about HQ's lock protocol.
4
+ * Serializes QMD writes started through HQ-managed hq-cli paths. A bare `qmd
5
+ * update` / `qmd embed` (or another tool that does not call this module) cannot
6
+ * participate, because qmd is a third-party package and does not know about
7
+ * HQ's lock protocol.
7
8
  */
8
9
  export const EMBED_LOCK_NAME = 'qmd-reindex-bg.lock';
9
10
  export function defaultEmbedLockDependencies(env = process.env) {
@@ -261,7 +262,7 @@ export function installEmbedLockCleanup(cleanup, processEvents = process, exit =
261
262
  };
262
263
  }
263
264
  /**
264
- * Run one synchronous embed while owning the shared lock.
265
+ * Run one synchronous QMD write or reindex pass while owning the shared lock.
265
266
  *
266
267
  * This deliberately does not install signal handlers. `run` is a synchronous
267
268
  * qmd child, so JavaScript cannot execute a cleanup handler until it returns;
@@ -0,0 +1,5 @@
1
+ export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
2
+ export declare const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
3
+ /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
4
+ export declare function qmdReindexSingleFlightEnabled(): boolean;
5
+ //# sourceMappingURL=reindex-single-flight-flag.d.ts.map
@@ -0,0 +1,8 @@
1
+ import { resolveProcessFlagGate } from "../flag-registry.js";
2
+ export const QMD_REINDEX_SINGLE_FLIGHT_FLAG = "qmd.reindex-single-flight";
3
+ export const QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK = false;
4
+ /** Keep all HQ-managed QMD writers coordinated only for explicitly enabled canaries. */
5
+ export function qmdReindexSingleFlightEnabled() {
6
+ return resolveProcessFlagGate(QMD_REINDEX_SINGLE_FLIGHT_FLAG, { fallback: QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK }, () => QMD_REINDEX_SINGLE_FLIGHT_FLAG_FALLBACK);
7
+ }
8
+ //# sourceMappingURL=reindex-single-flight-flag.js.map
@@ -0,0 +1,19 @@
1
+ import type { FlagClient, FlagClientOptions } from "@indigoai-us/hq-flags-client";
2
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG = "cli.network.force-ipv4";
3
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK = false;
4
+ export declare const NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS = 1500;
5
+ type NetworkFlagClient = Pick<FlagClient, "ready" | "isEnabled" | "close">;
6
+ export interface NodeNetworkForceIpv4Dependencies {
7
+ env?: NodeJS.ProcessEnv;
8
+ createClient?: (options: FlagClientOptions) => NetworkFlagClient;
9
+ getToken?: () => string | Promise<string>;
10
+ reportFailure?: (error: unknown) => void;
11
+ }
12
+ /**
13
+ * Read the opt-in hq-flags gate before CLI dispatch. Local hq-flags overrides
14
+ * work without a token or endpoint; service decisions require a company
15
+ * identity and use a short request deadline so startup remains bounded.
16
+ */
17
+ export declare function nodeNetworkForceIpv4Enabled(dependencies?: NodeNetworkForceIpv4Dependencies): Promise<boolean>;
18
+ export {};
19
+ //# sourceMappingURL=node-network-compat-flag.d.ts.map
@@ -0,0 +1,87 @@
1
+ import { createFlagClient, readLocalOverride } from "@indigoai-us/hq-flags-client";
2
+ export const NODE_NETWORK_FORCE_IPV4_FLAG = "cli.network.force-ipv4";
3
+ export const NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK = false;
4
+ // Node waits up to 1s before trying the next address family. Keep this request
5
+ // deadline above that wait so a dead IPv6 path can reach the usable IPv4 host.
6
+ export const NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS = 1_500;
7
+ function safeErrorClass(error) {
8
+ const name = error instanceof Error ? error.name : "UnknownError";
9
+ return /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(name)
10
+ ? name
11
+ : "UnknownError";
12
+ }
13
+ function reportFlagFailure(error) {
14
+ process.stderr.write("HQ network IPv4 flag lookup failed (" +
15
+ safeErrorClass(error) +
16
+ "); using default off.\n");
17
+ }
18
+ async function loadCachedIdToken() {
19
+ const { loadCachedTokens } = await import("@indigoai-us/hq-cloud");
20
+ return loadCachedTokens()?.idToken ?? "";
21
+ }
22
+ /**
23
+ * Read the opt-in hq-flags gate before CLI dispatch. Local hq-flags overrides
24
+ * work without a token or endpoint; service decisions require a company
25
+ * identity and use a short request deadline so startup remains bounded.
26
+ */
27
+ export async function nodeNetworkForceIpv4Enabled(dependencies = {}) {
28
+ const env = dependencies.env ?? process.env;
29
+ const localOverride = readLocalOverride(NODE_NETWORK_FORCE_IPV4_FLAG, env);
30
+ if (localOverride !== null)
31
+ return localOverride;
32
+ const endpoint = env.HQ_FLAGS_API_URL?.trim() || "";
33
+ const companyUid = env.HQ_COMPANY_UID?.trim() || "";
34
+ if (!endpoint ||
35
+ !/^cmp_[A-Za-z0-9]{3,128}$/.test(companyUid)) {
36
+ return NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
37
+ }
38
+ const companySlug = env.HQ_COMPANY_SLUG?.trim() || "";
39
+ let client;
40
+ let failed = false;
41
+ let failureReported = false;
42
+ let enabled = NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
43
+ const reportFailureOnce = (error) => {
44
+ failed = true;
45
+ if (failureReported)
46
+ return;
47
+ failureReported = true;
48
+ (dependencies.reportFailure ?? reportFlagFailure)(error);
49
+ };
50
+ try {
51
+ const token = await (dependencies.getToken ?? loadCachedIdToken)();
52
+ if (!token)
53
+ return NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK;
54
+ const createClient = dependencies.createClient ?? createFlagClient;
55
+ client = createClient({
56
+ endpoint,
57
+ companyUid,
58
+ ...(companySlug && /^[A-Za-z0-9][A-Za-z0-9-]{0,63}$/.test(companySlug)
59
+ ? { companyIdentifiers: [companySlug] }
60
+ : {}),
61
+ env: {},
62
+ getToken: () => token,
63
+ refreshIntervalMs: 0,
64
+ requestTimeoutMs: NODE_NETWORK_FORCE_IPV4_FLAG_TIMEOUT_MS,
65
+ onError: reportFailureOnce,
66
+ });
67
+ await client.ready();
68
+ if (!failed) {
69
+ enabled = client.isEnabled(NODE_NETWORK_FORCE_IPV4_FLAG, {
70
+ fallback: NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK,
71
+ });
72
+ }
73
+ }
74
+ catch (error) {
75
+ reportFailureOnce(error);
76
+ }
77
+ finally {
78
+ try {
79
+ client?.close();
80
+ }
81
+ catch (error) {
82
+ reportFailureOnce(error);
83
+ }
84
+ }
85
+ return failed ? NODE_NETWORK_FORCE_IPV4_FLAG_FALLBACK : enabled;
86
+ }
87
+ //# sourceMappingURL=node-network-compat-flag.js.map
@@ -1,31 +1,28 @@
1
1
  /**
2
2
  * Node network defaults for HQ CLI entry points.
3
3
  *
4
- * Node's default 250 ms address-family attempt window can fail on hosts where
5
- * IPv6 is advertised but black-holed. On affected WSL installations, undici's
6
- * fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
7
- * window fixes the connection while retaining Node's automatic family choice.
4
+ * The compatibility timeout remains the default. On a canary where IPv6 is
5
+ * advertised but unusable, the default-off hq-flags gate can also prefer IPv4
6
+ * and disable automatic family racing before the CLI issues its first request.
8
7
  *
9
- * Keep this module dependency-free and import it immediately after the Node
10
- * version preflight. Operators can retain a custom value through NODE_OPTIONS
11
- * or a direct Node CLI option; HQ only supplies the compatibility default when
12
- * neither is present.
13
- *
14
- * Use a namespace import of `node:net` (not a static named import of
15
- * `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
16
- * before any entry-module body runs — including `node-preflight` — so importing
17
- * a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
18
- * module-link SyntaxError instead of the preflight's upgrade message.
8
+ * Keep this module safe to import immediately after the Node version preflight.
9
+ * The hq-flags reader loads dynamically so an unsupported runtime exits through
10
+ * the preflight rather than failing during module linking.
19
11
  */
20
12
  export declare const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
21
13
  export interface NodeNetworkCompatibilityOptions {
22
14
  nodeOptions?: string;
23
15
  execArgv?: readonly string[];
16
+ forceIpv4?: boolean;
24
17
  /**
25
- * Inject the net timeout setter (tests). Pass `null` to simulate a runtime
26
- * that lacks `setDefaultAutoSelectFamilyAttemptTimeout`.
18
+ * Inject the net timeout setter (tests). Pass null to simulate a runtime
19
+ * that lacks setDefaultAutoSelectFamilyAttemptTimeout.
27
20
  */
28
21
  setAttemptTimeout?: ((milliseconds: number) => void) | null;
22
+ /** Inject Node's automatic family selection setter (tests). */
23
+ setAutoSelectFamily?: ((enabled: boolean) => void) | null;
24
+ /** Inject Node's DNS result-order setter (tests). */
25
+ setDefaultResultOrder?: ((order: "ipv4first") => void) | null;
29
26
  }
30
27
  export declare function configureNodeNetworkCompatibility(options?: NodeNetworkCompatibilityOptions): boolean;
31
28
  //# sourceMappingURL=node-network-compat.d.ts.map
@@ -1,30 +1,35 @@
1
1
  /**
2
2
  * Node network defaults for HQ CLI entry points.
3
3
  *
4
- * Node's default 250 ms address-family attempt window can fail on hosts where
5
- * IPv6 is advertised but black-holed. On affected WSL installations, undici's
6
- * fetch() reaches ETIMEDOUT before its usable IPv4 path completes. A 1 second
7
- * window fixes the connection while retaining Node's automatic family choice.
4
+ * The compatibility timeout remains the default. On a canary where IPv6 is
5
+ * advertised but unusable, the default-off hq-flags gate can also prefer IPv4
6
+ * and disable automatic family racing before the CLI issues its first request.
8
7
  *
9
- * Keep this module dependency-free and import it immediately after the Node
10
- * version preflight. Operators can retain a custom value through NODE_OPTIONS
11
- * or a direct Node CLI option; HQ only supplies the compatibility default when
12
- * neither is present.
13
- *
14
- * Use a namespace import of `node:net` (not a static named import of
15
- * `setDefaultAutoSelectFamilyAttemptTimeout`). Named ESM imports are linked
16
- * before any entry-module body runs — including `node-preflight` — so importing
17
- * a Node 18.13+/20.4+ API by name would turn unsupported runtimes into a
18
- * module-link SyntaxError instead of the preflight's upgrade message.
8
+ * Keep this module safe to import immediately after the Node version preflight.
9
+ * The hq-flags reader loads dynamically so an unsupported runtime exits through
10
+ * the preflight rather than failing during module linking.
19
11
  */
12
+ import * as nodeDns from "node:dns";
20
13
  import * as nodeNet from "node:net";
21
14
  export const HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 1000;
22
15
  const ATTEMPT_TIMEOUT_OPTION = "--network-family-autoselection-attempt-timeout";
23
16
  function hasExplicitAttemptTimeout(nodeOptions, execArgv) {
24
- const optionPattern = new RegExp(`(?:^|\\s)${ATTEMPT_TIMEOUT_OPTION}(?:=|\\s|$)`);
17
+ const optionPattern = new RegExp("(?:^|\\s)" + ATTEMPT_TIMEOUT_OPTION + "(?:=|\\s|$)");
25
18
  return (optionPattern.test(nodeOptions) ||
26
19
  execArgv.some((argument) => argument === ATTEMPT_TIMEOUT_OPTION ||
27
- argument.startsWith(`${ATTEMPT_TIMEOUT_OPTION}=`)));
20
+ argument.startsWith(ATTEMPT_TIMEOUT_OPTION + "=")));
21
+ }
22
+ function hasExplicitFamilySelection(nodeOptions, execArgv) {
23
+ const optionPattern = /(?:^|\s)(?:--network-family-autoselection|--enable-network-family-autoselection|--no-network-family-autoselection|--dns-result-order)(?:=|\s|$)/;
24
+ return (optionPattern.test(nodeOptions) ||
25
+ execArgv.some((argument) => argument === "--network-family-autoselection" ||
26
+ argument === "--enable-network-family-autoselection" ||
27
+ argument === "--no-network-family-autoselection" ||
28
+ argument === "--dns-result-order" ||
29
+ argument.startsWith("--network-family-autoselection=") ||
30
+ argument.startsWith("--enable-network-family-autoselection=") ||
31
+ argument.startsWith("--no-network-family-autoselection=") ||
32
+ argument.startsWith("--dns-result-order=")));
28
33
  }
29
34
  function resolveSetAttemptTimeout(override) {
30
35
  if (override === null)
@@ -34,19 +39,69 @@ function resolveSetAttemptTimeout(override) {
34
39
  const fn = nodeNet.setDefaultAutoSelectFamilyAttemptTimeout;
35
40
  return typeof fn === "function" ? fn.bind(nodeNet) : undefined;
36
41
  }
42
+ function resolveSetAutoSelectFamily(override) {
43
+ if (override === null)
44
+ return undefined;
45
+ if (override)
46
+ return override;
47
+ const fn = nodeNet.setDefaultAutoSelectFamily;
48
+ return typeof fn === "function" ? fn.bind(nodeNet) : undefined;
49
+ }
50
+ function resolveSetDefaultResultOrder(override) {
51
+ if (override === null)
52
+ return undefined;
53
+ if (override)
54
+ return override;
55
+ const fn = nodeDns.setDefaultResultOrder;
56
+ return typeof fn === "function" ? fn.bind(nodeDns) : undefined;
57
+ }
58
+ function applyIpv4Preference(options, nodeOptions, execArgv) {
59
+ if (!options.forceIpv4 || hasExplicitFamilySelection(nodeOptions, execArgv)) {
60
+ return false;
61
+ }
62
+ const setAutoSelectFamily = resolveSetAutoSelectFamily(options.setAutoSelectFamily);
63
+ const setDefaultResultOrder = resolveSetDefaultResultOrder(options.setDefaultResultOrder);
64
+ if (!setAutoSelectFamily || !setDefaultResultOrder)
65
+ return false;
66
+ setDefaultResultOrder("ipv4first");
67
+ setAutoSelectFamily(false);
68
+ return true;
69
+ }
37
70
  export function configureNodeNetworkCompatibility(options = {}) {
38
71
  const nodeOptions = options.nodeOptions ?? process.env.NODE_OPTIONS ?? "";
39
72
  const execArgv = options.execArgv ?? process.execArgv;
73
+ const ipv4Applied = applyIpv4Preference(options, nodeOptions, execArgv);
40
74
  if (hasExplicitAttemptTimeout(nodeOptions, execArgv)) {
41
- return false;
75
+ return ipv4Applied;
42
76
  }
43
77
  const setAttemptTimeout = resolveSetAttemptTimeout(options.setAttemptTimeout);
44
78
  if (!setAttemptTimeout) {
45
- // Runtime lacks the API (pre-Node 18.13 / preflight should already have exited).
46
- return false;
79
+ // Runtime lacks the API; the Node preflight should already have exited.
80
+ return ipv4Applied;
47
81
  }
48
82
  setAttemptTimeout(HQ_NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS);
49
83
  return true;
50
84
  }
85
+ async function applyHqFlaggedIpv4Preference() {
86
+ try {
87
+ const { nodeNetworkForceIpv4Enabled } = await import("./node-network-compat-flag.js");
88
+ if (nodeNetworkForceIpv4Enabled &&
89
+ (await nodeNetworkForceIpv4Enabled())) {
90
+ configureNodeNetworkCompatibility({ forceIpv4: true });
91
+ }
92
+ }
93
+ catch (error) {
94
+ const rawName = error instanceof Error ? error.name : "UnknownError";
95
+ const errorName = /^[A-Za-z][A-Za-z0-9]{0,63}$/.test(rawName)
96
+ ? rawName
97
+ : "UnknownError";
98
+ process.stderr.write("HQ network IPv4 flag lookup failed (" +
99
+ errorName +
100
+ "); using default off.\n");
101
+ }
102
+ }
103
+ // Preserve the historical timeout before any flag request. If the hq-flags
104
+ // lookup succeeds, the IPv4 preference is applied before command dispatch.
51
105
  configureNodeNetworkCompatibility();
106
+ await applyHqFlaggedIpv4Preference();
52
107
  //# sourceMappingURL=node-network-compat.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.196.0",
3
+ "version": "5.198.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {