@indigoai-us/hq-cli 5.31.0 → 5.32.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/dist/commands/group-grants.d.ts +76 -0
- package/dist/commands/group-grants.js +296 -0
- package/dist/commands/pack-install.d.ts +46 -1
- package/dist/commands/pack-install.js +96 -17
- package/dist/commands/packs.d.ts +22 -0
- package/dist/commands/packs.js +406 -0
- package/dist/commands/pkg-list.js +31 -5
- package/dist/index.js +12 -2
- package/dist/utils/pack-contributions.d.ts +87 -0
- package/dist/utils/pack-contributions.js +239 -0
- package/package.json +1 -1
- package/src/commands/group-grants.test.ts +291 -0
- package/src/commands/group-grants.ts +452 -0
- package/src/commands/pack-install.ts +129 -21
- package/src/commands/packs.ts +524 -0
- package/src/commands/pkg-list.ts +34 -3
- package/src/index.ts +12 -0
- package/src/utils/pack-contributions.test.ts +208 -0
- package/src/utils/pack-contributions.ts +303 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pack-contributions tests — pins the content-pack lifecycle helpers that
|
|
3
|
+
* commands/packs.ts (list / update / uninstall) depends on.
|
|
4
|
+
*
|
|
5
|
+
* The load-bearing invariant is the contributes->host-path mapping: it MUST
|
|
6
|
+
* agree byte-for-byte with core/scripts/scan-packages.sh (the wiring
|
|
7
|
+
* authority) and pack-install.ts:validateManifest. A drift there means
|
|
8
|
+
* uninstall would miss symlinks (dangling links) or delete the wrong path.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import * as fs from 'fs';
|
|
13
|
+
import * as os from 'os';
|
|
14
|
+
import * as path from 'path';
|
|
15
|
+
import {
|
|
16
|
+
contributionLinks,
|
|
17
|
+
linkStatus,
|
|
18
|
+
listInstalledPacks,
|
|
19
|
+
unwirePack,
|
|
20
|
+
packagesDir,
|
|
21
|
+
type WiredLink,
|
|
22
|
+
} from './pack-contributions.js';
|
|
23
|
+
import type { PackContributeKey } from '../types.js';
|
|
24
|
+
|
|
25
|
+
// The canonical mapping, encoded once here as the test oracle. Host paths are
|
|
26
|
+
// relative to hqRoot; src paths relative to the pack dir.
|
|
27
|
+
const EXPECTED: Record<PackContributeKey, { src: string; dst: string }> = {
|
|
28
|
+
workers: { src: 'workers/X', dst: 'core/workers/public/X' },
|
|
29
|
+
knowledge: { src: 'knowledge/X', dst: 'core/knowledge/public/X' },
|
|
30
|
+
skills: { src: 'skills/X', dst: '.claude/skills/X' },
|
|
31
|
+
commands: { src: 'commands/X.md', dst: '.claude/commands/X.md' },
|
|
32
|
+
hooks: { src: 'hooks/X.sh', dst: '.claude/hooks/X.sh' },
|
|
33
|
+
policies: { src: 'policies/X.md', dst: 'core/policies/X.md' },
|
|
34
|
+
scripts: { src: 'scripts/X', dst: 'core/scripts/X' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function mkTmp(prefix: string): string {
|
|
38
|
+
// realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
|
|
39
|
+
return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('contributionLinks: mapping', () => {
|
|
43
|
+
it('maps every contributes key to the canonical src/dst', () => {
|
|
44
|
+
const hqRoot = '/hq';
|
|
45
|
+
const packDir = '/hq/core/packages/hq-pack-x';
|
|
46
|
+
for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
|
|
47
|
+
const [link] = contributionLinks(hqRoot, packDir, { [key]: ['X'] });
|
|
48
|
+
expect(link.key).toBe(key);
|
|
49
|
+
expect(link.src).toBe(path.join(packDir, EXPECTED[key].src));
|
|
50
|
+
expect(link.dst).toBe(path.join(hqRoot, EXPECTED[key].dst));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('ignores empty / non-array contributes subfields', () => {
|
|
55
|
+
const links = contributionLinks('/hq', '/p', {
|
|
56
|
+
skills: [],
|
|
57
|
+
workers: ['a', 'b'],
|
|
58
|
+
// @ts-expect-error — defensive: non-array should be skipped
|
|
59
|
+
hooks: 'nope',
|
|
60
|
+
});
|
|
61
|
+
expect(links.map((l) => l.item)).toEqual(['a', 'b']);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe('contributionLinks: parity with scan-packages.sh', () => {
|
|
66
|
+
// Best-effort drift guard: if the bash wirer is reachable, confirm each
|
|
67
|
+
// case arm's dst suffix matches our TS mapping. Skips cleanly off-tree.
|
|
68
|
+
const candidates = [
|
|
69
|
+
path.resolve(process.cwd(), '../../../core/scripts/scan-packages.sh'),
|
|
70
|
+
path.resolve(process.cwd(), '../../../../core/scripts/scan-packages.sh'),
|
|
71
|
+
];
|
|
72
|
+
const scanPath = candidates.find((p) => fs.existsSync(p));
|
|
73
|
+
|
|
74
|
+
(scanPath ? it : it.skip)('dst suffixes match the bash case arms', () => {
|
|
75
|
+
const bash = fs.readFileSync(scanPath as string, 'utf-8');
|
|
76
|
+
for (const key of Object.keys(EXPECTED) as PackContributeKey[]) {
|
|
77
|
+
// dst host-path suffix, with the trailing /$item or /$item.md stripped.
|
|
78
|
+
const suffix = EXPECTED[key].dst.replace(/\/X(\.\w+)?$/, '');
|
|
79
|
+
expect(bash).toContain(suffix); // e.g. core/workers/public, .claude/skills
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('linkStatus + unwirePack', () => {
|
|
85
|
+
let hqRoot: string;
|
|
86
|
+
let packDir: string;
|
|
87
|
+
|
|
88
|
+
beforeEach(() => {
|
|
89
|
+
hqRoot = mkTmp('hq-pc-root-');
|
|
90
|
+
packDir = path.join(packagesDir(hqRoot), 'hq-pack-demo');
|
|
91
|
+
// A pack that contributes one skill and one command.
|
|
92
|
+
fs.mkdirSync(path.join(packDir, 'skills', 'demo-skill'), { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(packDir, 'skills', 'demo-skill', 'SKILL.md'), '# demo');
|
|
94
|
+
fs.mkdirSync(path.join(packDir, 'commands'), { recursive: true });
|
|
95
|
+
fs.writeFileSync(path.join(packDir, 'commands', 'demo-cmd.md'), '# cmd');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const contributes = { skills: ['demo-skill'], commands: ['demo-cmd'] };
|
|
103
|
+
|
|
104
|
+
function wire(link: WiredLink): void {
|
|
105
|
+
fs.mkdirSync(path.dirname(link.dst), { recursive: true });
|
|
106
|
+
fs.symlinkSync(link.src, link.dst);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
it('reports missing before wiring, live after wiring', () => {
|
|
110
|
+
const links = contributionLinks(hqRoot, packDir, contributes);
|
|
111
|
+
for (const l of links) expect(linkStatus(l)).toBe('missing');
|
|
112
|
+
for (const l of links) wire(l);
|
|
113
|
+
for (const l of links) expect(linkStatus(l)).toBe('live');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('reports broken when the pack payload is gone', () => {
|
|
117
|
+
const links = contributionLinks(hqRoot, packDir, contributes);
|
|
118
|
+
links.forEach(wire);
|
|
119
|
+
fs.rmSync(packDir, { recursive: true, force: true });
|
|
120
|
+
for (const l of links) expect(linkStatus(l)).toBe('broken');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('reports foreign when the dst points elsewhere or is a real file', () => {
|
|
124
|
+
const [skillLink, cmdLink] = contributionLinks(hqRoot, packDir, contributes);
|
|
125
|
+
// foreign symlink: points at some other path
|
|
126
|
+
fs.mkdirSync(path.dirname(skillLink.dst), { recursive: true });
|
|
127
|
+
const other = path.join(hqRoot, 'somewhere-else');
|
|
128
|
+
fs.mkdirSync(other, { recursive: true });
|
|
129
|
+
fs.symlinkSync(other, skillLink.dst);
|
|
130
|
+
expect(linkStatus(skillLink)).toBe('foreign');
|
|
131
|
+
// foreign real file
|
|
132
|
+
fs.mkdirSync(path.dirname(cmdLink.dst), { recursive: true });
|
|
133
|
+
fs.writeFileSync(cmdLink.dst, 'host-owned content');
|
|
134
|
+
expect(linkStatus(cmdLink)).toBe('foreign');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('unwirePack removes ONLY our symlinks, leaving foreign ones', () => {
|
|
138
|
+
const [skillLink, cmdLink] = contributionLinks(hqRoot, packDir, contributes);
|
|
139
|
+
wire(skillLink); // our link
|
|
140
|
+
// cmd slot occupied by a foreign symlink (another pack / host content)
|
|
141
|
+
fs.mkdirSync(path.dirname(cmdLink.dst), { recursive: true });
|
|
142
|
+
const other = path.join(hqRoot, 'foreign-target.md');
|
|
143
|
+
fs.writeFileSync(other, 'x');
|
|
144
|
+
fs.symlinkSync(other, cmdLink.dst);
|
|
145
|
+
|
|
146
|
+
const result = unwirePack(hqRoot, packDir, contributes);
|
|
147
|
+
|
|
148
|
+
expect(fs.existsSync(skillLink.dst)).toBe(false); // ours removed
|
|
149
|
+
expect(fs.lstatSync(cmdLink.dst).isSymbolicLink()).toBe(true); // foreign kept
|
|
150
|
+
expect(result.unlinked.map((u) => u.item)).toEqual(['demo-skill']);
|
|
151
|
+
expect(result.skipped.map((s) => s.item)).toEqual(['demo-cmd']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('unwirePack removes broken (dangling) links that point into our pack', () => {
|
|
155
|
+
const links = contributionLinks(hqRoot, packDir, contributes);
|
|
156
|
+
links.forEach(wire);
|
|
157
|
+
fs.rmSync(packDir, { recursive: true, force: true }); // now all broken
|
|
158
|
+
const result = unwirePack(hqRoot, packDir, contributes);
|
|
159
|
+
expect(result.unlinked).toHaveLength(2);
|
|
160
|
+
for (const l of links) expect(fs.existsSync(l.dst)).toBe(false);
|
|
161
|
+
for (const l of links) expect(() => fs.lstatSync(l.dst)).toThrow(); // gone
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('listInstalledPacks', () => {
|
|
166
|
+
let hqRoot: string;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
hqRoot = mkTmp('hq-pc-list-');
|
|
170
|
+
});
|
|
171
|
+
afterEach(() => {
|
|
172
|
+
fs.rmSync(hqRoot, { recursive: true, force: true });
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('returns [] when core/packages is absent', () => {
|
|
176
|
+
expect(listInstalledPacks(hqRoot)).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('walks packs, skips README + .archive, and parses the stamped source', () => {
|
|
180
|
+
const dir = packagesDir(hqRoot);
|
|
181
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
182
|
+
fs.writeFileSync(path.join(dir, 'README.md'), '# packages');
|
|
183
|
+
fs.mkdirSync(path.join(dir, '.archive', 'old-pack-2026'), { recursive: true });
|
|
184
|
+
|
|
185
|
+
const p = path.join(dir, 'hq-pack-one');
|
|
186
|
+
fs.mkdirSync(p, { recursive: true });
|
|
187
|
+
fs.writeFileSync(
|
|
188
|
+
path.join(p, 'package.yaml'),
|
|
189
|
+
'source: "github:acme/repo#packages/hq-pack-one"\nname: hq-pack-one\nversion: 2.1.0\ncontributes:\n skills:\n - foo\n',
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const packs = listInstalledPacks(hqRoot);
|
|
193
|
+
expect(packs.map((x) => x.name)).toEqual(['hq-pack-one']);
|
|
194
|
+
expect(packs[0].manifest?.version).toBe('2.1.0');
|
|
195
|
+
expect(packs[0].manifest?.source).toBe('github:acme/repo#packages/hq-pack-one');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('surfaces an unreadable manifest as error, not a throw', () => {
|
|
199
|
+
const dir = packagesDir(hqRoot);
|
|
200
|
+
const p = path.join(dir, 'hq-pack-bad');
|
|
201
|
+
fs.mkdirSync(p, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(p, 'package.yaml'), 'name: [unclosed\n');
|
|
203
|
+
const packs = listInstalledPacks(hqRoot);
|
|
204
|
+
expect(packs).toHaveLength(1);
|
|
205
|
+
expect(packs[0].manifest).toBeNull();
|
|
206
|
+
expect(packs[0].error).toMatch(/invalid/i);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-pack contribution helpers -- the single source of truth (in TS) for
|
|
3
|
+
* the `contributes.* -> host-path` symlink mapping that `hq install` wires via
|
|
4
|
+
* `core/scripts/scan-packages.sh`.
|
|
5
|
+
*
|
|
6
|
+
* `pack-install.ts` only INSTALLS content packs (into `core/packages/<name>/`,
|
|
7
|
+
* tracked by filesystem presence -- there is no registry file). The list /
|
|
8
|
+
* update / uninstall lifecycle in `commands/packs.ts` needs to reason about the
|
|
9
|
+
* SAME mapping so it can report link health and cleanly un-wire a pack without
|
|
10
|
+
* leaving dangling symlinks. That mapping is duplicated today in two places:
|
|
11
|
+
*
|
|
12
|
+
* - core/scripts/scan-packages.sh (bash `case`, the wiring authority)
|
|
13
|
+
* - pack-install.ts validateManifest's `subpaths` record (payload validation)
|
|
14
|
+
*
|
|
15
|
+
* This module re-encodes it once for TS callers. A parity test
|
|
16
|
+
* (`pack-contributions.test.ts`) asserts it matches scan-packages.sh's `case`
|
|
17
|
+
* arms so the three copies cannot drift.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import * as yaml from 'js-yaml';
|
|
23
|
+
import type { PackManifest, PackContributeKey } from '../types.js';
|
|
24
|
+
|
|
25
|
+
/** A single symlink a pack contributes: dst (host path) -> src (inside pack). */
|
|
26
|
+
export interface WiredLink {
|
|
27
|
+
key: PackContributeKey;
|
|
28
|
+
item: string;
|
|
29
|
+
src: string; // absolute path inside core/packages/<pack>/
|
|
30
|
+
dst: string; // absolute host path the symlink lives at
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type LinkStatus =
|
|
34
|
+
| 'live' // dst is a symlink -> this pack's src, and src exists
|
|
35
|
+
| 'broken' // dst is a symlink -> this pack's src, but src is gone
|
|
36
|
+
| 'missing' // dst does not exist (declared but never wired / already removed)
|
|
37
|
+
| 'foreign'; // dst exists but points elsewhere or is a real file (collision)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Map one contributes entry to its source/host paths. MUST stay in lockstep
|
|
41
|
+
* with scan-packages.sh:wire_one_package and pack-install.ts:validateManifest.
|
|
42
|
+
*/
|
|
43
|
+
function linkFor(
|
|
44
|
+
hqRoot: string,
|
|
45
|
+
packDir: string,
|
|
46
|
+
key: PackContributeKey,
|
|
47
|
+
item: string,
|
|
48
|
+
): WiredLink {
|
|
49
|
+
let srcRel: string;
|
|
50
|
+
let dstRel: string;
|
|
51
|
+
switch (key) {
|
|
52
|
+
case 'workers':
|
|
53
|
+
srcRel = path.join('workers', item);
|
|
54
|
+
dstRel = path.join('core', 'workers', 'public', item);
|
|
55
|
+
break;
|
|
56
|
+
case 'knowledge':
|
|
57
|
+
srcRel = path.join('knowledge', item);
|
|
58
|
+
dstRel = path.join('core', 'knowledge', 'public', item);
|
|
59
|
+
break;
|
|
60
|
+
case 'skills':
|
|
61
|
+
srcRel = path.join('skills', item);
|
|
62
|
+
dstRel = path.join('.claude', 'skills', item);
|
|
63
|
+
break;
|
|
64
|
+
case 'commands':
|
|
65
|
+
srcRel = path.join('commands', `${item}.md`);
|
|
66
|
+
dstRel = path.join('.claude', 'commands', `${item}.md`);
|
|
67
|
+
break;
|
|
68
|
+
case 'hooks':
|
|
69
|
+
srcRel = path.join('hooks', `${item}.sh`);
|
|
70
|
+
dstRel = path.join('.claude', 'hooks', `${item}.sh`);
|
|
71
|
+
break;
|
|
72
|
+
case 'policies':
|
|
73
|
+
srcRel = path.join('policies', `${item}.md`);
|
|
74
|
+
dstRel = path.join('core', 'policies', `${item}.md`);
|
|
75
|
+
break;
|
|
76
|
+
case 'scripts':
|
|
77
|
+
srcRel = path.join('scripts', item);
|
|
78
|
+
dstRel = path.join('core', 'scripts', item);
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
key,
|
|
83
|
+
item,
|
|
84
|
+
src: path.join(packDir, srcRel),
|
|
85
|
+
dst: path.join(hqRoot, dstRel),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Every symlink a pack's `contributes` block declares. Empty subfields and
|
|
91
|
+
* non-array values are ignored, mirroring scan-packages.sh.
|
|
92
|
+
*/
|
|
93
|
+
export function contributionLinks(
|
|
94
|
+
hqRoot: string,
|
|
95
|
+
packDir: string,
|
|
96
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
97
|
+
): WiredLink[] {
|
|
98
|
+
const links: WiredLink[] = [];
|
|
99
|
+
for (const [key, items] of Object.entries(contributes) as [
|
|
100
|
+
PackContributeKey,
|
|
101
|
+
unknown,
|
|
102
|
+
][]) {
|
|
103
|
+
if (!Array.isArray(items)) continue;
|
|
104
|
+
for (const item of items) {
|
|
105
|
+
if (typeof item !== 'string' || item.length === 0) continue;
|
|
106
|
+
links.push(linkFor(hqRoot, packDir, key, item));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return links;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Classify a host path against the link that should own it. */
|
|
113
|
+
export function linkStatus(link: WiredLink): LinkStatus {
|
|
114
|
+
let st: fs.Stats | undefined;
|
|
115
|
+
try {
|
|
116
|
+
st = fs.lstatSync(link.dst);
|
|
117
|
+
} catch {
|
|
118
|
+
return 'missing';
|
|
119
|
+
}
|
|
120
|
+
if (!st.isSymbolicLink()) {
|
|
121
|
+
return 'foreign'; // a real file/dir occupies the slot -- not ours
|
|
122
|
+
}
|
|
123
|
+
let target: string;
|
|
124
|
+
try {
|
|
125
|
+
target = fs.readlinkSync(link.dst);
|
|
126
|
+
} catch {
|
|
127
|
+
return 'foreign';
|
|
128
|
+
}
|
|
129
|
+
// scan-packages.sh writes the symlink target as the absolute `src` path, so a
|
|
130
|
+
// direct compare is correct. Resolve both to be robust to trailing slashes.
|
|
131
|
+
const resolvedTarget = path.resolve(path.dirname(link.dst), target);
|
|
132
|
+
if (path.resolve(link.src) !== resolvedTarget) {
|
|
133
|
+
return 'foreign'; // points at another pack / somewhere else
|
|
134
|
+
}
|
|
135
|
+
return fs.existsSync(link.src) ? 'live' : 'broken';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Installed-pack discovery
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
/** A content pack's manifest plus the install-time stamped source. */
|
|
143
|
+
export interface InstalledPackManifest extends PackManifest {
|
|
144
|
+
source?: string; // stamped by pack-install.ts:stampInstallSource
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface InstalledPack {
|
|
148
|
+
name: string; // directory name (== manifest.name when well-formed)
|
|
149
|
+
dir: string; // absolute path to core/packages/<name>/
|
|
150
|
+
manifest: InstalledPackManifest | null; // null when package.yaml is unreadable
|
|
151
|
+
error?: string; // parse/validation error message when manifest is null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Absolute path to `<hqRoot>/core/packages`. */
|
|
155
|
+
export function packagesDir(hqRoot: string): string {
|
|
156
|
+
return path.join(hqRoot, 'core', 'packages');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read and shallowly validate a pack's package.yaml. */
|
|
160
|
+
export function readPackManifest(packDir: string): {
|
|
161
|
+
manifest: InstalledPackManifest | null;
|
|
162
|
+
error?: string;
|
|
163
|
+
} {
|
|
164
|
+
const manifestPath = path.join(packDir, 'package.yaml');
|
|
165
|
+
if (!fs.existsSync(manifestPath)) {
|
|
166
|
+
return { manifest: null, error: 'package.yaml missing' };
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const parsed = yaml.load(fs.readFileSync(manifestPath, 'utf-8'));
|
|
170
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
171
|
+
return { manifest: null, error: 'package.yaml is not a mapping' };
|
|
172
|
+
}
|
|
173
|
+
return { manifest: parsed as InstalledPackManifest };
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { manifest: null, error: `package.yaml invalid: ${(e as Error).message}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Walk `core/packages/<name>/package.yaml`. Skips the `.archive` dir, the bundled
|
|
181
|
+
* `README.md`, and any non-directory entry. Filesystem presence is the source
|
|
182
|
+
* of truth for installed content packs.
|
|
183
|
+
*/
|
|
184
|
+
export function listInstalledPacks(hqRoot: string): InstalledPack[] {
|
|
185
|
+
const dir = packagesDir(hqRoot);
|
|
186
|
+
if (!fs.existsSync(dir)) return [];
|
|
187
|
+
const out: InstalledPack[] = [];
|
|
188
|
+
for (const name of fs.readdirSync(dir).sort()) {
|
|
189
|
+
if (name.startsWith('.')) continue; // .archive, .DS_Store, etc.
|
|
190
|
+
const packDir = path.join(dir, name);
|
|
191
|
+
let st: fs.Stats;
|
|
192
|
+
try {
|
|
193
|
+
st = fs.statSync(packDir);
|
|
194
|
+
} catch {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (!st.isDirectory()) continue; // README.md and friends
|
|
198
|
+
const { manifest, error } = readPackManifest(packDir);
|
|
199
|
+
if (!manifest && !fs.existsSync(path.join(packDir, 'package.yaml'))) {
|
|
200
|
+
continue; // a plain dir that isn't a pack -- ignore silently
|
|
201
|
+
}
|
|
202
|
+
out.push({ name, dir: packDir, manifest, error });
|
|
203
|
+
}
|
|
204
|
+
return out;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Un-wiring (the uninstall guarantee)
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
export interface UnwireResult {
|
|
212
|
+
unlinked: Array<{ key: PackContributeKey; item: string; dst: string }>;
|
|
213
|
+
skipped: Array<{
|
|
214
|
+
key: PackContributeKey;
|
|
215
|
+
item: string;
|
|
216
|
+
dst: string;
|
|
217
|
+
reason: 'foreign' | 'missing';
|
|
218
|
+
}>;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Remove only the host symlinks that resolve into THIS pack's directory
|
|
223
|
+
* (status `live` or `broken`). Foreign links and real files are left in place
|
|
224
|
+
* -- same collision philosophy as scan-packages.sh. This is what prevents an
|
|
225
|
+
* uninstall from leaving dangling symlinks behind.
|
|
226
|
+
*/
|
|
227
|
+
export function unwirePack(
|
|
228
|
+
hqRoot: string,
|
|
229
|
+
packDir: string,
|
|
230
|
+
contributes: Partial<Record<PackContributeKey, string[]>>,
|
|
231
|
+
): UnwireResult {
|
|
232
|
+
const result: UnwireResult = { unlinked: [], skipped: [] };
|
|
233
|
+
for (const link of contributionLinks(hqRoot, packDir, contributes)) {
|
|
234
|
+
const status = linkStatus(link);
|
|
235
|
+
if (status === 'live' || status === 'broken') {
|
|
236
|
+
try {
|
|
237
|
+
fs.unlinkSync(link.dst);
|
|
238
|
+
result.unlinked.push({ key: link.key, item: link.item, dst: link.dst });
|
|
239
|
+
} catch {
|
|
240
|
+
result.skipped.push({
|
|
241
|
+
key: link.key,
|
|
242
|
+
item: link.item,
|
|
243
|
+
dst: link.dst,
|
|
244
|
+
reason: 'foreign',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
result.skipped.push({
|
|
249
|
+
key: link.key,
|
|
250
|
+
item: link.item,
|
|
251
|
+
dst: link.dst,
|
|
252
|
+
reason: status === 'missing' ? 'missing' : 'foreign',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Host introspection: hqVersion + recommended_packages catalog
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
/** Locate core.yaml -- v14+ nests it under `core/`, older layouts at the root. */
|
|
264
|
+
function coreYamlPath(hqRoot: string): string | null {
|
|
265
|
+
const nested = path.join(hqRoot, 'core', 'core.yaml');
|
|
266
|
+
if (fs.existsSync(nested)) return nested;
|
|
267
|
+
const flat = path.join(hqRoot, 'core.yaml');
|
|
268
|
+
if (fs.existsSync(flat)) return flat;
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function readHqVersion(hqRoot: string): string | null {
|
|
273
|
+
const p = coreYamlPath(hqRoot);
|
|
274
|
+
if (!p) return null;
|
|
275
|
+
try {
|
|
276
|
+
const c = yaml.load(fs.readFileSync(p, 'utf-8')) as { hqVersion?: string };
|
|
277
|
+
return c?.hqVersion ?? null;
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface CatalogEntry {
|
|
284
|
+
source: string;
|
|
285
|
+
description?: string;
|
|
286
|
+
conditional?: string;
|
|
287
|
+
auto_install?: boolean;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Read `recommended_packages` from core.yaml (the curated content-pack catalog). */
|
|
291
|
+
export function readRecommendedPackages(hqRoot: string): CatalogEntry[] {
|
|
292
|
+
const p = coreYamlPath(hqRoot);
|
|
293
|
+
if (!p) return [];
|
|
294
|
+
try {
|
|
295
|
+
const c = yaml.load(fs.readFileSync(p, 'utf-8')) as {
|
|
296
|
+
recommended_packages?: CatalogEntry[];
|
|
297
|
+
};
|
|
298
|
+
const list = c?.recommended_packages;
|
|
299
|
+
return Array.isArray(list) ? list : [];
|
|
300
|
+
} catch {
|
|
301
|
+
return [];
|
|
302
|
+
}
|
|
303
|
+
}
|