@indigoai-us/hq-cli 5.291.0 → 5.292.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 +27 -8
- package/assets/scaffold/core/scripts/handoff-finalize.sh +18 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +71 -6
- package/assets/scaffold/core/scripts/hq-delegate-bundle.sh +307 -0
- package/assets/scaffold/core/scripts/hq-delegate-grant.sh +226 -0
- package/assets/scaffold/core/scripts/hq-delegate-publish.sh +91 -0
- package/assets/scaffold/core/scripts/hq-delegate-send.sh +259 -0
- package/assets/scaffold/core/scripts/hq-delegate-verify.sh +213 -0
- package/assets/scaffold/core/scripts/hq-job-notify.sh +389 -0
- package/assets/scaffold/core/scripts/hq-job-probe.sh +835 -0
- package/assets/scaffold/core/scripts/hq-job-remediate.sh +359 -0
- package/assets/scaffold/core/scripts/hq-job-run.sh +1025 -0
- package/assets/scaffold/core/scripts/outpost-jobs-reconcile.sh +830 -0
- package/dist/command-catalog.generated.d.ts +106 -1
- package/dist/command-catalog.generated.js +132 -1
- package/dist/commands/core-delegate-w1b.d.ts +8 -0
- package/dist/commands/core-delegate-w1b.js +1049 -0
- package/dist/commands/core-delegate.d.ts +28 -0
- package/dist/commands/core-delegate.js +15 -13
- package/dist/commands/core-handoff.js +152 -23
- package/dist/commands/core-jobs-native.d.ts +15 -0
- package/dist/commands/core-jobs-native.js +709 -2
- package/dist/commands/core-ontology.d.ts +22 -4
- package/dist/commands/core-ontology.js +90 -8
- package/dist/commands/core.js +10 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.d.ts +41 -0
- package/dist/lib/checkpoint-ontology/drop-aliases.js +76 -0
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +6 -3
- package/dist/lib/checkpoint-ontology/entity-file.js +12 -6
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +27 -8
- package/dist/lib/checkpoint-ontology/merge-copies.js +64 -97
- package/dist/lib/daemon/flag.d.ts +2 -0
- package/dist/lib/daemon/flag.js +2 -0
- package/dist/utils/scaffold-sibling-references.js +4 -4
- package/dist/utils/self-update.d.ts +6 -0
- package/dist/utils/self-update.js +28 -6
- package/dist/utils/staged-install.d.ts +34 -0
- package/dist/utils/staged-install.js +296 -0
- package/package.json +1 -1
- package/scripts/mesh-daemon-if-stale.mjs +4 -0
|
@@ -27,16 +27,25 @@
|
|
|
27
27
|
*
|
|
28
28
|
* A signal whose `entity_refs` names a copy holds that copy: signals are
|
|
29
29
|
* server-owned, so the ref cannot be re-pointed from here. With `rewriteRefs`
|
|
30
|
-
* (flag `checkpoint.ontology.merge-rewrite-refs`)
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
30
|
+
* (flag `checkpoint.ontology.merge-rewrite-refs`) invoice and bill links and
|
|
31
|
+
* knowledge links are re-pointed at the survivor and the survivor receives
|
|
32
|
+
* the copy's `external_ids` and `web_domains`. A signal ref or a link from a
|
|
33
|
+
* source still holds the pair (`server_owned_refs`): the sync client cannot
|
|
34
|
+
* push those files. A link from another copy or an external id conflict also
|
|
35
|
+
* holds a pair.
|
|
34
36
|
*
|
|
35
37
|
* With `pairKind: "variants"` (flag `checkpoint.ontology.merge-variants`) the
|
|
36
38
|
* pairs are two-file duplicate groups whose canonical names differ only in
|
|
37
39
|
* case, spacing or punctuation; the survivor is the spaced name, then the
|
|
38
40
|
* higher `signal_count`, then path order. The copy's canonical name becomes
|
|
39
41
|
* an alias of the survivor. Merge and delete work as for numbered copies.
|
|
42
|
+
*
|
|
43
|
+
* With `pairKind: "listed"` (flag `checkpoint.ontology.merge-listed-pairs`)
|
|
44
|
+
* the pairs are exactly the `only` entries, reviewed by a person: no finder
|
|
45
|
+
* runs, so the names may differ. Each entry must name two entities of one
|
|
46
|
+
* type; a copy listed twice, or listed both as a copy and as an original, is
|
|
47
|
+
* skipped as `listed_conflict`. Holds, merge and delete work as for numbered
|
|
48
|
+
* copies.
|
|
40
49
|
*/
|
|
41
50
|
import * as fs from "node:fs";
|
|
42
51
|
import * as path from "node:path";
|
|
@@ -53,6 +62,8 @@ export const MERGE_COPIES_FLAG = "checkpoint.ontology.merge-copies";
|
|
|
53
62
|
* names differ only in spacing, case or punctuation (CO-65).
|
|
54
63
|
*/
|
|
55
64
|
export const MERGE_VARIANTS_FLAG = "checkpoint.ontology.merge-variants";
|
|
65
|
+
/** `merge-copies --listed`: merge exactly the reviewed `--only` pairs (CO-74). */
|
|
66
|
+
export const MERGE_LISTED_PAIRS_FLAG = "checkpoint.ontology.merge-listed-pairs";
|
|
56
67
|
/**
|
|
57
68
|
* `merge-copies --rewrite-refs`: re-point signal `entity_refs`, invoice and
|
|
58
69
|
* bill links, source and knowledge links from a copy to its survivor, and
|
|
@@ -219,6 +230,36 @@ export function findVariantPairs(index) {
|
|
|
219
230
|
pairs.sort((a, b) => a.copy.localeCompare(b.copy));
|
|
220
231
|
return { pairs, skipped };
|
|
221
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* The reviewed pairs themselves, for `pairKind: "listed"`. An entry is kept
|
|
235
|
+
* when both files are indexed entities of one writable type. Read-only.
|
|
236
|
+
*/
|
|
237
|
+
export function findListedPairs(index, only) {
|
|
238
|
+
const pairs = [];
|
|
239
|
+
const skipped = [];
|
|
240
|
+
const copyCount = new Map();
|
|
241
|
+
for (const entry of only)
|
|
242
|
+
copyCount.set(entry.copy, (copyCount.get(entry.copy) ?? 0) + 1);
|
|
243
|
+
const originals = new Set(only.map((entry) => entry.original));
|
|
244
|
+
for (const entry of only) {
|
|
245
|
+
const copy = index.byRel.get(entry.copy);
|
|
246
|
+
const original = index.byRel.get(entry.original);
|
|
247
|
+
if (copyCount.get(entry.copy) > 1 || originals.has(entry.copy) || entry.copy === entry.original) {
|
|
248
|
+
skipped.push({ copy: entry.copy, reason: "listed_conflict" });
|
|
249
|
+
}
|
|
250
|
+
else if (!copy || !original) {
|
|
251
|
+
skipped.push({ copy: entry.copy, reason: index.invalid.has(entry.original) ? "original_invalid" : "no_original" });
|
|
252
|
+
}
|
|
253
|
+
else if (copy.type !== original.type || !isWritableEntityType(copy.type)) {
|
|
254
|
+
skipped.push({ copy: entry.copy, reason: "type_differs" });
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
pairs.push({ type: copy.type, copy: entry.copy, original: entry.original });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
pairs.sort((a, b) => a.copy.localeCompare(b.copy));
|
|
261
|
+
return { pairs, skipped };
|
|
262
|
+
}
|
|
222
263
|
/** Resolve a relative link in `fromRel` to a company-relative path, or null. */
|
|
223
264
|
function resolveLink(fromRel, href) {
|
|
224
265
|
if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("#") || href.startsWith("/"))
|
|
@@ -525,76 +566,13 @@ function referencesCopy(fromRel, content, copy) {
|
|
|
525
566
|
}
|
|
526
567
|
return false;
|
|
527
568
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
function
|
|
534
|
-
|
|
535
|
-
if (!front)
|
|
536
|
-
return content;
|
|
537
|
-
const index = front.lines.findIndex((line) => /^entity_refs:\s*$/.test(line));
|
|
538
|
-
if (index === -1) {
|
|
539
|
-
const inline = front.lines.some((line) => /^entity_refs:\s*\S/.test(line));
|
|
540
|
-
if (inline && content.includes(copyRef))
|
|
541
|
-
return "signal_ref_format";
|
|
542
|
-
return content;
|
|
543
|
-
}
|
|
544
|
-
let end = index + 1;
|
|
545
|
-
// A block sequence may sit at the key's own indent: `entity_refs:\n- company/x`.
|
|
546
|
-
while (end < front.lines.length && (/^\s/.test(front.lines[end]) || /^- /.test(front.lines[end])))
|
|
547
|
-
end += 1;
|
|
548
|
-
const itemRe = /^(\s*)- (["']?)([^"'\n]+?)\2\s*$/;
|
|
549
|
-
const refs = [];
|
|
550
|
-
for (let i = index + 1; i < end; i += 1) {
|
|
551
|
-
const match = itemRe.exec(front.lines[i]);
|
|
552
|
-
if (!match)
|
|
553
|
-
continue;
|
|
554
|
-
const hadMd = match[3].endsWith(".md");
|
|
555
|
-
refs.push({
|
|
556
|
-
index: i,
|
|
557
|
-
value: hadMd ? match[3].slice(0, -3) : match[3],
|
|
558
|
-
hadMd,
|
|
559
|
-
indent: match[1],
|
|
560
|
-
quote: match[2],
|
|
561
|
-
});
|
|
562
|
-
}
|
|
563
|
-
const copyItems = refs.filter((item) => item.value === copyRef);
|
|
564
|
-
if (copyItems.length === 0)
|
|
565
|
-
return content;
|
|
566
|
-
const lines = [...front.lines];
|
|
567
|
-
const remove = new Set();
|
|
568
|
-
let seenOriginal = refs.some((item) => item.value === originalRef);
|
|
569
|
-
for (const item of copyItems) {
|
|
570
|
-
if (seenOriginal) {
|
|
571
|
-
remove.add(item.index);
|
|
572
|
-
}
|
|
573
|
-
else {
|
|
574
|
-
const value = item.hadMd ? `${originalRef}.md` : originalRef;
|
|
575
|
-
lines[item.index] = `${item.indent}- ${item.quote}${value}${item.quote}`;
|
|
576
|
-
seenOriginal = true;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
const next = withFrontLines(content, front, lines.filter((_, i) => !remove.has(i)), fileNewline(content));
|
|
580
|
-
const oldParsed = parseEntityFile(content);
|
|
581
|
-
const newParsed = parseEntityFile(next);
|
|
582
|
-
if (!oldParsed || !newParsed || oldParsed.body !== newParsed.body)
|
|
583
|
-
return "signal_ref_format";
|
|
584
|
-
const keys = new Set([...Object.keys(oldParsed.frontmatter), ...Object.keys(newParsed.frontmatter)]);
|
|
585
|
-
for (const key of keys) {
|
|
586
|
-
if (key === "entity_refs")
|
|
587
|
-
continue;
|
|
588
|
-
if (!isDeepStrictEqual(oldParsed.frontmatter[key], newParsed.frontmatter[key]))
|
|
589
|
-
return "signal_ref_format";
|
|
590
|
-
}
|
|
591
|
-
const newRefs = newParsed.frontmatter.entity_refs;
|
|
592
|
-
if (!Array.isArray(newRefs) || !newRefs.every((ref) => typeof ref === "string"))
|
|
593
|
-
return "signal_ref_format";
|
|
594
|
-
const normalized = newRefs.map((ref) => (ref.endsWith(".md") ? ref.slice(0, -3) : ref));
|
|
595
|
-
if (!normalized.includes(originalRef) || normalized.includes(copyRef))
|
|
596
|
-
return "signal_ref_format";
|
|
597
|
-
return next;
|
|
569
|
+
/**
|
|
570
|
+
* Signals and sources are written by the server pipeline and the sync
|
|
571
|
+
* client never pushes them, so a rewrite made here would not reach the
|
|
572
|
+
* vault (hq-cloud cloud-authoritative.ts).
|
|
573
|
+
*/
|
|
574
|
+
function isServerOwnedRel(rel) {
|
|
575
|
+
return rel.startsWith("signals/") || rel.startsWith("sources/");
|
|
598
576
|
}
|
|
599
577
|
const REF_KEYS = new Set(["system", "namespace", "objectType", "recordId"]);
|
|
600
578
|
function refKey(ref) {
|
|
@@ -871,6 +849,7 @@ function plan(company, index, pairs, allPairs, now, rewriteRefs, resolutions = n
|
|
|
871
849
|
const unwritable = rewriteRefs
|
|
872
850
|
? inbound.filter((rel) => copies.has(rel))
|
|
873
851
|
: inbound.filter((rel) => !rel.startsWith("ontology/entities/") || !isWritableEntityType(rel.split("/")[2]) || copies.has(rel));
|
|
852
|
+
const serverOwned = rewriteRefs ? [...refs, ...inbound.filter(isServerOwnedRel)] : [];
|
|
874
853
|
if (typeof merged === "string") {
|
|
875
854
|
entry.held = "merge_invalid";
|
|
876
855
|
entry.detail = merged;
|
|
@@ -879,6 +858,10 @@ function plan(company, index, pairs, allPairs, now, rewriteRefs, resolutions = n
|
|
|
879
858
|
entry.held = "external_id_conflict";
|
|
880
859
|
entry.detail = identity.conflicts.join(", ");
|
|
881
860
|
}
|
|
861
|
+
else if (serverOwned.length > 0) {
|
|
862
|
+
entry.held = "server_owned_refs";
|
|
863
|
+
entry.detail = [...new Set(serverOwned)].sort().join(", ");
|
|
864
|
+
}
|
|
882
865
|
else if (!rewriteRefs && refs.length > 0) {
|
|
883
866
|
entry.held = "signal_refs";
|
|
884
867
|
}
|
|
@@ -960,7 +943,13 @@ export async function mergeCopies(options) {
|
|
|
960
943
|
const { hqRoot, company, mode, now } = options;
|
|
961
944
|
const index = buildEntityIndex(company.dir);
|
|
962
945
|
const pairKind = options.pairKind ?? "numbered";
|
|
963
|
-
|
|
946
|
+
if (pairKind === "listed" && !options.only)
|
|
947
|
+
throw new Error("merge-copies: listed pairs need an only list");
|
|
948
|
+
const found = pairKind === "listed"
|
|
949
|
+
? findListedPairs(index, options.only)
|
|
950
|
+
: pairKind === "variants"
|
|
951
|
+
? findVariantPairs(index)
|
|
952
|
+
: findCopyPairs(index);
|
|
964
953
|
const listed = options.only
|
|
965
954
|
? found.pairs.filter((pair) => options.only.some((entry) => entry.copy === pair.copy && entry.original === pair.original))
|
|
966
955
|
: found.pairs;
|
|
@@ -1060,28 +1049,6 @@ export async function mergeCopies(options) {
|
|
|
1060
1049
|
report.errors.push({ path: rel, error: error.name });
|
|
1061
1050
|
}
|
|
1062
1051
|
}
|
|
1063
|
-
if (!rewriteRefs)
|
|
1064
|
-
continue;
|
|
1065
|
-
const copyRef = entityRefOf(entry.copy);
|
|
1066
|
-
const originalRef = entityRefOf(entry.original);
|
|
1067
|
-
for (const rel of entry.signal_refs) {
|
|
1068
|
-
try {
|
|
1069
|
-
const content = read(company, rel);
|
|
1070
|
-
const next = rewriteSignalRefs(content, copyRef, originalRef);
|
|
1071
|
-
if (next === "signal_ref_format") {
|
|
1072
|
-
report.errors.push({ path: rel, error: "signal_ref_format" });
|
|
1073
|
-
continue;
|
|
1074
|
-
}
|
|
1075
|
-
if (next === content)
|
|
1076
|
-
continue;
|
|
1077
|
-
safeWriteFile(hqRoot, [company.dir], path.join(company.dir, rel), next);
|
|
1078
|
-
if (!report.rewritten.includes(rel))
|
|
1079
|
-
report.rewritten.push(rel);
|
|
1080
|
-
}
|
|
1081
|
-
catch (error) {
|
|
1082
|
-
report.errors.push({ path: rel, error: error.name });
|
|
1083
|
-
}
|
|
1084
|
-
}
|
|
1085
1052
|
}
|
|
1086
1053
|
}
|
|
1087
1054
|
if (mode === "delete") {
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { type FlagClientOptions, type FlagResolution } from "@indigoai-us/hq-flags-client";
|
|
7
7
|
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
8
8
|
export declare const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
9
|
+
/** Self-updates build the new CLI beside the running one and swap it in, so `hq` never disappears mid-update. */
|
|
10
|
+
export declare const STAGED_SELF_UPDATE_FLAG = "cli.staged-self-update";
|
|
9
11
|
export interface DaemonFlagClient {
|
|
10
12
|
ready(): Promise<void>;
|
|
11
13
|
explain(flagKey: string, options?: {
|
package/dist/lib/daemon/flag.js
CHANGED
|
@@ -9,6 +9,8 @@ import { resolveFlagRegistryEndpoint } from "../flag-registry-endpoint.js";
|
|
|
9
9
|
import { resolveProcessFlagCompanyContext } from "../flag-registry.js";
|
|
10
10
|
/** Outposts hand running services to the next daemon on a CLI update instead of restarting them all. */
|
|
11
11
|
export const SUPERVISOR_HANDOFF_FLAG = "daemon.supervisor-handoff";
|
|
12
|
+
/** Self-updates build the new CLI beside the running one and swap it in, so `hq` never disappears mid-update. */
|
|
13
|
+
export const STAGED_SELF_UPDATE_FLAG = "cli.staged-self-update";
|
|
12
14
|
const READ_TIMEOUT_MS = 5_000;
|
|
13
15
|
export async function readDaemonFlag(flagKey, options = {}) {
|
|
14
16
|
const env = options.env ?? process.env;
|
|
@@ -42,7 +42,7 @@ function shellLocationAliases(contents) {
|
|
|
42
42
|
const scriptRoot = SCRIPT_ROOT_TOKENS.exec(value);
|
|
43
43
|
if (scriptRoot) {
|
|
44
44
|
const suffix = value.slice(scriptRoot.index + scriptRoot[0].length)
|
|
45
|
-
.match(/^\/([^"'`\s;|&)]+)/)?.[1] ?? ".";
|
|
45
|
+
.match(/^\/([^"'`\s;|&)}]+)/)?.[1] ?? ".";
|
|
46
46
|
aliases.set(name, { kind: "asset", baseDirectory: path.posix.normalize(suffix) });
|
|
47
47
|
continue;
|
|
48
48
|
}
|
|
@@ -116,15 +116,15 @@ export function scanScaffoldSiblingReferences(scaffoldRoot) {
|
|
|
116
116
|
(/\b(?:source|exec|bash|sh|node)\b/.test(line) || /^\s*\.\s+/.test(line)));
|
|
117
117
|
for (const line of commandLines) {
|
|
118
118
|
SCRIPT_ROOT_TOKENS.lastIndex = 0;
|
|
119
|
-
for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)]+)/g)) {
|
|
119
|
+
for (const match of line.matchAll(/(?:\$\{SCRIPT_DIR\}|\$SCRIPT_DIR)\/([^"'`\s;|&)}]+)/g)) {
|
|
120
120
|
recordRelative(sourceFile, match[1]);
|
|
121
121
|
}
|
|
122
|
-
const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)]+)`, "g");
|
|
122
|
+
const dirnamePath = new RegExp(String.raw `\$\(\s*dirname\s+["']?${BASH_SOURCE_TOKEN}["']?\s*\)(\/[^"'\x60\s;|&)}]+)`, "g");
|
|
123
123
|
for (const match of line.matchAll(dirnamePath))
|
|
124
124
|
recordRelative(sourceFile, match[1]);
|
|
125
125
|
for (const [alias, location] of aliases) {
|
|
126
126
|
const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
127
|
-
const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)]+)`, "g");
|
|
127
|
+
const aliasPath = new RegExp(String.raw `(?:\$\{${escapedAlias}\}|\$${escapedAlias})\/([^"'\x60\s;|&)}]+)`, "g");
|
|
128
128
|
for (const match of line.matchAll(aliasPath)) {
|
|
129
129
|
if (location.kind === "hq-root") {
|
|
130
130
|
const reference = match[0];
|
|
@@ -161,6 +161,12 @@ export interface SelfUpdateDeps {
|
|
|
161
161
|
interactive?: boolean;
|
|
162
162
|
/** Best-effort daemon kick after the CLI install converges (tests). */
|
|
163
163
|
restartDaemon?: () => RestartDaemonResult | void;
|
|
164
|
+
/**
|
|
165
|
+
* Whether `cli.staged-self-update` is on: build the new version beside the
|
|
166
|
+
* running package and swap it in, instead of moving the running package
|
|
167
|
+
* aside for the whole install. Read only when an npm global install is due.
|
|
168
|
+
*/
|
|
169
|
+
stagedSelfUpdate?: () => Promise<boolean>;
|
|
164
170
|
}
|
|
165
171
|
/**
|
|
166
172
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
@@ -64,6 +64,7 @@ import semver from "semver";
|
|
|
64
64
|
import chalk from "chalk";
|
|
65
65
|
import { CLI_NAME, CLI_VERSION } from "../cli-version.js";
|
|
66
66
|
import { buildBunInstallArgv, buildPnpmInstallArgv, buildPrefixedInstallArgv, buildSpawnPlan, checkUpdateConvergence, derivePnpmHome, isBunManagedPackageDir, isLocalDependencyInstall, isPnpmManagedPackageDir, isPrefixWritable, nonWritablePrefixNote, openInstallOutput, pnpmUpdateEnv, resolveRunningInstall, runUpdateCommand, runSupervisedUpdateCommand, } from "./version-gate.js";
|
|
67
|
+
import { canStageInstall, stagedNpmInstall } from "./staged-install.js";
|
|
67
68
|
import { acquireUpdateLock as acquireSharedUpdateLock } from "./update-lock.js";
|
|
68
69
|
import { markLatestIneffective } from "./version-check.js";
|
|
69
70
|
import { restartDaemonServiceIfInstalled } from "../lib/mesh/live/daemon/install.js";
|
|
@@ -414,12 +415,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
414
415
|
console.error(chalk.dim(`hq-cli ${latest} is available, but this copy is a local dependency (${install.packageRoot}) — update the project that owns it.`));
|
|
415
416
|
return { action: "skipped", latest };
|
|
416
417
|
}
|
|
418
|
+
const staged = canStageInstall(install) && (await readStagedSelfUpdateFlag(deps, env));
|
|
417
419
|
const releaseLock = flavor.lock ? (deps.acquireLock ?? acquireUpdateLock)() : () => { };
|
|
418
420
|
if (!releaseLock)
|
|
419
421
|
return { action: "skipped", latest };
|
|
420
422
|
const renamePath = deps.renamePath ?? fs.renameSync;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
+
// The staged install keeps the running package in place until the swap and
|
|
424
|
+
// restores it itself, so it takes no snapshot.
|
|
425
|
+
const installSnapshot = staged ? null : snapshotInstall(install, renamePath);
|
|
426
|
+
if (!staged && !installSnapshot) {
|
|
423
427
|
console.error(chalk.dim(" No backup could be made for this hq-cli install; proceeding without rollback protection."));
|
|
424
428
|
}
|
|
425
429
|
let result;
|
|
@@ -440,10 +444,15 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
440
444
|
// Forward env only when PNPM_HOME had to be injected, so the common path
|
|
441
445
|
// spawns with the inherited environment and keeps the two-argument runner
|
|
442
446
|
// call it has always made.
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
+
if (staged) {
|
|
448
|
+
result = stagedNpmInstall(install, latestValid, { run: runner, env, rename: renamePath });
|
|
449
|
+
}
|
|
450
|
+
else if (updateEnv === undefined) {
|
|
451
|
+
result = runner(plan.cmd, plan.args);
|
|
452
|
+
}
|
|
453
|
+
else {
|
|
454
|
+
result = runner(plan.cmd, plan.args, updateEnv);
|
|
455
|
+
}
|
|
447
456
|
if (!result.ok && installSnapshot) {
|
|
448
457
|
restoredAfterFailure = restoreInstall(installSnapshot, renamePath);
|
|
449
458
|
if (restoredAfterFailure)
|
|
@@ -537,6 +546,19 @@ async function updateAndReexec(argv, flavor, known, deps) {
|
|
|
537
546
|
console.error(chalk.dim(`✓ hq-cli updated to ${latest}.`));
|
|
538
547
|
return { action: "reexec", reexecStatus: status, latest };
|
|
539
548
|
}
|
|
549
|
+
async function readStagedSelfUpdateFlag(deps, env) {
|
|
550
|
+
try {
|
|
551
|
+
if (deps.stagedSelfUpdate)
|
|
552
|
+
return await deps.stagedSelfUpdate();
|
|
553
|
+
// Loaded here so the startup path pays for the flag client only when an
|
|
554
|
+
// update is actually due.
|
|
555
|
+
const { readDaemonFlag, STAGED_SELF_UPDATE_FLAG } = await import("../lib/daemon/flag.js");
|
|
556
|
+
return await readDaemonFlag(STAGED_SELF_UPDATE_FLAG, { env });
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return false;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
540
562
|
/**
|
|
541
563
|
* Startup path: the running CLI is behind npm `latest`, so update in place and
|
|
542
564
|
* re-run the user's command on the new version. `latest` is normally the cached
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Staged npm self-update: install the new hq-cli beside the running package,
|
|
3
|
+
* check that it starts, then swap it in with two renames.
|
|
4
|
+
*
|
|
5
|
+
* The in-place update moves the live package aside for the whole
|
|
6
|
+
* `npm install -g`, so anything that starts `hq` during the install finds no
|
|
7
|
+
* CLI on disk ("hq: command not found", MODULE_NOT_FOUND; outposts primary and
|
|
8
|
+
* 2, 5.290.0 -> 5.291.0). Here the live package stays in place until the new
|
|
9
|
+
* one is complete and has answered `--version`.
|
|
10
|
+
*
|
|
11
|
+
* Only a plain npm global layout is staged: `<prefix>/lib/node_modules/<pkg>`
|
|
12
|
+
* as a real directory, with bin links in `<prefix>/bin`. pnpm, Bun, Windows
|
|
13
|
+
* and linked checkouts keep the in-place update.
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import { type RunningInstall, type UpdateResult } from "./version-gate.js";
|
|
17
|
+
/** Set for the staging install; hq-cli's own postinstall skips itself on it. */
|
|
18
|
+
export declare const STAGED_INSTALL_ENV = "HQ_STAGED_INSTALL";
|
|
19
|
+
export interface StagedInstallDeps {
|
|
20
|
+
/** Runs the package manager. Defaults to {@link runUpdateCommand}. */
|
|
21
|
+
run?: (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => UpdateResult;
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
/** The version the staged package's `hq --version` reports, or null. */
|
|
24
|
+
probeVersion?: (packageRoot: string) => string | null;
|
|
25
|
+
/** The package's postinstall, run from the live path after the swap. */
|
|
26
|
+
runPostinstall?: (packageRoot: string) => void;
|
|
27
|
+
rename?: typeof fs.renameSync;
|
|
28
|
+
/** Removes the staging and backup directories. */
|
|
29
|
+
remove?: (target: string) => void;
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
}
|
|
32
|
+
export declare function canStageInstall(install: RunningInstall, platform?: NodeJS.Platform): boolean;
|
|
33
|
+
export declare function stagedNpmInstall(install: RunningInstall, version: string, deps?: StagedInstallDeps): UpdateResult;
|
|
34
|
+
//# sourceMappingURL=staged-install.d.ts.map
|