@indigoai-us/hq-cli 5.108.1 → 5.108.3
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 +79 -0
- package/assets/mesh-daemon/README.md +23 -0
- package/assets/mesh-daemon/launchd.plist.template +33 -0
- package/assets/mesh-daemon/systemd.system.service.template +27 -0
- package/assets/mesh-daemon/systemd.user.service.template +18 -0
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +267 -0
- package/dist/commands/core.js +5 -4
- package/dist/commands/index-cmd.js +13 -0
- package/dist/commands/mesh.d.ts +15 -0
- package/dist/commands/mesh.js +732 -4
- package/dist/commands/search.d.ts +23 -0
- package/dist/commands/search.js +42 -1
- package/dist/lib/doctor/checks/work-context.d.ts +23 -0
- package/dist/lib/doctor/checks/work-context.js +195 -0
- package/dist/lib/doctor/registry.js +4 -0
- package/dist/lib/mesh/api.d.ts +18 -0
- package/dist/lib/mesh/api.js +55 -0
- package/dist/lib/mesh/cache.js +35 -4
- package/dist/lib/mesh/client.d.ts +118 -0
- package/dist/lib/mesh/client.js +268 -0
- package/dist/lib/mesh/live/backoff.d.ts +16 -0
- package/dist/lib/mesh/live/backoff.js +20 -0
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +49 -0
- package/dist/lib/mesh/live/daemon/board-refresh.js +185 -0
- package/dist/lib/mesh/live/daemon/credentials.d.ts +71 -0
- package/dist/lib/mesh/live/daemon/credentials.js +227 -0
- package/dist/lib/mesh/live/daemon/doctor.d.ts +37 -0
- package/dist/lib/mesh/live/daemon/doctor.js +139 -0
- package/dist/lib/mesh/live/daemon/index.d.ts +24 -0
- package/dist/lib/mesh/live/daemon/index.js +14 -0
- package/dist/lib/mesh/live/daemon/install.d.ts +83 -0
- package/dist/lib/mesh/live/daemon/install.js +330 -0
- package/dist/lib/mesh/live/daemon/log.d.ts +11 -0
- package/dist/lib/mesh/live/daemon/log.js +79 -0
- package/dist/lib/mesh/live/daemon/paths.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/paths.js +31 -0
- package/dist/lib/mesh/live/daemon/pid-lock.d.ts +48 -0
- package/dist/lib/mesh/live/daemon/pid-lock.js +134 -0
- package/dist/lib/mesh/live/daemon/presence.d.ts +101 -0
- package/dist/lib/mesh/live/daemon/presence.js +250 -0
- package/dist/lib/mesh/live/daemon/presign.d.ts +18 -0
- package/dist/lib/mesh/live/daemon/presign.js +65 -0
- package/dist/lib/mesh/live/daemon/run.d.ts +74 -0
- package/dist/lib/mesh/live/daemon/run.js +400 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +24 -0
- package/dist/lib/mesh/live/daemon/state.js +57 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +170 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +520 -0
- package/dist/lib/mesh/live/enqueue.d.ts +48 -0
- package/dist/lib/mesh/live/enqueue.js +100 -0
- package/dist/lib/mesh/live/flush.d.ts +56 -0
- package/dist/lib/mesh/live/flush.js +360 -0
- package/dist/lib/mesh/live/format-spool-line.d.ts +42 -0
- package/dist/lib/mesh/live/format-spool-line.js +106 -0
- package/dist/lib/mesh/live/index.d.ts +22 -0
- package/dist/lib/mesh/live/index.js +16 -0
- package/dist/lib/mesh/live/paths.d.ts +13 -0
- package/dist/lib/mesh/live/paths.js +30 -0
- package/dist/lib/mesh/live/session-events-client.d.ts +58 -0
- package/dist/lib/mesh/live/session-events-client.js +120 -0
- package/dist/lib/mesh/live/session-identity.d.ts +39 -0
- package/dist/lib/mesh/live/session-identity.js +91 -0
- package/dist/lib/mesh/live/spool.d.ts +52 -0
- package/dist/lib/mesh/live/spool.js +193 -0
- package/dist/lib/mesh/live/ulid.d.ts +12 -0
- package/dist/lib/mesh/live/ulid.js +39 -0
- package/dist/lib/mesh/live/validate-session-event.d.ts +39 -0
- package/dist/lib/mesh/live/validate-session-event.js +128 -0
- package/dist/lib/search-index/index.d.ts +98 -0
- package/dist/lib/search-index/index.js +174 -2
- package/dist/lib/work-context/atomic.d.ts +18 -0
- package/dist/lib/work-context/atomic.js +132 -0
- package/dist/lib/work-context/company.d.ts +103 -0
- package/dist/lib/work-context/company.js +274 -0
- package/dist/lib/work-context/config.d.ts +59 -0
- package/dist/lib/work-context/config.js +172 -0
- package/dist/lib/work-context/contract.d.ts +81 -0
- package/dist/lib/work-context/contract.js +65 -0
- package/dist/lib/work-context/errors.d.ts +27 -0
- package/dist/lib/work-context/errors.js +46 -0
- package/dist/lib/work-context/index.d.ts +23 -0
- package/dist/lib/work-context/index.js +20 -0
- package/dist/lib/work-context/migrate.d.ts +84 -0
- package/dist/lib/work-context/migrate.js +287 -0
- package/dist/lib/work-context/organize.d.ts +130 -0
- package/dist/lib/work-context/organize.js +471 -0
- package/dist/lib/work-context/outbox.d.ts +97 -0
- package/dist/lib/work-context/outbox.js +357 -0
- package/dist/lib/work-context/paths.d.ts +14 -0
- package/dist/lib/work-context/paths.js +39 -0
- package/dist/lib/work-context/project.d.ts +78 -0
- package/dist/lib/work-context/project.js +247 -0
- package/dist/lib/work-context/reconcile.d.ts +85 -0
- package/dist/lib/work-context/reconcile.js +651 -0
- package/dist/lib/work-context/repo-remote.d.ts +43 -0
- package/dist/lib/work-context/repo-remote.js +200 -0
- package/dist/lib/work-context/state.d.ts +112 -0
- package/dist/lib/work-context/state.js +162 -0
- package/dist/main.js +93 -4
- package/dist/utils/cognito-session.d.ts +85 -9
- package/dist/utils/cognito-session.js +216 -9
- package/dist/utils/qmd-model-download-error.d.ts +27 -0
- package/dist/utils/qmd-model-download-error.js +114 -0
- package/dist/utils/qmd-query-document-error.d.ts +23 -0
- package/dist/utils/qmd-query-document-error.js +117 -0
- package/dist/utils/qmd-workdir-missing-error.d.ts +18 -0
- package/dist/utils/qmd-workdir-missing-error.js +78 -0
- package/dist/utils/sentry-fingerprint.js +7 -0
- package/package.json +5 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Work Mesh Live — session-event validator (US-001).
|
|
3
|
+
*
|
|
4
|
+
* Uses Ajv draft 2020-12 against the verbatim schema copy. Prohibited content
|
|
5
|
+
* classes are rejected (additionalProperties: false). Error messages never
|
|
6
|
+
* echo prohibited marker values (redaction assertion surface).
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
export const SESSION_EVENT_SCHEMA_PATH = join(HERE, "session-event.schema.json");
|
|
15
|
+
/** Expected SHA-256 of the canonical schema bytes (hq-pro + hq-cli must match). */
|
|
16
|
+
export const SESSION_EVENT_SCHEMA_SHA256 = "be300120b4793a5c6f1e713bf9f72b2fd22f6ac082bef8eedfa507b684b09ccb";
|
|
17
|
+
export const PROHIBITED_CONTENT_CLASSES = [
|
|
18
|
+
"prompts",
|
|
19
|
+
"model_output",
|
|
20
|
+
"transcripts",
|
|
21
|
+
"message_bodies",
|
|
22
|
+
"tokens",
|
|
23
|
+
"credentials",
|
|
24
|
+
];
|
|
25
|
+
/** Fixture field name → prohibited class. */
|
|
26
|
+
export const PROHIBITED_FIELD_BY_CLASS = {
|
|
27
|
+
prompts: "prompt",
|
|
28
|
+
model_output: "modelOutput",
|
|
29
|
+
transcripts: "transcript",
|
|
30
|
+
message_bodies: "messageBody",
|
|
31
|
+
tokens: "token",
|
|
32
|
+
credentials: "credentials",
|
|
33
|
+
};
|
|
34
|
+
const REDACT_MARKERS = [
|
|
35
|
+
"REDACT_MARKER_PROMPT_",
|
|
36
|
+
"REDACT_MARKER_MODEL_OUTPUT_",
|
|
37
|
+
"REDACT_MARKER_TRANSCRIPT_",
|
|
38
|
+
"REDACT_MARKER_MESSAGE_BODY_",
|
|
39
|
+
"REDACT_MARKER_TOKEN_",
|
|
40
|
+
"REDACT_MARKER_CREDENTIALS_",
|
|
41
|
+
];
|
|
42
|
+
let cachedValidate;
|
|
43
|
+
function loadSchema() {
|
|
44
|
+
return JSON.parse(readFileSync(SESSION_EVENT_SCHEMA_PATH, "utf8"));
|
|
45
|
+
}
|
|
46
|
+
export function getSessionEventValidator() {
|
|
47
|
+
if (cachedValidate)
|
|
48
|
+
return cachedValidate;
|
|
49
|
+
const ajv = new Ajv2020({
|
|
50
|
+
allErrors: true,
|
|
51
|
+
strict: false,
|
|
52
|
+
validateFormats: false,
|
|
53
|
+
});
|
|
54
|
+
cachedValidate = ajv.compile(loadSchema());
|
|
55
|
+
return cachedValidate;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Redact known prohibited markers from Ajv error text so diagnostics never
|
|
59
|
+
* leak prompt/token/credential material.
|
|
60
|
+
*/
|
|
61
|
+
export function redactValidationMessage(message) {
|
|
62
|
+
let out = message;
|
|
63
|
+
for (const marker of REDACT_MARKERS) {
|
|
64
|
+
if (out.includes(marker)) {
|
|
65
|
+
out = out.split(marker)[0] + "[REDACTED]";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Also strip any bearer-shaped or op:// fragments that slipped through.
|
|
69
|
+
out = out.replace(/Bearer\s+[A-Za-z0-9._-]+/gi, "Bearer [REDACTED]");
|
|
70
|
+
out = out.replace(/op:\/\/[^\s"]+/g, "op://[REDACTED]");
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
function formatErrors(errors) {
|
|
74
|
+
if (!errors || errors.length === 0)
|
|
75
|
+
return ["validation_failed"];
|
|
76
|
+
return errors.map((e) => {
|
|
77
|
+
const path = e.instancePath || "/";
|
|
78
|
+
const msg = e.message ?? "invalid";
|
|
79
|
+
return redactValidationMessage(`${path}: ${msg}`);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
export function validateSessionEvent(candidate) {
|
|
83
|
+
const validate = getSessionEventValidator();
|
|
84
|
+
const ok = validate(candidate);
|
|
85
|
+
if (ok)
|
|
86
|
+
return { ok: true, errors: [] };
|
|
87
|
+
return { ok: false, errors: formatErrors(validate.errors) };
|
|
88
|
+
}
|
|
89
|
+
export function sha256File(path) {
|
|
90
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
export function sha256SessionEventSchema() {
|
|
93
|
+
return sha256File(SESSION_EVENT_SCHEMA_PATH);
|
|
94
|
+
}
|
|
95
|
+
const HQ_PRO_SCHEMA_RELATIVE = "src/vault-service/work-mesh/live/session-event.schema.json";
|
|
96
|
+
/** Walk up from `start` until a directory containing package.json is found. */
|
|
97
|
+
function findRepoRoot(start) {
|
|
98
|
+
let dir = start;
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (existsSync(join(dir, "package.json")))
|
|
101
|
+
return dir;
|
|
102
|
+
const parent = dirname(dir);
|
|
103
|
+
if (parent === dir)
|
|
104
|
+
return null;
|
|
105
|
+
dir = parent;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve the hq-pro canonical schema path when available.
|
|
110
|
+
*
|
|
111
|
+
* Order: `HQ_PRO_SCHEMA_PATH` env override (absolute path to the hq-pro
|
|
112
|
+
* schema file) → sibling checkout
|
|
113
|
+
* `../hq-pro/src/vault-service/work-mesh/live/session-event.schema.json`
|
|
114
|
+
* from the hq-cli repo root → null.
|
|
115
|
+
* Shipped code must not hardcode machine-specific worktree paths.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveHqProSchemaPath() {
|
|
118
|
+
const envPath = process.env.HQ_PRO_SCHEMA_PATH?.trim();
|
|
119
|
+
if (envPath) {
|
|
120
|
+
return isAbsolute(envPath) ? envPath : resolve(envPath);
|
|
121
|
+
}
|
|
122
|
+
const repoRoot = findRepoRoot(HERE);
|
|
123
|
+
if (!repoRoot)
|
|
124
|
+
return null;
|
|
125
|
+
const sibling = resolve(repoRoot, "..", "hq-pro", HQ_PRO_SCHEMA_RELATIVE);
|
|
126
|
+
return existsSync(sibling) ? sibling : null;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=validate-session-event.js.map
|
|
@@ -38,6 +38,25 @@ export type QmdInvocation = {
|
|
|
38
38
|
prefixArgs: string[];
|
|
39
39
|
execDir?: string;
|
|
40
40
|
};
|
|
41
|
+
/**
|
|
42
|
+
* Bounded, hq-DERIVED evidence stamped onto a CAPTURED qmd spawn-level failure
|
|
43
|
+
* (the interpreter-missing and residual QmdBinaryMissingError cases). The
|
|
44
|
+
* reporting host (HQ-CLI-1A) shipped no platform log, so spawnSync's ENOENT
|
|
45
|
+
* could not be pinned to a missing command vs. a missing working directory. The
|
|
46
|
+
* boundary forwards this into a bounded Sentry context so the NEXT occurrence
|
|
47
|
+
* carries the evidence this one lacked. Every field is hq-derived — the errno
|
|
48
|
+
* code, the resolved `cwd`, `process.execPath`, the bundled launcher, and their
|
|
49
|
+
* existence at failure time — never caller argv or query text.
|
|
50
|
+
*/
|
|
51
|
+
export type QmdSpawnDiagnostics = {
|
|
52
|
+
errnoCode: string | null;
|
|
53
|
+
cwd: string | null;
|
|
54
|
+
cwdExists: boolean | null;
|
|
55
|
+
command: string;
|
|
56
|
+
commandExists: boolean;
|
|
57
|
+
launcher: string;
|
|
58
|
+
launcherExists: boolean;
|
|
59
|
+
};
|
|
41
60
|
/** Low-level spawn seam (defaults to `spawnSync`); injected in tests. */
|
|
42
61
|
export type QmdSpawn = (cmd: string, args: string[], options: {
|
|
43
62
|
cwd?: string;
|
|
@@ -59,6 +78,34 @@ export type QmdSpawn = (cmd: string, args: string[], options: {
|
|
|
59
78
|
export declare function withNodeDirOnPath(env: NodeJS.ProcessEnv | undefined, execDir: string | undefined, platform?: NodeJS.Platform): NodeJS.ProcessEnv | undefined;
|
|
60
79
|
export declare class QmdBinaryMissingError extends Error {
|
|
61
80
|
name: string;
|
|
81
|
+
/**
|
|
82
|
+
* Present only when the failure came through the real spawn path (attribution
|
|
83
|
+
* available): the errno code and per-component existence booleans captured at
|
|
84
|
+
* failure time, forwarded to a bounded Sentry context by the boundary so the
|
|
85
|
+
* next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
|
|
86
|
+
* Absent on the legacy injected-runner path and the resolver's opaque throws,
|
|
87
|
+
* so their existing shapes are unchanged.
|
|
88
|
+
*/
|
|
89
|
+
readonly spawnDiagnostics?: QmdSpawnDiagnostics;
|
|
90
|
+
constructor(message: string, spawnDiagnostics?: QmdSpawnDiagnostics);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* qmd could not be SPAWNED because its working directory does not exist:
|
|
94
|
+
* spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
|
|
95
|
+
* every index call site) before it could exec the command. On Linux that ENOENT
|
|
96
|
+
* is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
|
|
97
|
+
* COMMAND either way — so finishRunQmd disambiguates by existence and raises
|
|
98
|
+
* THIS typed carrier only when the working directory itself is absent. Carries
|
|
99
|
+
* the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
|
|
100
|
+
* `errnoCode`; a missing working directory is the caller's filesystem, so the
|
|
101
|
+
* boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
|
|
102
|
+
*/
|
|
103
|
+
export declare class QmdWorkdirMissingError extends Error {
|
|
104
|
+
readonly workdir: string;
|
|
105
|
+
readonly command: string;
|
|
106
|
+
readonly errnoCode: string;
|
|
107
|
+
name: string;
|
|
108
|
+
constructor(message: string, workdir: string, command: string, errnoCode: string);
|
|
62
109
|
}
|
|
63
110
|
export declare class QmdExitError extends Error {
|
|
64
111
|
readonly args: string[];
|
|
@@ -200,6 +247,51 @@ export declare class QmdStoreUnopenableError extends QmdExitError {
|
|
|
200
247
|
name: string;
|
|
201
248
|
constructor(message: string, args: string[], status: number | null, stdout: string, stderr: string, storeDir?: string | undefined, probeReason?: string | undefined);
|
|
202
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* qmd's `query` subcommand (hybrid search) rejected the positional as a
|
|
252
|
+
* MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
|
|
253
|
+
* document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
|
|
254
|
+
* expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
|
|
255
|
+
* caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
|
|
256
|
+
* forwarding a whole Slack message — not a bug HQ can fix in code.
|
|
257
|
+
*
|
|
258
|
+
* HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
|
|
259
|
+
* --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
|
|
260
|
+
* through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
|
|
261
|
+
* intent: prefix. …`. With no classifier the generic template raised a plain
|
|
262
|
+
* QmdExitError that reached the boundary's final else and was captured.
|
|
263
|
+
* buildSearchArgs now normalises free text to a single line so the common case
|
|
264
|
+
* never reaches qmd multi-line; this typed subclass lets the boundary classify a
|
|
265
|
+
* genuinely malformed structured document as the caller's input and stop
|
|
266
|
+
* fingerprinting on the query text: it is raised only from qmd's OWN captured
|
|
267
|
+
* streams and its message names the SUBCOMMAND only.
|
|
268
|
+
*/
|
|
269
|
+
export declare class QmdQueryDocumentError extends QmdExitError {
|
|
270
|
+
name: string;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
|
|
274
|
+
* own partial `.ipull` download file into place. The partial file was gone by the
|
|
275
|
+
* time the rename ran — the signature of ANOTHER qmd process on the same box
|
|
276
|
+
* finishing (or clearing) the same download first. That is a transient, retryable
|
|
277
|
+
* LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
|
|
278
|
+
* children other users start on the same host, and reaching into qmd's model
|
|
279
|
+
* cache would be hq owning upstream state.
|
|
280
|
+
*
|
|
281
|
+
* HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
|
|
282
|
+
* 5.107.1): `qmd query` reached its reranking stage and its reranker-model
|
|
283
|
+
* downloader died uncaught with `[Error: ENOENT: no such file or directory,
|
|
284
|
+
* rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
|
|
285
|
+
* With no classifier it took the identical fall-through to capture as the
|
|
286
|
+
* query-document crash — and because both share the (QmdExitError, qmd:query,
|
|
287
|
+
* exit:1) fingerprint, a query-document-only fix would leave this issue alive.
|
|
288
|
+
* This typed subclass lets the boundary classify it and stop the capture: it is
|
|
289
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
290
|
+
* SUBCOMMAND only.
|
|
291
|
+
*/
|
|
292
|
+
export declare class QmdModelDownloadError extends QmdExitError {
|
|
293
|
+
name: string;
|
|
294
|
+
}
|
|
203
295
|
export type ResolveQmdBinOptions = {
|
|
204
296
|
env?: Record<string, string | undefined>;
|
|
205
297
|
isExecutable?: (candidate: string) => boolean;
|
|
@@ -424,6 +516,12 @@ export type RunQmdOptions = {
|
|
|
424
516
|
execPath?: string;
|
|
425
517
|
/** Invocation resolver seam (default {@link resolveQmdInvocation}). */
|
|
426
518
|
resolveInvocation?: (options: ResolveQmdInvocationOptions) => QmdInvocation;
|
|
519
|
+
/**
|
|
520
|
+
* Existence-check seam threaded into finishRunQmd's spawn-failure
|
|
521
|
+
* disambiguation (default `fs.existsSync`); injected in tests so the missing
|
|
522
|
+
* cwd / interpreter branches are provable without touching the real disk.
|
|
523
|
+
*/
|
|
524
|
+
exists?: (candidate: string) => boolean;
|
|
427
525
|
};
|
|
428
526
|
/** Run qmd with captured output and typed failures. */
|
|
429
527
|
export declare function runQmd(args: string[], options?: RunQmdOptions): QmdProcessResult;
|
|
@@ -4,8 +4,10 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import * as os from 'node:os';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { isQmdModelDownloadError } from '../../utils/qmd-model-download-error.js';
|
|
7
8
|
import { isQmdModuleMissingError } from '../../utils/qmd-module-missing-error.js';
|
|
8
9
|
import { isQmdNativeBindingError } from '../../utils/qmd-native-binding-error.js';
|
|
10
|
+
import { isQmdQueryDocumentError } from '../../utils/qmd-query-document-error.js';
|
|
9
11
|
import { isQmdStoreMissingError } from '../../utils/qmd-store-missing-error.js';
|
|
10
12
|
import { isQmdStoreUnopenableError } from '../../utils/qmd-store-unopenable-error.js';
|
|
11
13
|
import { redactErrorText } from '../../utils/redact-error-text.js';
|
|
@@ -28,6 +30,43 @@ export function withNodeDirOnPath(env, execDir, platform = process.platform) {
|
|
|
28
30
|
}
|
|
29
31
|
export class QmdBinaryMissingError extends Error {
|
|
30
32
|
name = 'QmdBinaryMissingError';
|
|
33
|
+
/**
|
|
34
|
+
* Present only when the failure came through the real spawn path (attribution
|
|
35
|
+
* available): the errno code and per-component existence booleans captured at
|
|
36
|
+
* failure time, forwarded to a bounded Sentry context by the boundary so the
|
|
37
|
+
* next occurrence carries the evidence the reported one lacked (HQ-CLI-1A).
|
|
38
|
+
* Absent on the legacy injected-runner path and the resolver's opaque throws,
|
|
39
|
+
* so their existing shapes are unchanged.
|
|
40
|
+
*/
|
|
41
|
+
spawnDiagnostics;
|
|
42
|
+
constructor(message, spawnDiagnostics) {
|
|
43
|
+
super(message);
|
|
44
|
+
if (spawnDiagnostics)
|
|
45
|
+
this.spawnDiagnostics = spawnDiagnostics;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* qmd could not be SPAWNED because its working directory does not exist:
|
|
50
|
+
* spawnSync returned ENOENT while chdir-ing into `cwd` (the resolved hq root at
|
|
51
|
+
* every index call site) before it could exec the command. On Linux that ENOENT
|
|
52
|
+
* is byte-identical to a missing-command ENOENT — Node sets `error.path` to the
|
|
53
|
+
* COMMAND either way — so finishRunQmd disambiguates by existence and raises
|
|
54
|
+
* THIS typed carrier only when the working directory itself is absent. Carries
|
|
55
|
+
* the hq-DERIVED `workdir`, the `command` that was spawned, and the bounded
|
|
56
|
+
* `errnoCode`; a missing working directory is the caller's filesystem, so the
|
|
57
|
+
* boundary prints qmdWorkdirMissingMessage and SKIPS capture (HQ-CLI-1A).
|
|
58
|
+
*/
|
|
59
|
+
export class QmdWorkdirMissingError extends Error {
|
|
60
|
+
workdir;
|
|
61
|
+
command;
|
|
62
|
+
errnoCode;
|
|
63
|
+
name = 'QmdWorkdirMissingError';
|
|
64
|
+
constructor(message, workdir, command, errnoCode) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.workdir = workdir;
|
|
67
|
+
this.command = command;
|
|
68
|
+
this.errnoCode = errnoCode;
|
|
69
|
+
}
|
|
31
70
|
}
|
|
32
71
|
export class QmdExitError extends Error {
|
|
33
72
|
args;
|
|
@@ -189,6 +228,51 @@ export class QmdStoreUnopenableError extends QmdExitError {
|
|
|
189
228
|
this.probeReason = probeReason;
|
|
190
229
|
}
|
|
191
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* qmd's `query` subcommand (hybrid search) rejected the positional as a
|
|
233
|
+
* MALFORMED QUERY DOCUMENT: qmd 2.5.3 parses a multi-line positional as a query
|
|
234
|
+
* document and requires EVERY non-empty line to carry a lex:/vec:/hyde:/intent:/
|
|
235
|
+
* expand: prefix (dist/cli/qmd.js:2157-2217), throwing otherwise. That is the
|
|
236
|
+
* caller's INPUT SHAPE — a multi-line free-text query, most often a fleet agent
|
|
237
|
+
* forwarding a whole Slack message — not a bug HQ can fix in code.
|
|
238
|
+
*
|
|
239
|
+
* HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, 28 of 29 events): `hq search
|
|
240
|
+
* --mode hybrid` mapped to `qmd query` and handed the caller's multi-line query
|
|
241
|
+
* through unchanged; qmd exited 1 with `Line 1 is missing a lex:/vec:/hyde:/
|
|
242
|
+
* intent: prefix. …`. With no classifier the generic template raised a plain
|
|
243
|
+
* QmdExitError that reached the boundary's final else and was captured.
|
|
244
|
+
* buildSearchArgs now normalises free text to a single line so the common case
|
|
245
|
+
* never reaches qmd multi-line; this typed subclass lets the boundary classify a
|
|
246
|
+
* genuinely malformed structured document as the caller's input and stop
|
|
247
|
+
* fingerprinting on the query text: it is raised only from qmd's OWN captured
|
|
248
|
+
* streams and its message names the SUBCOMMAND only.
|
|
249
|
+
*/
|
|
250
|
+
export class QmdQueryDocumentError extends QmdExitError {
|
|
251
|
+
name = 'QmdQueryDocumentError';
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* A qmd MODEL DOWNLOAD did not finish: qmd crashed with ENOENT while renaming its
|
|
255
|
+
* own partial `.ipull` download file into place. The partial file was gone by the
|
|
256
|
+
* time the rename ran — the signature of ANOTHER qmd process on the same box
|
|
257
|
+
* finishing (or clearing) the same download first. That is a transient, retryable
|
|
258
|
+
* LOCAL-CACHE race, not a bug HQ can fix in code: hq cannot coordinate qmd
|
|
259
|
+
* children other users start on the same host, and reaching into qmd's model
|
|
260
|
+
* cache would be hq owning upstream state.
|
|
261
|
+
*
|
|
262
|
+
* HQ-CLI-1B (Sentry indigo-d0/hq-cli 7705711476, the newest event, on release
|
|
263
|
+
* 5.107.1): `qmd query` reached its reranking stage and its reranker-model
|
|
264
|
+
* downloader died uncaught with `[Error: ENOENT: no such file or directory,
|
|
265
|
+
* rename '<cache>/…-reranker-….gguf.ipull' -> '<cache>/…-reranker-….gguf']`.
|
|
266
|
+
* With no classifier it took the identical fall-through to capture as the
|
|
267
|
+
* query-document crash — and because both share the (QmdExitError, qmd:query,
|
|
268
|
+
* exit:1) fingerprint, a query-document-only fix would leave this issue alive.
|
|
269
|
+
* This typed subclass lets the boundary classify it and stop the capture: it is
|
|
270
|
+
* raised only from qmd's OWN captured streams and its message names the
|
|
271
|
+
* SUBCOMMAND only.
|
|
272
|
+
*/
|
|
273
|
+
export class QmdModelDownloadError extends QmdExitError {
|
|
274
|
+
name = 'QmdModelDownloadError';
|
|
275
|
+
}
|
|
192
276
|
function isExecutable(candidate) {
|
|
193
277
|
try {
|
|
194
278
|
fs.accessSync(candidate, fs.constants.X_OK);
|
|
@@ -919,7 +1003,7 @@ function describeQmdTermination(stdout, stderr) {
|
|
|
919
1003
|
return '';
|
|
920
1004
|
}
|
|
921
1005
|
/** Normalise a spawn result and raise the typed qmd failures. */
|
|
922
|
-
function finishRunQmd(result, bin, args) {
|
|
1006
|
+
function finishRunQmd(result, bin, args, attribution) {
|
|
923
1007
|
const normalized = {
|
|
924
1008
|
status: result.status,
|
|
925
1009
|
stdout: result.stdout ?? '',
|
|
@@ -928,6 +1012,54 @@ function finishRunQmd(result, bin, args) {
|
|
|
928
1012
|
signal: result.signal,
|
|
929
1013
|
};
|
|
930
1014
|
if (normalized.error) {
|
|
1015
|
+
// A spawn-LEVEL failure: the child could not even be exec'd. When the real
|
|
1016
|
+
// spawn path supplied attribution we can attribute it to the right
|
|
1017
|
+
// component; the legacy injected-runner path passes none and keeps today's
|
|
1018
|
+
// exact class and message.
|
|
1019
|
+
if (attribution) {
|
|
1020
|
+
const spawnError = normalized.error;
|
|
1021
|
+
const errnoCode = typeof spawnError.code === 'string' ? spawnError.code : null;
|
|
1022
|
+
const exists = attribution.exists ?? ((candidate) => fs.existsSync(candidate));
|
|
1023
|
+
const cwd = attribution.cwd ?? null;
|
|
1024
|
+
const cwdExists = cwd !== null ? exists(cwd) : null;
|
|
1025
|
+
const commandExists = exists(attribution.command);
|
|
1026
|
+
const launcherExists = exists(attribution.launcher);
|
|
1027
|
+
// (a) MISSING WORKING DIRECTORY. On Linux spawnSync reports ENOENT with
|
|
1028
|
+
// `error.path` set to the COMMAND both when the command is absent AND when
|
|
1029
|
+
// chdir into `cwd` fails, so this cwd check MUST precede the command check
|
|
1030
|
+
// below — a path-based test alone would keep blaming the interpreter for a
|
|
1031
|
+
// directory that is simply gone (HQ-CLI-1A). The directory is the caller's
|
|
1032
|
+
// filesystem, so it is raised as its own carrier and the boundary skips
|
|
1033
|
+
// capture.
|
|
1034
|
+
if (errnoCode === 'ENOENT' && cwd !== null && cwdExists === false) {
|
|
1035
|
+
throw new QmdWorkdirMissingError(`qmd could not run: its working directory (${cwd}) does not exist`, cwd, attribution.command, errnoCode);
|
|
1036
|
+
}
|
|
1037
|
+
const diagnostics = {
|
|
1038
|
+
errnoCode,
|
|
1039
|
+
cwd,
|
|
1040
|
+
cwdExists,
|
|
1041
|
+
command: attribution.command,
|
|
1042
|
+
commandExists,
|
|
1043
|
+
launcher: attribution.launcher,
|
|
1044
|
+
launcherExists,
|
|
1045
|
+
};
|
|
1046
|
+
// (b) MISSING INTERPRETER. The bundled qmd is launched as `<node> <qmd
|
|
1047
|
+
// launcher>`; when the interpreter (`command`, distinct from the launcher)
|
|
1048
|
+
// is what could not be executed, name IT rather than the launcher the
|
|
1049
|
+
// reported message wrongly blamed. Still captured — an absent interpreter
|
|
1050
|
+
// is a genuine defect — but now correct and self-describing.
|
|
1051
|
+
if (errnoCode === 'ENOENT' &&
|
|
1052
|
+
attribution.command !== attribution.launcher &&
|
|
1053
|
+
commandExists === false) {
|
|
1054
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd: its Node interpreter (${attribution.command}) could not be executed: ${normalized.error.message}`, diagnostics);
|
|
1055
|
+
}
|
|
1056
|
+
// (c) RESIDUAL: an ENOENT the fix cannot attribute (a genuinely missing
|
|
1057
|
+
// qmd, or a race where the directory/interpreter reappeared) or any other
|
|
1058
|
+
// spawn errno (EACCES/EINVAL/…). Keep today's class and message byte-for-
|
|
1059
|
+
// byte, and attach the bounded diagnostics so the next occurrence carries
|
|
1060
|
+
// the per-component existence evidence.
|
|
1061
|
+
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`, diagnostics);
|
|
1062
|
+
}
|
|
931
1063
|
throw new QmdBinaryMissingError(`Unable to execute qmd at ${bin}: ${normalized.error.message}`);
|
|
932
1064
|
}
|
|
933
1065
|
if (normalized.status === 0)
|
|
@@ -1036,6 +1168,35 @@ function finishRunQmd(result, bin, args) {
|
|
|
1036
1168
|
const ensure = lastQmdStoreEnsure;
|
|
1037
1169
|
throw new QmdStoreUnopenableError(`qmd ${subcommand} could not run: its local search store could not be opened`, args, normalized.status, normalized.stdout, normalized.stderr, ensure?.dir, ensure && !ensure.writable ? ensure.reason : undefined);
|
|
1038
1170
|
}
|
|
1171
|
+
// qmd's `query` subcommand rejected the positional as a MALFORMED QUERY
|
|
1172
|
+
// DOCUMENT: qmd 2.5.3 parses a multi-line positional as a document and requires
|
|
1173
|
+
// every non-empty line to carry a lex:/vec:/hyde:/intent:/expand: prefix, so a
|
|
1174
|
+
// multi-line free-text query (a fleet agent forwarding a whole Slack message)
|
|
1175
|
+
// exits 1 with a syntax diagnostic. That is the caller's INPUT SHAPE, not an
|
|
1176
|
+
// hq-cli defect. Read qmd's OWN captured streams only, never the synthesized
|
|
1177
|
+
// message, so a user query that merely contains the wording can never trip it.
|
|
1178
|
+
// hq-cli side: buildSearchArgs now normalises free text to one line so this no
|
|
1179
|
+
// longer fires for the common case; this branch types a genuinely malformed
|
|
1180
|
+
// structured document so the boundary prints a query-free remedy and skips
|
|
1181
|
+
// capture. Placed AFTER the store checks and BEFORE the collection-missing
|
|
1182
|
+
// regex; the wordings are disjoint (that regex needs collection/qmd:// adjacent
|
|
1183
|
+
// to a not-found token, absent here), so no existing branch changes behaviour
|
|
1184
|
+
// (HQ-CLI-1B / Sentry 7705711476).
|
|
1185
|
+
if (isQmdQueryDocumentError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
1186
|
+
throw new QmdQueryDocumentError(`qmd ${subcommand} could not run: the query was read as a malformed query document`, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
1187
|
+
}
|
|
1188
|
+
// A qmd MODEL DOWNLOAD died with ENOENT renaming its own partial `.ipull` file
|
|
1189
|
+
// — the signature of another qmd process on the same box finishing or clearing
|
|
1190
|
+
// the same download first. That is a transient, retryable LOCAL-CACHE race, not
|
|
1191
|
+
// an hq-cli defect (hq cannot coordinate qmd children other users start on the
|
|
1192
|
+
// host). Require BOTH the ENOENT-rename signature AND the `.ipull` marker so a
|
|
1193
|
+
// durable failure (ENOSPC/EACCES/network) keeps its reportable path. Read qmd's
|
|
1194
|
+
// OWN captured streams only. Placed with the query-document check, before the
|
|
1195
|
+
// collection-missing regex; the signatures are disjoint, so no existing branch
|
|
1196
|
+
// changes behaviour (HQ-CLI-1B / Sentry 7705711476, the newest event).
|
|
1197
|
+
if (isQmdModelDownloadError({ stderr: normalized.stderr, stdout: normalized.stdout })) {
|
|
1198
|
+
throw new QmdModelDownloadError(`qmd ${subcommand} could not run: a model download did not finish (its partial file disappeared)`, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
1199
|
+
}
|
|
1039
1200
|
if (/(?:collection|qmd:\/\/).*(?:not found|does not exist|unknown)|(?:not found|does not exist).*collection/i.test(classifyText)) {
|
|
1040
1201
|
throw new QmdCollectionMissingError(message, args, normalized.status, normalized.stdout, normalized.stderr);
|
|
1041
1202
|
}
|
|
@@ -1081,13 +1242,24 @@ export function runQmd(args, options = {}) {
|
|
|
1081
1242
|
// idempotent; only the real spawn path needs it (the injected-runner path
|
|
1082
1243
|
// above never opens a store).
|
|
1083
1244
|
ensureQmdStoreDir(options.env ?? process.env, { cwd: options.cwd });
|
|
1245
|
+
const launcher = invocationBin(invocation);
|
|
1084
1246
|
const result = spawnQmd(invocation, args, {
|
|
1085
1247
|
cwd: options.cwd,
|
|
1086
1248
|
env: options.env ?? process.env,
|
|
1087
1249
|
platform: options.platform,
|
|
1088
1250
|
spawn: options.spawn,
|
|
1089
1251
|
});
|
|
1090
|
-
|
|
1252
|
+
// Thread attribution so finishRunQmd can attribute a spawn-level ENOENT to the
|
|
1253
|
+
// right component — the working directory, the Node interpreter, or a
|
|
1254
|
+
// genuinely missing qmd — instead of blaming the launcher for all three
|
|
1255
|
+
// (HQ-CLI-1A). `command` is the process actually spawned; `launcher` is qmd's
|
|
1256
|
+
// file identity.
|
|
1257
|
+
return finishRunQmd(result, launcher, args, {
|
|
1258
|
+
command: invocation.command,
|
|
1259
|
+
launcher,
|
|
1260
|
+
cwd: options.cwd,
|
|
1261
|
+
exists: options.exists,
|
|
1262
|
+
});
|
|
1091
1263
|
}
|
|
1092
1264
|
function containsIndexedMarkdown(directory) {
|
|
1093
1265
|
if (!fs.existsSync(directory))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic owner-only file writes with symlink and path guards for work-context.
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Resolve symlinks on an existing path; for a missing leaf, resolve the parent
|
|
6
|
+
* and re-append the basename so we never rename onto a symlink.
|
|
7
|
+
*/
|
|
8
|
+
export declare function resolveRealTarget(target: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Ensure parent dir exists as a real directory (not a symlink), mode 0700.
|
|
11
|
+
*/
|
|
12
|
+
export declare function ensureOwnerDir(dir: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Atomically replace target with data at mode 0600 (same-dir tmp + rename).
|
|
15
|
+
*/
|
|
16
|
+
export declare function atomicWriteFile(target: string, data: string | Buffer): void;
|
|
17
|
+
export declare function atomicWriteJson(target: string, value: unknown): void;
|
|
18
|
+
//# sourceMappingURL=atomic.d.ts.map
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic owner-only file writes with symlink and path guards for work-context.
|
|
3
|
+
*/
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { UnsafeConfigPathError } from "./errors.js";
|
|
7
|
+
function fsyncDir(dir) {
|
|
8
|
+
try {
|
|
9
|
+
const fd = fs.openSync(dir, "r");
|
|
10
|
+
try {
|
|
11
|
+
fs.fsyncSync(fd);
|
|
12
|
+
}
|
|
13
|
+
finally {
|
|
14
|
+
fs.closeSync(fd);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// Best-effort: some platforms reject directory fsync.
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve symlinks on an existing path; for a missing leaf, resolve the parent
|
|
23
|
+
* and re-append the basename so we never rename onto a symlink.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveRealTarget(target) {
|
|
26
|
+
try {
|
|
27
|
+
return fs.realpathSync(target);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Leaf may not exist yet.
|
|
31
|
+
}
|
|
32
|
+
let cur = path.dirname(target);
|
|
33
|
+
const base = path.basename(target);
|
|
34
|
+
for (let i = 0; i < 40; i += 1) {
|
|
35
|
+
try {
|
|
36
|
+
const lst = fs.lstatSync(cur);
|
|
37
|
+
if (lst.isSymbolicLink()) {
|
|
38
|
+
throw new UnsafeConfigPathError(`Refusing write through symlink: ${cur}`);
|
|
39
|
+
}
|
|
40
|
+
const real = fs.realpathSync(cur);
|
|
41
|
+
return path.join(real, base);
|
|
42
|
+
}
|
|
43
|
+
catch (err) {
|
|
44
|
+
if (err instanceof UnsafeConfigPathError)
|
|
45
|
+
throw err;
|
|
46
|
+
const parent = path.dirname(cur);
|
|
47
|
+
if (parent === cur)
|
|
48
|
+
break;
|
|
49
|
+
cur = parent;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return target;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Ensure parent dir exists as a real directory (not a symlink), mode 0700.
|
|
56
|
+
*/
|
|
57
|
+
export function ensureOwnerDir(dir) {
|
|
58
|
+
const resolved = (() => {
|
|
59
|
+
try {
|
|
60
|
+
if (fs.existsSync(dir)) {
|
|
61
|
+
const lst = fs.lstatSync(dir);
|
|
62
|
+
if (lst.isSymbolicLink()) {
|
|
63
|
+
throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${dir}`);
|
|
64
|
+
}
|
|
65
|
+
return fs.realpathSync(dir);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
if (err instanceof UnsafeConfigPathError)
|
|
70
|
+
throw err;
|
|
71
|
+
}
|
|
72
|
+
return dir;
|
|
73
|
+
})();
|
|
74
|
+
fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
|
|
75
|
+
try {
|
|
76
|
+
fs.chmodSync(resolved, 0o700);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// ignore chmod failures on exotic FS
|
|
80
|
+
}
|
|
81
|
+
const lst = fs.lstatSync(resolved);
|
|
82
|
+
if (lst.isSymbolicLink()) {
|
|
83
|
+
throw new UnsafeConfigPathError(`Refusing directory that is a symlink: ${resolved}`);
|
|
84
|
+
}
|
|
85
|
+
return fs.realpathSync(resolved);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Atomically replace target with data at mode 0600 (same-dir tmp + rename).
|
|
89
|
+
*/
|
|
90
|
+
export function atomicWriteFile(target, data) {
|
|
91
|
+
const realTarget = resolveRealTarget(target);
|
|
92
|
+
const dir = ensureOwnerDir(path.dirname(realTarget));
|
|
93
|
+
const finalPath = path.join(dir, path.basename(realTarget));
|
|
94
|
+
const tmp = path.join(dir, `.${path.basename(finalPath)}.tmp.${process.pid}.${Date.now()}`);
|
|
95
|
+
let fd;
|
|
96
|
+
try {
|
|
97
|
+
fd = fs.openSync(tmp, "wx", 0o600);
|
|
98
|
+
fs.writeSync(fd, typeof data === "string" ? Buffer.from(data, "utf8") : data);
|
|
99
|
+
fs.fsyncSync(fd);
|
|
100
|
+
fs.closeSync(fd);
|
|
101
|
+
fd = undefined;
|
|
102
|
+
fs.renameSync(tmp, finalPath);
|
|
103
|
+
fsyncDir(dir);
|
|
104
|
+
try {
|
|
105
|
+
fs.chmodSync(finalPath, 0o600);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// ignore
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
if (fd !== undefined) {
|
|
113
|
+
try {
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
/* already closed */
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
fs.unlinkSync(tmp);
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
/* temp may not exist */
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
export function atomicWriteJson(target, value) {
|
|
130
|
+
atomicWriteFile(target, `${JSON.stringify(value, null, 2)}\n`);
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=atomic.js.map
|