@indigoai-us/hq-cli 5.19.0 → 5.21.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 +56 -0
- package/dist/commands/cloud.d.ts +18 -0
- package/dist/commands/cloud.js +56 -15
- package/dist/commands/pack-install.d.ts +24 -0
- package/dist/commands/pack-install.js +66 -8
- package/package.json +2 -2
- package/src/commands/cloud.pull-all.test.ts +98 -0
- package/src/commands/cloud.push-all.test.ts +75 -0
- package/src/commands/cloud.ts +81 -7
- package/src/commands/pack-install.test.ts +115 -1
- package/src/commands/pack-install.ts +65 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.21.0] — 2026-05-24
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Bumps `@indigoai-us/hq-cloud` to `~5.34.0` (was `~5.25.0`). 5.34.0
|
|
10
|
+
closes the 10-bug cross-machine sync cleanup from
|
|
11
|
+
[hq-cloud#24](https://github.com/indigoai-us/hq-cloud/pull/24) plus
|
|
12
|
+
the Codex P1/P2 follow-up safeties.
|
|
13
|
+
|
|
14
|
+
Highest-impact fixes that ride this bump:
|
|
15
|
+
|
|
16
|
+
- **Bug #9** (drifted files never converge): the pull walker on
|
|
17
|
+
`~5.25.0` had no tombstone-consumption mechanism, so cross-machine
|
|
18
|
+
deletes never propagated — every host kept the union of "every
|
|
19
|
+
file ever created anywhere." 5.34.0 lands journal-vs-LIST diff +
|
|
20
|
+
HEAD-verify STS-scope guard + local-edit divergence detector for
|
|
21
|
+
both files and symlinks. The Drift counter can finally reach zero.
|
|
22
|
+
- **Bug #7** (concurrent edit data loss): two open laptops editing
|
|
23
|
+
the same file before either had synced silently overwrote the
|
|
24
|
+
slower side on `~5.25.0` — no conflict event, no tray badge,
|
|
25
|
+
invisible data loss. 5.34.0 writes a mirror to
|
|
26
|
+
`<orig>.conflict-<ts>-<short>` instead.
|
|
27
|
+
- **Bug #10** (`ENOTDIR` wedges the whole company sync): one
|
|
28
|
+
dir-vs-file collision on a stale path on `~5.25.0` threw
|
|
29
|
+
`ENOTDIR` and aborted the whole company — `partial: true`,
|
|
30
|
+
`filesSkipped: 0`. 5.34.0 handles both `(local-file, cloud-dir)`
|
|
31
|
+
and `(local-dir, cloud-file)` topologies symmetrically.
|
|
32
|
+
|
|
33
|
+
Plus #1/#6/#8 (`.hq/` directory leak channel — including the
|
|
34
|
+
alternate `.hq/machine.json` and `.hq/install-manifest.json` paths
|
|
35
|
+
that were cross-polluting per-host identity), #2 (PULL walker now
|
|
36
|
+
applies `EPHEMERAL_PATH_PATTERN` — legacy `.conflict-*` litter no
|
|
37
|
+
longer ratchets), #3 (`conflictPaths` deduped within a company), #4
|
|
38
|
+
(dir-vs-file warning + structured event), and #5 (file mode
|
|
39
|
+
preserved across upload/download — was previously collapsing every
|
|
40
|
+
mode to receiver umask default).
|
|
41
|
+
|
|
42
|
+
New `SyncResult` fields surface via the runner's `complete` event:
|
|
43
|
+
`filesTombstoned`, `filesExcludedByPolicy`,
|
|
44
|
+
`filesRefusedStalePaths` (cap 50). hq-cli forwards them through
|
|
45
|
+
unchanged; the menubar can opt in via UI work in a follow-up.
|
|
46
|
+
|
|
47
|
+
## [5.20.0] — 2026-05-21
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **`hq sync {push,pull,now} --all --no-personal`** — skip the
|
|
52
|
+
canonical-person leg of the `--all` fanout (companies-only sync).
|
|
53
|
+
Mirrors the upstream `hq-sync-runner --skip-personal` flag added in
|
|
54
|
+
`@indigoai-us/hq-cloud@5.25.0`. Wired so the AppBar HQ Sync menubar
|
|
55
|
+
can drop personal sync via a toggle, and so CLI users can opt out
|
|
56
|
+
one-off. Plumbed through `pullAll` / `pushAll` / `runNowAll` via a
|
|
57
|
+
new `skipPersonal` option; outside `--all` mode the flag is a no-op.
|
|
58
|
+
- Bumps `@indigoai-us/hq-cloud` to `~5.25.0` (currency-gated personal-
|
|
59
|
+
vault default exclusions + skip-personal CLI/env surface).
|
|
60
|
+
|
|
5
61
|
## [5.18.3] — 2026-05-21
|
|
6
62
|
|
|
7
63
|
### Fixed
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -77,6 +77,17 @@ export interface PullAllOptions {
|
|
|
77
77
|
* legacy behavior for this run via `--mode-all`.
|
|
78
78
|
*/
|
|
79
79
|
modeAllOverride?: boolean;
|
|
80
|
+
/**
|
|
81
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
82
|
+
* fanout only visits the caller's company memberships. Mirrors the
|
|
83
|
+
* `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
|
|
84
|
+
* `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
|
|
85
|
+
* mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
|
|
86
|
+
* CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
|
|
87
|
+
* menubar toggle (and CLI users opting out one-off) can drop the
|
|
88
|
+
* personal vault from the run without touching the rest of the plan.
|
|
89
|
+
*/
|
|
90
|
+
skipPersonal?: boolean;
|
|
80
91
|
}
|
|
81
92
|
export interface PullAllRow {
|
|
82
93
|
slug: string;
|
|
@@ -110,6 +121,13 @@ export interface PushAllOptions {
|
|
|
110
121
|
hqRoot: string;
|
|
111
122
|
onConflict?: ConflictStrategy;
|
|
112
123
|
message?: string;
|
|
124
|
+
/**
|
|
125
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
126
|
+
* fanout only visits the caller's company memberships. Symmetric with
|
|
127
|
+
* `PullAllOptions.skipPersonal`; surfaced on the CLI as
|
|
128
|
+
* `hq sync push --all --no-personal`.
|
|
129
|
+
*/
|
|
130
|
+
skipPersonal?: boolean;
|
|
113
131
|
}
|
|
114
132
|
export interface PushAllRow {
|
|
115
133
|
slug: string;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* hq sync status — show local journal summary
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
16
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="bdaf154f-b17d-5482-9e4d-feaebe606a1b")}catch(e){}}();
|
|
17
17
|
import chalk from "chalk";
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import * as path from "path";
|
|
@@ -60,7 +60,7 @@ export async function pullAll(options, deps) {
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
}
|
|
63
|
-
const personal = pickCanonicalPerson(persons);
|
|
63
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
64
64
|
if (personal) {
|
|
65
65
|
plan.push({
|
|
66
66
|
slug: "personal",
|
|
@@ -176,7 +176,7 @@ export async function pushAll(options, deps) {
|
|
|
176
176
|
},
|
|
177
177
|
});
|
|
178
178
|
}
|
|
179
|
-
const personal = pickCanonicalPerson(persons);
|
|
179
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
180
180
|
if (personal) {
|
|
181
181
|
plan.push({
|
|
182
182
|
slug: "personal",
|
|
@@ -354,6 +354,12 @@ export function registerCloudCommands(program) {
|
|
|
354
354
|
"every top-level entry under <hq-root> minus the excluded set " +
|
|
355
355
|
"(.git, companies, repos, workspace) — same scope as `--all`'s " +
|
|
356
356
|
"personal slot. Mutually exclusive with --company and --all.")
|
|
357
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
358
|
+
"only push to the caller's company memberships. Mirrors the " +
|
|
359
|
+
"upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
|
|
360
|
+
"wired so the AppBar HQ Sync menubar can drop personal sync via " +
|
|
361
|
+
"a toggle, and so CLI users can opt out one-off. Ignored outside " +
|
|
362
|
+
"`--all`.")
|
|
357
363
|
.action(async (paths, options) => {
|
|
358
364
|
try {
|
|
359
365
|
assertSingleSelector(options, "push");
|
|
@@ -376,7 +382,13 @@ export function registerCloudCommands(program) {
|
|
|
376
382
|
"target instead.");
|
|
377
383
|
process.exit(1);
|
|
378
384
|
}
|
|
379
|
-
|
|
385
|
+
// `options.personal === false` happens when the user passed
|
|
386
|
+
// `--no-personal` (Commander's auto-negation of the `--personal`
|
|
387
|
+
// selector). In `--all` mode that means "skip the personal leg of
|
|
388
|
+
// the fanout" — wired through to `pushAll.skipPersonal`. Outside
|
|
389
|
+
// `--all` the flag has no effect (logged above as part of the
|
|
390
|
+
// option's help text).
|
|
391
|
+
await runPushAll(options.hqRoot, options.message, options.onConflict, options.personal === false);
|
|
380
392
|
return;
|
|
381
393
|
}
|
|
382
394
|
const jsonMode = options.json === true;
|
|
@@ -531,6 +543,10 @@ export function registerCloudCommands(program) {
|
|
|
531
543
|
"(no companies/<slug>/ prefix). Resolves the person UID automatically " +
|
|
532
544
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
533
545
|
"and --all.")
|
|
546
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
547
|
+
"only pull the caller's company memberships. Mirrors the upstream " +
|
|
548
|
+
"`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
|
|
549
|
+
"outside `--all`.")
|
|
534
550
|
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
535
551
|
"Has no effect today (default narrow-hint level is 'hint'); " +
|
|
536
552
|
"wired so future hq-core-staging releases can flip the default to " +
|
|
@@ -544,7 +560,10 @@ export function registerCloudCommands(program) {
|
|
|
544
560
|
process.exit(1);
|
|
545
561
|
}
|
|
546
562
|
if (options.all) {
|
|
547
|
-
|
|
563
|
+
// `options.personal === false` is Commander's auto-negation of
|
|
564
|
+
// `--personal`; in `--all` mode that means "drop the personal
|
|
565
|
+
// leg from the fanout" (see `--no-personal` option above).
|
|
566
|
+
await runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, options.personal === false);
|
|
548
567
|
return;
|
|
549
568
|
}
|
|
550
569
|
if (options.personal) {
|
|
@@ -667,6 +686,10 @@ export function registerCloudCommands(program) {
|
|
|
667
686
|
"--personal.")
|
|
668
687
|
.option("--personal", "Sync the caller's canonical personal vault bidirectionally. " +
|
|
669
688
|
"Mutually exclusive with --company and --all.")
|
|
689
|
+
.option("--no-personal", "In `--all` mode, skip the canonical-person leg of both the push " +
|
|
690
|
+
"and pull fanouts — only sync the caller's company memberships. " +
|
|
691
|
+
"Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
|
|
692
|
+
"(hq-cloud 5.25.0). Ignored outside `--all`.")
|
|
670
693
|
.option("--mode-all", "US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
671
694
|
"No-op today; wired so future hq-core-staging releases can flip " +
|
|
672
695
|
"the default narrow-hint level to 'strict'.")
|
|
@@ -674,7 +697,11 @@ export function registerCloudCommands(program) {
|
|
|
674
697
|
try {
|
|
675
698
|
assertSingleSelector(options, "now");
|
|
676
699
|
if (options.all) {
|
|
677
|
-
|
|
700
|
+
// `options.personal === false` is Commander's auto-negation
|
|
701
|
+
// of `--personal`; in `--all` mode that means "drop the
|
|
702
|
+
// personal leg from both legs of the bidirectional fanout"
|
|
703
|
+
// (see `--no-personal` option above).
|
|
704
|
+
await runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, options.personal === false);
|
|
678
705
|
return;
|
|
679
706
|
}
|
|
680
707
|
await runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true);
|
|
@@ -685,10 +712,14 @@ export function registerCloudCommands(program) {
|
|
|
685
712
|
}
|
|
686
713
|
});
|
|
687
714
|
}
|
|
688
|
-
async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
715
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal) {
|
|
689
716
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
690
717
|
console.log(` HQ root: ${hqRoot}`);
|
|
691
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
718
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
719
|
+
if (skipPersonal) {
|
|
720
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
721
|
+
}
|
|
722
|
+
console.log("");
|
|
692
723
|
let result;
|
|
693
724
|
try {
|
|
694
725
|
const accessToken = await ensureCognitoToken();
|
|
@@ -712,6 +743,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride) {
|
|
|
712
743
|
...(onConflict ? { onConflict } : {}),
|
|
713
744
|
narrowHintLevel: resolveBannerLevel(),
|
|
714
745
|
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
746
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
715
747
|
}, {
|
|
716
748
|
vaultClient: adapter,
|
|
717
749
|
sync: (opts) => sync({
|
|
@@ -785,10 +817,14 @@ async function runPullPersonal(hqRoot, onConflict) {
|
|
|
785
817
|
process.exit(1);
|
|
786
818
|
}
|
|
787
819
|
}
|
|
788
|
-
async function runPushAll(hqRoot, message, onConflict) {
|
|
820
|
+
async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
789
821
|
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
790
822
|
console.log(` HQ root: ${hqRoot}`);
|
|
791
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
823
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
824
|
+
if (skipPersonal) {
|
|
825
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
826
|
+
}
|
|
827
|
+
console.log("");
|
|
792
828
|
let result;
|
|
793
829
|
try {
|
|
794
830
|
const accessToken = await ensureCognitoToken();
|
|
@@ -811,6 +847,7 @@ async function runPushAll(hqRoot, message, onConflict) {
|
|
|
811
847
|
hqRoot,
|
|
812
848
|
...(onConflict ? { onConflict } : {}),
|
|
813
849
|
...(message ? { message } : {}),
|
|
850
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
814
851
|
}, {
|
|
815
852
|
vaultClient: adapter,
|
|
816
853
|
share: (opts) => share({
|
|
@@ -1002,20 +1039,24 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1002
1039
|
process.exit(1);
|
|
1003
1040
|
}
|
|
1004
1041
|
}
|
|
1005
|
-
async function runNowAll(hqRoot, message, onConflict, modeAllOverride) {
|
|
1042
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal) {
|
|
1006
1043
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
1007
1044
|
console.log(` HQ root: ${hqRoot}`);
|
|
1008
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
1045
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1046
|
+
if (skipPersonal) {
|
|
1047
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
1048
|
+
}
|
|
1049
|
+
console.log("");
|
|
1009
1050
|
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
1010
1051
|
// so the per-target rendering, error isolation, and exit codes are
|
|
1011
1052
|
// identical to running `push --all` then `pull --all` back-to-back.
|
|
1012
1053
|
console.log(chalk.dim("→ push --all"));
|
|
1013
|
-
await runPushAll(hqRoot, message, onConflict);
|
|
1054
|
+
await runPushAll(hqRoot, message, onConflict, skipPersonal);
|
|
1014
1055
|
console.log(chalk.dim("\n→ pull --all"));
|
|
1015
1056
|
// US-011: forward --mode-all so the strict refusal applies to the
|
|
1016
1057
|
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
1017
1058
|
// pull-side).
|
|
1018
|
-
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
1059
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
|
|
1019
1060
|
}
|
|
1020
1061
|
/**
|
|
1021
1062
|
* Best-effort read of the active company slug from `<hqRoot>/.hq/config.json`.
|
|
@@ -1089,4 +1130,4 @@ function resolveUploadAuthorFromCache() {
|
|
|
1089
1130
|
}
|
|
1090
1131
|
}
|
|
1091
1132
|
//# sourceMappingURL=cloud.js.map
|
|
1092
|
-
//# debugId=
|
|
1133
|
+
//# debugId=bdaf154f-b17d-5482-9e4d-feaebe606a1b
|
|
@@ -54,6 +54,30 @@ export declare function sourceMatchesPackPattern(source: string): boolean;
|
|
|
54
54
|
* contract this function pins.
|
|
55
55
|
*/
|
|
56
56
|
export declare function installToPackages(payloadDir: string, pkg: PackManifest, hqRoot: string): string;
|
|
57
|
+
/**
|
|
58
|
+
* Stamp the install-time `source:` into the destination package.yaml. This is
|
|
59
|
+
* the only on-disk record of which `recommended_packages[].source` produced
|
|
60
|
+
* each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
|
|
61
|
+
* `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
|
|
62
|
+
* against `core/core.yaml:recommended_packages` — without this stamp, the
|
|
63
|
+
* dedup set is empty and every recommended pack is re-prompted on every
|
|
64
|
+
* setup.sh run.
|
|
65
|
+
*
|
|
66
|
+
* Idempotent: re-installs (and `hq update`) replace any pre-existing
|
|
67
|
+
* top-level `source:` line rather than duplicating it. Nested `source:`
|
|
68
|
+
* fields under `contributes:` (or any other block) are left untouched.
|
|
69
|
+
*
|
|
70
|
+
* The value is YAML double-quoted so `github:` sources containing `#`
|
|
71
|
+
* survive round-trip (an unquoted `#` would be parsed as a comment).
|
|
72
|
+
*
|
|
73
|
+
* If the manifest opens with a `---` YAML document marker, the source line is
|
|
74
|
+
* inserted *after* the marker — otherwise the prepend would create a
|
|
75
|
+
* two-document stream that single-doc `yaml.load` callers in this codebase
|
|
76
|
+
* cannot read.
|
|
77
|
+
*
|
|
78
|
+
* Exported for tests.
|
|
79
|
+
*/
|
|
80
|
+
export declare function stampInstallSource(destDir: string, source: string): void;
|
|
57
81
|
/**
|
|
58
82
|
* Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
|
|
59
83
|
* pack's contributions into the host paths (skills, hooks, policies, etc.).
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
* from each pack's package.yaml; rationale lives in the layout-fix PR.)
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="
|
|
37
|
+
!function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="845e5459-a7ba-53ca-a04c-dd055fabe52d")}catch(e){}}();
|
|
38
38
|
import * as fs from 'fs';
|
|
39
39
|
import * as os from 'os';
|
|
40
40
|
import * as path from 'path';
|
|
@@ -446,6 +446,61 @@ export function installToPackages(payloadDir, pkg, hqRoot) {
|
|
|
446
446
|
execFileSync('rsync', ['-a', srcSlashed, destSlashed], { stdio: 'inherit' });
|
|
447
447
|
return destDir;
|
|
448
448
|
}
|
|
449
|
+
/**
|
|
450
|
+
* Stamp the install-time `source:` into the destination package.yaml. This is
|
|
451
|
+
* the only on-disk record of which `recommended_packages[].source` produced
|
|
452
|
+
* each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
|
|
453
|
+
* `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
|
|
454
|
+
* against `core/core.yaml:recommended_packages` — without this stamp, the
|
|
455
|
+
* dedup set is empty and every recommended pack is re-prompted on every
|
|
456
|
+
* setup.sh run.
|
|
457
|
+
*
|
|
458
|
+
* Idempotent: re-installs (and `hq update`) replace any pre-existing
|
|
459
|
+
* top-level `source:` line rather than duplicating it. Nested `source:`
|
|
460
|
+
* fields under `contributes:` (or any other block) are left untouched.
|
|
461
|
+
*
|
|
462
|
+
* The value is YAML double-quoted so `github:` sources containing `#`
|
|
463
|
+
* survive round-trip (an unquoted `#` would be parsed as a comment).
|
|
464
|
+
*
|
|
465
|
+
* If the manifest opens with a `---` YAML document marker, the source line is
|
|
466
|
+
* inserted *after* the marker — otherwise the prepend would create a
|
|
467
|
+
* two-document stream that single-doc `yaml.load` callers in this codebase
|
|
468
|
+
* cannot read.
|
|
469
|
+
*
|
|
470
|
+
* Exported for tests.
|
|
471
|
+
*/
|
|
472
|
+
export function stampInstallSource(destDir, source) {
|
|
473
|
+
const manifestPath = path.join(destDir, 'package.yaml');
|
|
474
|
+
if (!fs.existsSync(manifestPath)) {
|
|
475
|
+
// installToPackages already validated the payload contained package.yaml.
|
|
476
|
+
// If it's gone by the time we stamp, something deleted it between calls —
|
|
477
|
+
// fail loud so the regression surfaces in tests.
|
|
478
|
+
throw new Error(`stampInstallSource: ${manifestPath} not found`);
|
|
479
|
+
}
|
|
480
|
+
const original = fs.readFileSync(manifestPath, 'utf-8');
|
|
481
|
+
// Strip any existing top-level `source:` line. Match only lines starting at
|
|
482
|
+
// column 0 so nested `source:` fields (none in spec today, but defensive)
|
|
483
|
+
// are not touched.
|
|
484
|
+
const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
|
|
485
|
+
const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
486
|
+
const sourceLine = `source: ${quoted}`;
|
|
487
|
+
// Detect a leading YAML document marker (`---`, optionally followed by
|
|
488
|
+
// trailing whitespace or a comment). If present, the source line goes
|
|
489
|
+
// *after* it so the manifest stays a single document.
|
|
490
|
+
let insertAt = 0;
|
|
491
|
+
while (insertAt < lines.length && lines[insertAt].trim() === '')
|
|
492
|
+
insertAt++;
|
|
493
|
+
const opensWithDocMarker = insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
|
|
494
|
+
if (opensWithDocMarker) {
|
|
495
|
+
lines.splice(insertAt + 1, 0, sourceLine);
|
|
496
|
+
}
|
|
497
|
+
else {
|
|
498
|
+
// Drop any leading blank lines we walked past so the file doesn't grow a
|
|
499
|
+
// blank-line gutter on every re-stamp.
|
|
500
|
+
lines.splice(0, insertAt, sourceLine);
|
|
501
|
+
}
|
|
502
|
+
fs.writeFileSync(manifestPath, lines.join('\n'));
|
|
503
|
+
}
|
|
449
504
|
/**
|
|
450
505
|
* Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
|
|
451
506
|
* pack's contributions into the host paths (skills, hooks, policies, etc.).
|
|
@@ -509,12 +564,15 @@ export async function installPack(source, opts = {}) {
|
|
|
509
564
|
return;
|
|
510
565
|
}
|
|
511
566
|
const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
|
|
512
|
-
// Under the v12 HQ layout, packs live at `core/packages/<name>/` and
|
|
513
|
-
// tracked by filesystem presence
|
|
514
|
-
//
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
567
|
+
// Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
|
|
568
|
+
// are tracked by filesystem presence — no `modules.yaml` write (that
|
|
569
|
+
// removed the orphan top-level `modules/` tree alongside the canonical
|
|
570
|
+
// `core/`). The one piece of install-time provenance we DO record is the
|
|
571
|
+
// input `source` string, stamped into the destination `package.yaml` so
|
|
572
|
+
// setup.sh can dedup against `core/core.yaml:recommended_packages` on
|
|
573
|
+
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
574
|
+
// matches the verbatim equality check in setup.sh.
|
|
575
|
+
stampInstallSource(destDir, source);
|
|
518
576
|
runScanPackages(hqRoot);
|
|
519
577
|
console.log(chalk.green(`\n✓ Installed ${pkg.name}@${pkg.version} → ${path.relative(hqRoot, destDir)}/`));
|
|
520
578
|
console.log(chalk.dim(` Wired ${Object.values(pkg.contributes).flat().filter(Boolean).length} ` +
|
|
@@ -525,4 +583,4 @@ export async function installPack(source, opts = {}) {
|
|
|
525
583
|
}
|
|
526
584
|
}
|
|
527
585
|
//# sourceMappingURL=pack-install.js.map
|
|
528
|
-
//# debugId=
|
|
586
|
+
//# debugId=845e5459-a7ba-53ca-a04c-dd055fabe52d
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.21.0",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"clean": "rm -rf dist"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@indigoai-us/hq-cloud": "
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.34.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -493,4 +493,102 @@ describe("pullAll", () => {
|
|
|
493
493
|
expect(call.hqRoot).toBe("/Users/me/scratch/hq");
|
|
494
494
|
}
|
|
495
495
|
});
|
|
496
|
+
|
|
497
|
+
// ── 11. skipPersonal — fanout omits the canonical-person leg ──────────────
|
|
498
|
+
//
|
|
499
|
+
// Mirrors the upstream `hq-sync-runner --skip-personal` flag (hq-cloud
|
|
500
|
+
// 5.25.0) plumbed through to the CLI as `hq sync pull --all --no-personal`.
|
|
501
|
+
// When the option is true, `pullAll` must (a) skip the canonical-person
|
|
502
|
+
// bucket even when one exists, and (b) leave companies untouched.
|
|
503
|
+
|
|
504
|
+
it("skipPersonal=true omits the personal leg even when a person entity exists", async () => {
|
|
505
|
+
const vaultClient = makeVaultClient({
|
|
506
|
+
memberships: [
|
|
507
|
+
{ companyUid: "cmp_acme" },
|
|
508
|
+
{ companyUid: "cmp_globex" },
|
|
509
|
+
],
|
|
510
|
+
persons: [
|
|
511
|
+
{
|
|
512
|
+
uid: "psn_alice",
|
|
513
|
+
type: "person",
|
|
514
|
+
slug: "alice",
|
|
515
|
+
bucketName: "hq-vault-psn-alice",
|
|
516
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
entitiesBySlug: {
|
|
520
|
+
cmp_acme: { slug: "acme" },
|
|
521
|
+
cmp_globex: { slug: "globex" },
|
|
522
|
+
},
|
|
523
|
+
});
|
|
524
|
+
const sync = makeSyncSpy();
|
|
525
|
+
|
|
526
|
+
const result = await pullAll(
|
|
527
|
+
{ hqRoot: "/tmp/hq", skipPersonal: true },
|
|
528
|
+
{ vaultClient, sync: sync.fn },
|
|
529
|
+
);
|
|
530
|
+
|
|
531
|
+
expect(sync.calls.map((c) => c.company)).toEqual([
|
|
532
|
+
"cmp_acme",
|
|
533
|
+
"cmp_globex",
|
|
534
|
+
]);
|
|
535
|
+
for (const call of sync.calls) {
|
|
536
|
+
expect(call.personalMode).toBeUndefined();
|
|
537
|
+
expect(call.journalSlug).toBeUndefined();
|
|
538
|
+
}
|
|
539
|
+
expect(result.attempted).toBe(2);
|
|
540
|
+
expect(result.perCompany.map((r) => r.slug)).toEqual(["acme", "globex"]);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it("skipPersonal omitted/false keeps the personal leg in the fanout (baseline)", async () => {
|
|
544
|
+
const vaultClient = makeVaultClient({
|
|
545
|
+
memberships: [{ companyUid: "cmp_acme" }],
|
|
546
|
+
persons: [
|
|
547
|
+
{
|
|
548
|
+
uid: "psn_alice",
|
|
549
|
+
type: "person",
|
|
550
|
+
slug: "alice",
|
|
551
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
entitiesBySlug: { cmp_acme: { slug: "acme" } },
|
|
555
|
+
});
|
|
556
|
+
const sync = makeSyncSpy();
|
|
557
|
+
|
|
558
|
+
await pullAll(
|
|
559
|
+
{ hqRoot: "/tmp/hq" },
|
|
560
|
+
{ vaultClient, sync: sync.fn },
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
expect(sync.calls.map((c) => c.company)).toEqual([
|
|
564
|
+
"cmp_acme",
|
|
565
|
+
"psn_alice",
|
|
566
|
+
]);
|
|
567
|
+
expect(sync.calls[1].personalMode).toBe(true);
|
|
568
|
+
expect(sync.calls[1].journalSlug).toBe("personal");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it("skipPersonal=true with empty memberships produces zero sync calls", async () => {
|
|
572
|
+
const vaultClient = makeVaultClient({
|
|
573
|
+
memberships: [],
|
|
574
|
+
persons: [
|
|
575
|
+
{
|
|
576
|
+
uid: "psn_alice",
|
|
577
|
+
type: "person",
|
|
578
|
+
slug: "alice",
|
|
579
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
const sync = makeSyncSpy();
|
|
584
|
+
|
|
585
|
+
const result = await pullAll(
|
|
586
|
+
{ hqRoot: "/tmp/hq", skipPersonal: true },
|
|
587
|
+
{ vaultClient, sync: sync.fn },
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
expect(sync.calls).toEqual([]);
|
|
591
|
+
expect(result.attempted).toBe(0);
|
|
592
|
+
expect(result.perCompany).toEqual([]);
|
|
593
|
+
});
|
|
496
594
|
});
|
|
@@ -378,4 +378,79 @@ describe("pushAll", () => {
|
|
|
378
378
|
expect(result.filesUploaded).toBe(3);
|
|
379
379
|
expect(result.filesDeleted).toBe(4);
|
|
380
380
|
});
|
|
381
|
+
|
|
382
|
+
// ── skipPersonal — fanout omits the canonical-person leg ──────────────────
|
|
383
|
+
//
|
|
384
|
+
// Symmetric with the `pullAll` skipPersonal coverage in
|
|
385
|
+
// `cloud.pull-all.test.ts`. Wired through from `hq sync push --all
|
|
386
|
+
// --no-personal` (Commander's `--no-personal` is the auto-negation of the
|
|
387
|
+
// `--personal` single-target selector). Verifies (a) the personal leg is
|
|
388
|
+
// dropped even when a canonical person exists, and (b) the baseline
|
|
389
|
+
// behavior — no flag → personal stays in the plan.
|
|
390
|
+
|
|
391
|
+
it("skipPersonal=true omits the personal leg even when a person entity exists", async () => {
|
|
392
|
+
const hqRoot = makeHqRoot([".git", "companies", "repos", "workspace"]);
|
|
393
|
+
const vaultClient = makeVaultClient({
|
|
394
|
+
memberships: [
|
|
395
|
+
{ companyUid: "cmp_acme" },
|
|
396
|
+
{ companyUid: "cmp_globex" },
|
|
397
|
+
],
|
|
398
|
+
persons: [
|
|
399
|
+
{
|
|
400
|
+
uid: "psn_alice",
|
|
401
|
+
type: "person",
|
|
402
|
+
slug: "alice",
|
|
403
|
+
bucketName: "hq-vault-psn-alice",
|
|
404
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
405
|
+
},
|
|
406
|
+
],
|
|
407
|
+
entitiesBySlug: {
|
|
408
|
+
cmp_acme: { slug: "acme" },
|
|
409
|
+
cmp_globex: { slug: "globex" },
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
const share = makeShareSpy();
|
|
413
|
+
|
|
414
|
+
const result = await pushAll(
|
|
415
|
+
{ hqRoot, skipPersonal: true },
|
|
416
|
+
{ vaultClient, share: share.fn },
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(share.calls.map((c) => c.company)).toEqual([
|
|
420
|
+
"cmp_acme",
|
|
421
|
+
"cmp_globex",
|
|
422
|
+
]);
|
|
423
|
+
for (const call of share.calls) {
|
|
424
|
+
expect(call.personalMode).toBeUndefined();
|
|
425
|
+
expect(call.journalSlug).toBeUndefined();
|
|
426
|
+
}
|
|
427
|
+
expect(result.attempted).toBe(2);
|
|
428
|
+
expect(result.perCompany.map((r) => r.slug)).toEqual(["acme", "globex"]);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("skipPersonal omitted keeps the personal leg in the plan (baseline)", async () => {
|
|
432
|
+
const hqRoot = makeHqRoot([".git", "companies", "repos", "workspace"]);
|
|
433
|
+
const vaultClient = makeVaultClient({
|
|
434
|
+
memberships: [{ companyUid: "cmp_acme" }],
|
|
435
|
+
persons: [
|
|
436
|
+
{
|
|
437
|
+
uid: "psn_alice",
|
|
438
|
+
type: "person",
|
|
439
|
+
slug: "alice",
|
|
440
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
441
|
+
},
|
|
442
|
+
],
|
|
443
|
+
entitiesBySlug: { cmp_acme: { slug: "acme" } },
|
|
444
|
+
});
|
|
445
|
+
const share = makeShareSpy();
|
|
446
|
+
|
|
447
|
+
await pushAll({ hqRoot }, { vaultClient, share: share.fn });
|
|
448
|
+
|
|
449
|
+
expect(share.calls.map((c) => c.company)).toEqual([
|
|
450
|
+
"cmp_acme",
|
|
451
|
+
"psn_alice",
|
|
452
|
+
]);
|
|
453
|
+
expect(share.calls[1].personalMode).toBe(true);
|
|
454
|
+
expect(share.calls[1].journalSlug).toBe("personal");
|
|
455
|
+
});
|
|
381
456
|
});
|
package/src/commands/cloud.ts
CHANGED
|
@@ -125,6 +125,17 @@ export interface PullAllOptions {
|
|
|
125
125
|
* legacy behavior for this run via `--mode-all`.
|
|
126
126
|
*/
|
|
127
127
|
modeAllOverride?: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
130
|
+
* fanout only visits the caller's company memberships. Mirrors the
|
|
131
|
+
* `--skip-personal` flag and `HQ_SYNC_SKIP_PERSONAL` env var that
|
|
132
|
+
* `@indigoai-us/hq-cloud`'s `sync-runner` exposes in `--companies`
|
|
133
|
+
* mode (see hq-cloud 5.25.0 `resolveSkipPersonal`). Surfaced on the
|
|
134
|
+
* CLI as `hq sync pull --all --no-personal` so the AppBar HQ Sync
|
|
135
|
+
* menubar toggle (and CLI users opting out one-off) can drop the
|
|
136
|
+
* personal vault from the run without touching the rest of the plan.
|
|
137
|
+
*/
|
|
138
|
+
skipPersonal?: boolean;
|
|
128
139
|
}
|
|
129
140
|
|
|
130
141
|
export interface PullAllRow {
|
|
@@ -173,6 +184,13 @@ export interface PushAllOptions {
|
|
|
173
184
|
hqRoot: string;
|
|
174
185
|
onConflict?: ConflictStrategy;
|
|
175
186
|
message?: string;
|
|
187
|
+
/**
|
|
188
|
+
* When `true`, skip the canonical-person-entity leg entirely — the
|
|
189
|
+
* fanout only visits the caller's company memberships. Symmetric with
|
|
190
|
+
* `PullAllOptions.skipPersonal`; surfaced on the CLI as
|
|
191
|
+
* `hq sync push --all --no-personal`.
|
|
192
|
+
*/
|
|
193
|
+
skipPersonal?: boolean;
|
|
176
194
|
}
|
|
177
195
|
|
|
178
196
|
export interface PushAllRow {
|
|
@@ -252,7 +270,7 @@ export async function pullAll(
|
|
|
252
270
|
});
|
|
253
271
|
}
|
|
254
272
|
|
|
255
|
-
const personal = pickCanonicalPerson(persons);
|
|
273
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
256
274
|
if (personal) {
|
|
257
275
|
plan.push({
|
|
258
276
|
slug: "personal",
|
|
@@ -385,7 +403,7 @@ export async function pushAll(
|
|
|
385
403
|
});
|
|
386
404
|
}
|
|
387
405
|
|
|
388
|
-
const personal = pickCanonicalPerson(persons);
|
|
406
|
+
const personal = options.skipPersonal ? null : pickCanonicalPerson(persons);
|
|
389
407
|
if (personal) {
|
|
390
408
|
plan.push({
|
|
391
409
|
slug: "personal",
|
|
@@ -637,6 +655,15 @@ export function registerCloudCommands(program: Command): void {
|
|
|
637
655
|
"(.git, companies, repos, workspace) — same scope as `--all`'s " +
|
|
638
656
|
"personal slot. Mutually exclusive with --company and --all.",
|
|
639
657
|
)
|
|
658
|
+
.option(
|
|
659
|
+
"--no-personal",
|
|
660
|
+
"In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
661
|
+
"only push to the caller's company memberships. Mirrors the " +
|
|
662
|
+
"upstream `hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0); " +
|
|
663
|
+
"wired so the AppBar HQ Sync menubar can drop personal sync via " +
|
|
664
|
+
"a toggle, and so CLI users can opt out one-off. Ignored outside " +
|
|
665
|
+
"`--all`.",
|
|
666
|
+
)
|
|
640
667
|
.action(
|
|
641
668
|
async (
|
|
642
669
|
paths: string[],
|
|
@@ -678,10 +705,17 @@ export function registerCloudCommands(program: Command): void {
|
|
|
678
705
|
);
|
|
679
706
|
process.exit(1);
|
|
680
707
|
}
|
|
708
|
+
// `options.personal === false` happens when the user passed
|
|
709
|
+
// `--no-personal` (Commander's auto-negation of the `--personal`
|
|
710
|
+
// selector). In `--all` mode that means "skip the personal leg of
|
|
711
|
+
// the fanout" — wired through to `pushAll.skipPersonal`. Outside
|
|
712
|
+
// `--all` the flag has no effect (logged above as part of the
|
|
713
|
+
// option's help text).
|
|
681
714
|
await runPushAll(
|
|
682
715
|
options.hqRoot,
|
|
683
716
|
options.message,
|
|
684
717
|
options.onConflict,
|
|
718
|
+
options.personal === false,
|
|
685
719
|
);
|
|
686
720
|
return;
|
|
687
721
|
}
|
|
@@ -885,6 +919,13 @@ export function registerCloudCommands(program: Command): void {
|
|
|
885
919
|
"from the cached Cognito session. Mutually exclusive with --company " +
|
|
886
920
|
"and --all.",
|
|
887
921
|
)
|
|
922
|
+
.option(
|
|
923
|
+
"--no-personal",
|
|
924
|
+
"In `--all` mode, skip the canonical-person leg of the fanout — " +
|
|
925
|
+
"only pull the caller's company memberships. Mirrors the upstream " +
|
|
926
|
+
"`hq-sync-runner --skip-personal` flag (hq-cloud 5.25.0). Ignored " +
|
|
927
|
+
"outside `--all`.",
|
|
928
|
+
)
|
|
888
929
|
.option(
|
|
889
930
|
"--mode-all",
|
|
890
931
|
"US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
@@ -911,10 +952,14 @@ export function registerCloudCommands(program: Command): void {
|
|
|
911
952
|
process.exit(1);
|
|
912
953
|
}
|
|
913
954
|
if (options.all) {
|
|
955
|
+
// `options.personal === false` is Commander's auto-negation of
|
|
956
|
+
// `--personal`; in `--all` mode that means "drop the personal
|
|
957
|
+
// leg from the fanout" (see `--no-personal` option above).
|
|
914
958
|
await runPullAll(
|
|
915
959
|
options.hqRoot,
|
|
916
960
|
options.onConflict,
|
|
917
961
|
options.modeAll === true,
|
|
962
|
+
options.personal === false,
|
|
918
963
|
);
|
|
919
964
|
return;
|
|
920
965
|
}
|
|
@@ -1100,6 +1145,13 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1100
1145
|
"Sync the caller's canonical personal vault bidirectionally. " +
|
|
1101
1146
|
"Mutually exclusive with --company and --all.",
|
|
1102
1147
|
)
|
|
1148
|
+
.option(
|
|
1149
|
+
"--no-personal",
|
|
1150
|
+
"In `--all` mode, skip the canonical-person leg of both the push " +
|
|
1151
|
+
"and pull fanouts — only sync the caller's company memberships. " +
|
|
1152
|
+
"Mirrors the upstream `hq-sync-runner --skip-personal` flag " +
|
|
1153
|
+
"(hq-cloud 5.25.0). Ignored outside `--all`.",
|
|
1154
|
+
)
|
|
1103
1155
|
.option(
|
|
1104
1156
|
"--mode-all",
|
|
1105
1157
|
"US-011: opt out of the strict narrow-hint refusal for this run. " +
|
|
@@ -1119,11 +1171,16 @@ export function registerCloudCommands(program: Command): void {
|
|
|
1119
1171
|
try {
|
|
1120
1172
|
assertSingleSelector(options, "now");
|
|
1121
1173
|
if (options.all) {
|
|
1174
|
+
// `options.personal === false` is Commander's auto-negation
|
|
1175
|
+
// of `--personal`; in `--all` mode that means "drop the
|
|
1176
|
+
// personal leg from both legs of the bidirectional fanout"
|
|
1177
|
+
// (see `--no-personal` option above).
|
|
1122
1178
|
await runNowAll(
|
|
1123
1179
|
options.hqRoot,
|
|
1124
1180
|
options.message,
|
|
1125
1181
|
options.onConflict,
|
|
1126
1182
|
options.modeAll === true,
|
|
1183
|
+
options.personal === false,
|
|
1127
1184
|
);
|
|
1128
1185
|
return;
|
|
1129
1186
|
}
|
|
@@ -1150,10 +1207,15 @@ async function runPullAll(
|
|
|
1150
1207
|
hqRoot: string,
|
|
1151
1208
|
onConflict?: ConflictStrategy,
|
|
1152
1209
|
modeAllOverride?: boolean,
|
|
1210
|
+
skipPersonal?: boolean,
|
|
1153
1211
|
): Promise<void> {
|
|
1154
1212
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
1155
1213
|
console.log(` HQ root: ${hqRoot}`);
|
|
1156
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
1214
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1215
|
+
if (skipPersonal) {
|
|
1216
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
1217
|
+
}
|
|
1218
|
+
console.log("");
|
|
1157
1219
|
|
|
1158
1220
|
let result: PullAllResult;
|
|
1159
1221
|
try {
|
|
@@ -1181,6 +1243,7 @@ async function runPullAll(
|
|
|
1181
1243
|
...(onConflict ? { onConflict } : {}),
|
|
1182
1244
|
narrowHintLevel: resolveBannerLevel(),
|
|
1183
1245
|
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
1246
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1184
1247
|
},
|
|
1185
1248
|
{
|
|
1186
1249
|
vaultClient: adapter,
|
|
@@ -1285,10 +1348,15 @@ async function runPushAll(
|
|
|
1285
1348
|
hqRoot: string,
|
|
1286
1349
|
message?: string,
|
|
1287
1350
|
onConflict?: ConflictStrategy,
|
|
1351
|
+
skipPersonal?: boolean,
|
|
1288
1352
|
): Promise<void> {
|
|
1289
1353
|
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
1290
1354
|
console.log(` HQ root: ${hqRoot}`);
|
|
1291
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
1355
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1356
|
+
if (skipPersonal) {
|
|
1357
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
1358
|
+
}
|
|
1359
|
+
console.log("");
|
|
1292
1360
|
|
|
1293
1361
|
let result: PushAllResult;
|
|
1294
1362
|
try {
|
|
@@ -1314,6 +1382,7 @@ async function runPushAll(
|
|
|
1314
1382
|
hqRoot,
|
|
1315
1383
|
...(onConflict ? { onConflict } : {}),
|
|
1316
1384
|
...(message ? { message } : {}),
|
|
1385
|
+
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1317
1386
|
},
|
|
1318
1387
|
{
|
|
1319
1388
|
vaultClient: adapter,
|
|
@@ -1552,21 +1621,26 @@ async function runNowAll(
|
|
|
1552
1621
|
message?: string,
|
|
1553
1622
|
onConflict?: ConflictStrategy,
|
|
1554
1623
|
modeAllOverride?: boolean,
|
|
1624
|
+
skipPersonal?: boolean,
|
|
1555
1625
|
): Promise<void> {
|
|
1556
1626
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
1557
1627
|
console.log(` HQ root: ${hqRoot}`);
|
|
1558
|
-
console.log(` Strategy: ${onConflict ?? "(interactive)"}
|
|
1628
|
+
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1629
|
+
if (skipPersonal) {
|
|
1630
|
+
console.log(` Personal: skipped (--no-personal)`);
|
|
1631
|
+
}
|
|
1632
|
+
console.log("");
|
|
1559
1633
|
|
|
1560
1634
|
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
1561
1635
|
// so the per-target rendering, error isolation, and exit codes are
|
|
1562
1636
|
// identical to running `push --all` then `pull --all` back-to-back.
|
|
1563
1637
|
console.log(chalk.dim("→ push --all"));
|
|
1564
|
-
await runPushAll(hqRoot, message, onConflict);
|
|
1638
|
+
await runPushAll(hqRoot, message, onConflict, skipPersonal);
|
|
1565
1639
|
console.log(chalk.dim("\n→ pull --all"));
|
|
1566
1640
|
// US-011: forward --mode-all so the strict refusal applies to the
|
|
1567
1641
|
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
1568
1642
|
// pull-side).
|
|
1569
|
-
await runPullAll(hqRoot, onConflict, modeAllOverride);
|
|
1643
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal);
|
|
1570
1644
|
}
|
|
1571
1645
|
|
|
1572
1646
|
/**
|
|
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
|
21
21
|
import * as fs from 'fs';
|
|
22
22
|
import * as os from 'os';
|
|
23
23
|
import * as path from 'path';
|
|
24
|
-
import { installToPackages, runScanPackages } from './pack-install.js';
|
|
24
|
+
import { installToPackages, runScanPackages, stampInstallSource } from './pack-install.js';
|
|
25
25
|
import type { PackManifest } from '../types.js';
|
|
26
26
|
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -145,4 +145,118 @@ describe('pack-install: install path layout', () => {
|
|
|
145
145
|
expect(fs.existsSync(path.join(hqRoot, 'modules'))).toBe(false);
|
|
146
146
|
expect(fs.existsSync(path.join(hqRoot, 'modules.yaml'))).toBe(false);
|
|
147
147
|
});
|
|
148
|
+
|
|
149
|
+
// ---- 5. stampInstallSource — setup.sh dedup contract --------------------
|
|
150
|
+
// setup.sh in hq-core ≥14.2.x reads `^source:` from every installed
|
|
151
|
+
// core/packages/*/package.yaml and uses verbatim equality against
|
|
152
|
+
// core.yaml:recommended_packages[].source to skip "already installed" packs.
|
|
153
|
+
// Without the stamp the dedup set is empty and every recommended pack gets
|
|
154
|
+
// re-prompted on every setup.sh run — the bug this stamp closes.
|
|
155
|
+
it('stampInstallSource writes a top-level source: line into package.yaml', () => {
|
|
156
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
157
|
+
const src = '@indigoai-us/hq-pack-test@1.2.3';
|
|
158
|
+
stampInstallSource(dest, src);
|
|
159
|
+
|
|
160
|
+
const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
161
|
+
// First line so a `grep ^source:` style scan never misses it.
|
|
162
|
+
expect(written.split('\n')[0]).toBe(`source: "${src}"`);
|
|
163
|
+
// Original fields preserved verbatim — the stamp must not clobber name/version.
|
|
164
|
+
expect(written).toContain('name: hq-pack-test');
|
|
165
|
+
expect(written).toContain('version: 1.0.0');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('stampInstallSource is idempotent — re-install replaces (not duplicates) the source line', () => {
|
|
169
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
170
|
+
stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
|
|
171
|
+
stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.2.3');
|
|
172
|
+
|
|
173
|
+
const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
174
|
+
// Exactly one top-level source: line, and it carries the latest value.
|
|
175
|
+
const sourceLines = written.split('\n').filter((l) => /^source\s*:/.test(l));
|
|
176
|
+
expect(sourceLines).toHaveLength(1);
|
|
177
|
+
expect(sourceLines[0]).toBe('source: "@indigoai-us/hq-pack-test@1.2.3"');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('stampInstallSource quotes github: sources so the # is not parsed as a YAML comment', () => {
|
|
181
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
182
|
+
const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
|
|
183
|
+
stampInstallSource(dest, src);
|
|
184
|
+
|
|
185
|
+
const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
186
|
+
// Double-quoted form survives the '#' that would otherwise truncate the
|
|
187
|
+
// value at the first YAML comment marker.
|
|
188
|
+
expect(written.split('\n')[0]).toBe(`source: "${src}"`);
|
|
189
|
+
// Round-trip through js-yaml to confirm a real parser reads the full value.
|
|
190
|
+
const yamlLib = require('js-yaml');
|
|
191
|
+
const parsed = yamlLib.load(written) as { source: string };
|
|
192
|
+
expect(parsed.source).toBe(src);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('stampInstallSource only strips top-level source: lines, not nested ones', () => {
|
|
196
|
+
// Defensive: today's spec has no nested `source:` keys, but if a pack
|
|
197
|
+
// author adds e.g. `metadata: { source: ... }` we should leave it alone.
|
|
198
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
199
|
+
fs.writeFileSync(
|
|
200
|
+
path.join(dest, 'package.yaml'),
|
|
201
|
+
[
|
|
202
|
+
'name: hq-pack-test',
|
|
203
|
+
'version: 1.0.0',
|
|
204
|
+
'metadata:',
|
|
205
|
+
' source: nested-value-keep-me',
|
|
206
|
+
'',
|
|
207
|
+
].join('\n'),
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
stampInstallSource(dest, '@indigoai-us/hq-pack-test@1.0.0');
|
|
211
|
+
|
|
212
|
+
const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
213
|
+
expect(written.split('\n')[0]).toBe('source: "@indigoai-us/hq-pack-test@1.0.0"');
|
|
214
|
+
// Nested source line survives.
|
|
215
|
+
expect(written).toContain(' source: nested-value-keep-me');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('stampInstallSource throws when package.yaml is missing — surfaces upstream regressions loudly', () => {
|
|
219
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
220
|
+
fs.unlinkSync(path.join(dest, 'package.yaml'));
|
|
221
|
+
|
|
222
|
+
expect(() => stampInstallSource(dest, '@scope/x@1.0.0')).toThrow(/package\.yaml.*not found/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('stampInstallSource preserves a leading `---` document marker — no multi-doc YAML stream', () => {
|
|
226
|
+
// Regression: prepending `source:` before a `---` would split the file
|
|
227
|
+
// into two YAML documents, and single-doc `yaml.load` callers downstream
|
|
228
|
+
// (in this codebase + hq-core) silently misread or fail. Insert AFTER
|
|
229
|
+
// the marker so the manifest stays a single document.
|
|
230
|
+
const dest = installToPackages(payload, fakeManifest({ name: 'hq-pack-test' }), hqRoot);
|
|
231
|
+
fs.writeFileSync(
|
|
232
|
+
path.join(dest, 'package.yaml'),
|
|
233
|
+
['---', 'name: hq-pack-test', 'version: 1.0.0', ''].join('\n'),
|
|
234
|
+
);
|
|
235
|
+
const src = 'github:indigoai-us/hq-packages#packages/hq-pack-test';
|
|
236
|
+
|
|
237
|
+
stampInstallSource(dest, src);
|
|
238
|
+
|
|
239
|
+
const written = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
240
|
+
// `---` stays on line 0, `source:` follows immediately so `grep ^source:`
|
|
241
|
+
// dedup scans still match.
|
|
242
|
+
const lines = written.split('\n');
|
|
243
|
+
expect(lines[0]).toBe('---');
|
|
244
|
+
expect(lines[1]).toBe(`source: "${src}"`);
|
|
245
|
+
|
|
246
|
+
// Critical assertion: js-yaml's single-doc loader returns the merged
|
|
247
|
+
// manifest, not just the first of two documents.
|
|
248
|
+
const yamlLib = require('js-yaml');
|
|
249
|
+
const parsed = yamlLib.load(written) as { source: string; name: string; version: string };
|
|
250
|
+
expect(parsed.source).toBe(src);
|
|
251
|
+
expect(parsed.name).toBe('hq-pack-test');
|
|
252
|
+
expect(parsed.version).toBe('1.0.0');
|
|
253
|
+
|
|
254
|
+
// Re-stamp is still idempotent in the doc-marker case.
|
|
255
|
+
stampInstallSource(dest, '@scope/x@2.0.0');
|
|
256
|
+
const rewritten = fs.readFileSync(path.join(dest, 'package.yaml'), 'utf-8');
|
|
257
|
+
const sourceLines = rewritten.split('\n').filter((l) => /^source\s*:/.test(l));
|
|
258
|
+
expect(sourceLines).toHaveLength(1);
|
|
259
|
+
expect(sourceLines[0]).toBe('source: "@scope/x@2.0.0"');
|
|
260
|
+
expect(rewritten.split('\n')[0]).toBe('---');
|
|
261
|
+
});
|
|
148
262
|
});
|
|
@@ -547,6 +547,62 @@ export function installToPackages(
|
|
|
547
547
|
return destDir;
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Stamp the install-time `source:` into the destination package.yaml. This is
|
|
552
|
+
* the only on-disk record of which `recommended_packages[].source` produced
|
|
553
|
+
* each installed pack. `core/scripts/setup.sh` (hq-core >=14.2.x) reads
|
|
554
|
+
* `^source:` from every installed `core/packages/<name>/package.yaml` to dedup
|
|
555
|
+
* against `core/core.yaml:recommended_packages` — without this stamp, the
|
|
556
|
+
* dedup set is empty and every recommended pack is re-prompted on every
|
|
557
|
+
* setup.sh run.
|
|
558
|
+
*
|
|
559
|
+
* Idempotent: re-installs (and `hq update`) replace any pre-existing
|
|
560
|
+
* top-level `source:` line rather than duplicating it. Nested `source:`
|
|
561
|
+
* fields under `contributes:` (or any other block) are left untouched.
|
|
562
|
+
*
|
|
563
|
+
* The value is YAML double-quoted so `github:` sources containing `#`
|
|
564
|
+
* survive round-trip (an unquoted `#` would be parsed as a comment).
|
|
565
|
+
*
|
|
566
|
+
* If the manifest opens with a `---` YAML document marker, the source line is
|
|
567
|
+
* inserted *after* the marker — otherwise the prepend would create a
|
|
568
|
+
* two-document stream that single-doc `yaml.load` callers in this codebase
|
|
569
|
+
* cannot read.
|
|
570
|
+
*
|
|
571
|
+
* Exported for tests.
|
|
572
|
+
*/
|
|
573
|
+
export function stampInstallSource(destDir: string, source: string): void {
|
|
574
|
+
const manifestPath = path.join(destDir, 'package.yaml');
|
|
575
|
+
if (!fs.existsSync(manifestPath)) {
|
|
576
|
+
// installToPackages already validated the payload contained package.yaml.
|
|
577
|
+
// If it's gone by the time we stamp, something deleted it between calls —
|
|
578
|
+
// fail loud so the regression surfaces in tests.
|
|
579
|
+
throw new Error(`stampInstallSource: ${manifestPath} not found`);
|
|
580
|
+
}
|
|
581
|
+
const original = fs.readFileSync(manifestPath, 'utf-8');
|
|
582
|
+
// Strip any existing top-level `source:` line. Match only lines starting at
|
|
583
|
+
// column 0 so nested `source:` fields (none in spec today, but defensive)
|
|
584
|
+
// are not touched.
|
|
585
|
+
const lines = original.split('\n').filter((line) => !/^source\s*:/.test(line));
|
|
586
|
+
const quoted = `"${source.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
587
|
+
const sourceLine = `source: ${quoted}`;
|
|
588
|
+
|
|
589
|
+
// Detect a leading YAML document marker (`---`, optionally followed by
|
|
590
|
+
// trailing whitespace or a comment). If present, the source line goes
|
|
591
|
+
// *after* it so the manifest stays a single document.
|
|
592
|
+
let insertAt = 0;
|
|
593
|
+
while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt++;
|
|
594
|
+
const opensWithDocMarker =
|
|
595
|
+
insertAt < lines.length && /^---(\s|#|$)/.test(lines[insertAt]);
|
|
596
|
+
if (opensWithDocMarker) {
|
|
597
|
+
lines.splice(insertAt + 1, 0, sourceLine);
|
|
598
|
+
} else {
|
|
599
|
+
// Drop any leading blank lines we walked past so the file doesn't grow a
|
|
600
|
+
// blank-line gutter on every re-stamp.
|
|
601
|
+
lines.splice(0, insertAt, sourceLine);
|
|
602
|
+
}
|
|
603
|
+
fs.writeFileSync(manifestPath, lines.join('\n'));
|
|
604
|
+
}
|
|
605
|
+
|
|
550
606
|
/**
|
|
551
607
|
* Run `<hqRoot>/core/scripts/scan-packages.sh` to wire the newly installed
|
|
552
608
|
* pack's contributions into the host paths (skills, hooks, policies, etc.).
|
|
@@ -640,12 +696,15 @@ export async function installPack(
|
|
|
640
696
|
}
|
|
641
697
|
|
|
642
698
|
const destDir = installToPackages(fetched.payloadDir, pkg, hqRoot);
|
|
643
|
-
// Under the v12 HQ layout, packs live at `core/packages/<name>/` and
|
|
644
|
-
// tracked by filesystem presence
|
|
645
|
-
//
|
|
646
|
-
//
|
|
647
|
-
//
|
|
648
|
-
//
|
|
699
|
+
// Under the v12+ HQ layout, packs live at `core/packages/<name>/` and
|
|
700
|
+
// are tracked by filesystem presence — no `modules.yaml` write (that
|
|
701
|
+
// removed the orphan top-level `modules/` tree alongside the canonical
|
|
702
|
+
// `core/`). The one piece of install-time provenance we DO record is the
|
|
703
|
+
// input `source` string, stamped into the destination `package.yaml` so
|
|
704
|
+
// setup.sh can dedup against `core/core.yaml:recommended_packages` on
|
|
705
|
+
// re-runs. Stamping the literal input (not the resolved SHA/version)
|
|
706
|
+
// matches the verbatim equality check in setup.sh.
|
|
707
|
+
stampInstallSource(destDir, source);
|
|
649
708
|
runScanPackages(hqRoot);
|
|
650
709
|
|
|
651
710
|
console.log(
|