@indigoai-us/hq-cli 5.20.0 → 5.22.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
CHANGED
|
@@ -2,6 +2,86 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.22.0] — 2026-05-25
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Bumps `@indigoai-us/hq-cloud` to `~5.37.0` (was `~5.34.0`). Picks up
|
|
10
|
+
the 5.35 → 5.37 chain on top of yesterday's 5.34.0 cleanup.
|
|
11
|
+
|
|
12
|
+
- **5.35.0** — `.claude/state/` + `.claude/audit/` in
|
|
13
|
+
`DEFAULT_IGNORES`. Removes ~85% of the conflict-mirror class
|
|
14
|
+
observed during the 5.34.0 live cross-machine test. See
|
|
15
|
+
[hq-cloud#25](https://github.com/indigoai-us/hq-cloud/pull/25).
|
|
16
|
+
- **5.36.0** — two sync speedups: an lstat fast-path that skips
|
|
17
|
+
SHA-256 when `(size, mtimeMs)` match the journal baseline
|
|
18
|
+
(~5–10× on no-op syncs, which are most syncs), and a bounded-
|
|
19
|
+
parallel transfer pool (default 16, knob
|
|
20
|
+
`HQ_SYNC_TRANSFER_CONCURRENCY`) for uploads + downloads (4–8× on
|
|
21
|
+
transfer-heavy syncs). Codex P1 follow-ups serialize the
|
|
22
|
+
interactive conflict prompt under the pool and drain in-flight
|
|
23
|
+
transfers on worker error. See
|
|
24
|
+
[hq-cloud#26](https://github.com/indigoai-us/hq-cloud/pull/26).
|
|
25
|
+
- **5.37.0** — file mtime / birthtime preservation across sync.
|
|
26
|
+
Push stamps source-side `lstat.mtimeMs` (+ `birthtimeMs` when the
|
|
27
|
+
filesystem supports it AND differs from mtime) into S3 metadata as
|
|
28
|
+
`hq-mtime` / `hq-btime`. Pull applies the stamped value via
|
|
29
|
+
`utimesSync` after the byte write, falling back to write-time
|
|
30
|
+
when metadata is absent (back-compat). Symlinks skipped on both
|
|
31
|
+
sides. Composes cleanly with the 5.36.0 fast-path — the journal
|
|
32
|
+
records the post-utimes mtime, so the next sync correctly skips
|
|
33
|
+
re-hashing. Codex P2 widened the accepted mtime domain to include
|
|
34
|
+
`0` (Unix epoch, reproducible-builds clamp value) and negative
|
|
35
|
+
epochs (pre-1970). See
|
|
36
|
+
[hq-cloud#27](https://github.com/indigoai-us/hq-cloud/pull/27).
|
|
37
|
+
|
|
38
|
+
Live verified end-to-end on the macOS desktop ↔ Lightsail outpost
|
|
39
|
+
pair: bilateral convergence in 2–3 sync rounds, mtime round-trip
|
|
40
|
+
exact for 2020 / 2030 / epoch-0 / pre-epoch-negative test cases,
|
|
41
|
+
fast-path correctly skips post-utimes files on the next no-op.
|
|
42
|
+
|
|
43
|
+
## [5.21.0] — 2026-05-24
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Bumps `@indigoai-us/hq-cloud` to `~5.34.0` (was `~5.25.0`). 5.34.0
|
|
48
|
+
closes the 10-bug cross-machine sync cleanup from
|
|
49
|
+
[hq-cloud#24](https://github.com/indigoai-us/hq-cloud/pull/24) plus
|
|
50
|
+
the Codex P1/P2 follow-up safeties.
|
|
51
|
+
|
|
52
|
+
Highest-impact fixes that ride this bump:
|
|
53
|
+
|
|
54
|
+
- **Bug #9** (drifted files never converge): the pull walker on
|
|
55
|
+
`~5.25.0` had no tombstone-consumption mechanism, so cross-machine
|
|
56
|
+
deletes never propagated — every host kept the union of "every
|
|
57
|
+
file ever created anywhere." 5.34.0 lands journal-vs-LIST diff +
|
|
58
|
+
HEAD-verify STS-scope guard + local-edit divergence detector for
|
|
59
|
+
both files and symlinks. The Drift counter can finally reach zero.
|
|
60
|
+
- **Bug #7** (concurrent edit data loss): two open laptops editing
|
|
61
|
+
the same file before either had synced silently overwrote the
|
|
62
|
+
slower side on `~5.25.0` — no conflict event, no tray badge,
|
|
63
|
+
invisible data loss. 5.34.0 writes a mirror to
|
|
64
|
+
`<orig>.conflict-<ts>-<short>` instead.
|
|
65
|
+
- **Bug #10** (`ENOTDIR` wedges the whole company sync): one
|
|
66
|
+
dir-vs-file collision on a stale path on `~5.25.0` threw
|
|
67
|
+
`ENOTDIR` and aborted the whole company — `partial: true`,
|
|
68
|
+
`filesSkipped: 0`. 5.34.0 handles both `(local-file, cloud-dir)`
|
|
69
|
+
and `(local-dir, cloud-file)` topologies symmetrically.
|
|
70
|
+
|
|
71
|
+
Plus #1/#6/#8 (`.hq/` directory leak channel — including the
|
|
72
|
+
alternate `.hq/machine.json` and `.hq/install-manifest.json` paths
|
|
73
|
+
that were cross-polluting per-host identity), #2 (PULL walker now
|
|
74
|
+
applies `EPHEMERAL_PATH_PATTERN` — legacy `.conflict-*` litter no
|
|
75
|
+
longer ratchets), #3 (`conflictPaths` deduped within a company), #4
|
|
76
|
+
(dir-vs-file warning + structured event), and #5 (file mode
|
|
77
|
+
preserved across upload/download — was previously collapsing every
|
|
78
|
+
mode to receiver umask default).
|
|
79
|
+
|
|
80
|
+
New `SyncResult` fields surface via the runner's `complete` event:
|
|
81
|
+
`filesTombstoned`, `filesExcludedByPolicy`,
|
|
82
|
+
`filesRefusedStalePaths` (cap 50). hq-cli forwards them through
|
|
83
|
+
unchanged; the menubar can opt in via UI work in a follow-up.
|
|
84
|
+
|
|
5
85
|
## [5.20.0] — 2026-05-21
|
|
6
86
|
|
|
7
87
|
### Added
|
|
@@ -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.22.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": "~5.
|
|
18
|
+
"@indigoai-us/hq-cloud": "~5.37.0",
|
|
19
19
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
20
20
|
"@sentry/node": "^10.49.0",
|
|
21
21
|
"chalk": "^5.3.0",
|
|
@@ -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(
|