@jamaynor/hal-config 1.0.1

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.
@@ -0,0 +1,326 @@
1
+ /**
2
+ * config-io.test
3
+ *
4
+ * Responsibility: Tests for resolveWorkspace, loadSkillConfig, and
5
+ * saveSkillConfig added in M-2.5/2.6.
6
+ */
7
+
8
+ import { describe, it, beforeEach, afterEach } from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname } from 'node:path';
14
+
15
+ import { makeTmpDir, writeJson, cleanup } from '../test-utils.js';
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+
19
+ // Fresh dynamic import for each test to reset module-level cache
20
+ async function freshImport() {
21
+ const modPath = path.resolve(__dirname, '../lib/config.js');
22
+ const url = `file:///${modPath.replace(/\\/g, '/')}?t=${Date.now()}`;
23
+ return import(url);
24
+ }
25
+
26
+ // ============================================================================
27
+ // resolveWorkspace
28
+ // ============================================================================
29
+
30
+ describe('resolveWorkspace()', () => {
31
+ let hal;
32
+
33
+ beforeEach(async () => {
34
+ hal = await freshImport();
35
+ });
36
+
37
+ it('returns explicit opts.workspace when provided', () => {
38
+ const result = hal.resolveWorkspace({ workspace: '/explicit/path' });
39
+ assert.equal(result, path.resolve('/explicit/path'));
40
+ });
41
+
42
+ it('explicit opts.workspace takes precedence over all env vars', () => {
43
+ const origSkill = process.env.HAL_TEST_SKILL_MASTER_WORKSPACE;
44
+ const origAgent = process.env.HAL_AGENT_WORKSPACE;
45
+ process.env.HAL_TEST_SKILL_MASTER_WORKSPACE = '/skill/env/path';
46
+ process.env.HAL_AGENT_WORKSPACE = '/agent/env/path';
47
+ try {
48
+ const result = hal.resolveWorkspace({
49
+ workspace: '/explicit/wins',
50
+ envPrefix: 'TEST_SKILL',
51
+ fallback: '/fallback/path',
52
+ });
53
+ assert.equal(result, path.resolve('/explicit/wins'));
54
+ } finally {
55
+ if (origSkill === undefined) delete process.env.HAL_TEST_SKILL_MASTER_WORKSPACE;
56
+ else process.env.HAL_TEST_SKILL_MASTER_WORKSPACE = origSkill;
57
+ if (origAgent === undefined) delete process.env.HAL_AGENT_WORKSPACE;
58
+ else process.env.HAL_AGENT_WORKSPACE = origAgent;
59
+ }
60
+ });
61
+
62
+ it('reads HAL_{envPrefix}_MASTER_WORKSPACE when envPrefix is set', () => {
63
+ const key = 'HAL_TEST_SKILL_MASTER_WORKSPACE';
64
+ const orig = process.env[key];
65
+ process.env[key] = '/from/skill/env';
66
+ try {
67
+ const result = hal.resolveWorkspace({ envPrefix: 'TEST_SKILL' });
68
+ assert.equal(result, path.resolve('/from/skill/env'));
69
+ } finally {
70
+ if (orig === undefined) delete process.env[key];
71
+ else process.env[key] = orig;
72
+ }
73
+ });
74
+
75
+ it('falls through to HAL_AGENT_WORKSPACE when skill-specific env var is unset', () => {
76
+ const skillKey = 'HAL_PLAN_DAY_MASTER_WORKSPACE';
77
+ const agentKey = 'HAL_AGENT_WORKSPACE';
78
+ const origSkill = process.env[skillKey];
79
+ const origAgent = process.env[agentKey];
80
+ delete process.env[skillKey];
81
+ process.env[agentKey] = '/from/agent/env';
82
+ try {
83
+ const result = hal.resolveWorkspace({ envPrefix: 'PLAN_DAY' });
84
+ assert.equal(result, path.resolve('/from/agent/env'));
85
+ } finally {
86
+ if (origSkill === undefined) delete process.env[skillKey];
87
+ else process.env[skillKey] = origSkill;
88
+ if (origAgent === undefined) delete process.env[agentKey];
89
+ else process.env[agentKey] = origAgent;
90
+ }
91
+ });
92
+
93
+ it('falls through to opts.fallback when no env vars are set', () => {
94
+ const skillKey = 'HAL_TEST_SKILL_MASTER_WORKSPACE';
95
+ const agentKey = 'HAL_AGENT_WORKSPACE';
96
+ const origSkill = process.env[skillKey];
97
+ const origAgent = process.env[agentKey];
98
+ delete process.env[skillKey];
99
+ delete process.env[agentKey];
100
+ try {
101
+ const result = hal.resolveWorkspace({
102
+ envPrefix: 'TEST_SKILL',
103
+ fallback: '/my/fallback',
104
+ });
105
+ assert.equal(result, path.resolve('/my/fallback'));
106
+ } finally {
107
+ if (origSkill === undefined) delete process.env[skillKey];
108
+ else process.env[skillKey] = origSkill;
109
+ if (origAgent === undefined) delete process.env[agentKey];
110
+ else process.env[agentKey] = origAgent;
111
+ }
112
+ });
113
+
114
+ it('falls through to process.cwd() when nothing is configured', () => {
115
+ const skillKey = 'HAL_NOTHING_MASTER_WORKSPACE';
116
+ const agentKey = 'HAL_AGENT_WORKSPACE';
117
+ const origSkill = process.env[skillKey];
118
+ const origAgent = process.env[agentKey];
119
+ delete process.env[skillKey];
120
+ delete process.env[agentKey];
121
+ try {
122
+ const result = hal.resolveWorkspace({ envPrefix: 'NOTHING' });
123
+ assert.equal(result, path.resolve(process.cwd()));
124
+ } finally {
125
+ if (origSkill === undefined) delete process.env[skillKey];
126
+ else process.env[skillKey] = origSkill;
127
+ if (origAgent === undefined) delete process.env[agentKey];
128
+ else process.env[agentKey] = origAgent;
129
+ }
130
+ });
131
+
132
+ it('returns cwd when opts is empty', () => {
133
+ const agentKey = 'HAL_AGENT_WORKSPACE';
134
+ const origAgent = process.env[agentKey];
135
+ delete process.env[agentKey];
136
+ try {
137
+ const result = hal.resolveWorkspace({});
138
+ assert.equal(result, path.resolve(process.cwd()));
139
+ } finally {
140
+ if (origAgent === undefined) delete process.env[agentKey];
141
+ else process.env[agentKey] = origAgent;
142
+ }
143
+ });
144
+
145
+ it('returns cwd when opts is omitted', () => {
146
+ const agentKey = 'HAL_AGENT_WORKSPACE';
147
+ const origAgent = process.env[agentKey];
148
+ delete process.env[agentKey];
149
+ try {
150
+ const result = hal.resolveWorkspace();
151
+ assert.equal(result, path.resolve(process.cwd()));
152
+ } finally {
153
+ if (origAgent === undefined) delete process.env[agentKey];
154
+ else process.env[agentKey] = origAgent;
155
+ }
156
+ });
157
+
158
+ it('all returned paths are absolute', () => {
159
+ const result = hal.resolveWorkspace({ workspace: 'relative/path' });
160
+ assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`);
161
+ });
162
+
163
+ it('env var path is resolved to absolute', () => {
164
+ const key = 'HAL_AGENT_WORKSPACE';
165
+ const orig = process.env[key];
166
+ process.env[key] = 'relative/agent/path';
167
+ try {
168
+ const result = hal.resolveWorkspace({});
169
+ assert.ok(path.isAbsolute(result), `Expected absolute path, got: ${result}`);
170
+ } finally {
171
+ if (orig === undefined) delete process.env[key];
172
+ else process.env[key] = orig;
173
+ }
174
+ });
175
+ });
176
+
177
+ // ============================================================================
178
+ // loadSkillConfig
179
+ // ============================================================================
180
+
181
+ describe('loadSkillConfig()', () => {
182
+ let hal;
183
+ let tmpDir;
184
+
185
+ beforeEach(async () => {
186
+ hal = await freshImport();
187
+ tmpDir = makeTmpDir('hsc-load-');
188
+ });
189
+
190
+ afterEach(() => {
191
+ cleanup(tmpDir);
192
+ });
193
+
194
+ it('returns {} for a missing config file', () => {
195
+ const result = hal.loadSkillConfig('no-such-skill', tmpDir);
196
+ assert.deepStrictEqual(result, {});
197
+ });
198
+
199
+ it('returns parsed JSON for an existing config file', () => {
200
+ const configPath = path.join(tmpDir, 'hal', 'config', 'my-skill.json');
201
+ writeJson(configPath, { version: '1.0', key: 'value' });
202
+ const result = hal.loadSkillConfig('my-skill', tmpDir);
203
+ assert.equal(result.version, '1.0');
204
+ assert.equal(result.key, 'value');
205
+ });
206
+
207
+ it('reads from {workspace}/hal/config/{skillName}.json', () => {
208
+ const expected = { configured: true, count: 42 };
209
+ const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
210
+ writeJson(configPath, expected);
211
+ const result = hal.loadSkillConfig('test-skill', tmpDir);
212
+ assert.deepStrictEqual(result, expected);
213
+ });
214
+
215
+ it('throws SyntaxError on corrupt JSON', () => {
216
+ const configDir = path.join(tmpDir, 'hal', 'config');
217
+ fs.mkdirSync(configDir, { recursive: true });
218
+ fs.writeFileSync(path.join(configDir, 'bad-skill.json'), '{not valid json', 'utf8');
219
+ assert.throws(
220
+ () => hal.loadSkillConfig('bad-skill', tmpDir),
221
+ { name: 'SyntaxError' }
222
+ );
223
+ });
224
+ });
225
+
226
+ // ============================================================================
227
+ // saveSkillConfig
228
+ // ============================================================================
229
+
230
+ describe('saveSkillConfig()', () => {
231
+ let hal;
232
+ let tmpDir;
233
+
234
+ beforeEach(async () => {
235
+ hal = await freshImport();
236
+ tmpDir = makeTmpDir('hsc-save-');
237
+ });
238
+
239
+ afterEach(() => {
240
+ cleanup(tmpDir);
241
+ });
242
+
243
+ it('creates the file and parent directories on first write', () => {
244
+ hal.saveSkillConfig('new-skill', tmpDir, { initialized: true });
245
+ const filePath = path.join(tmpDir, 'hal', 'config', 'new-skill.json');
246
+ assert.ok(fs.existsSync(filePath), 'config file must be created');
247
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
248
+ assert.equal(parsed.initialized, true);
249
+ });
250
+
251
+ it('deep merge: new top-level keys are added, existing keys preserved', () => {
252
+ const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
253
+ writeJson(configPath, { existing: 'keep-me', count: 1 });
254
+
255
+ hal.saveSkillConfig('test-skill', tmpDir, { newKey: 'added' });
256
+
257
+ const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
258
+ assert.equal(result.existing, 'keep-me');
259
+ assert.equal(result.count, 1);
260
+ assert.equal(result.newKey, 'added');
261
+ });
262
+
263
+ it('deep merge: overwritten top-level keys are replaced', () => {
264
+ const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
265
+ writeJson(configPath, { key: 'old-value', other: 'unchanged' });
266
+
267
+ hal.saveSkillConfig('test-skill', tmpDir, { key: 'new-value' });
268
+
269
+ const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
270
+ assert.equal(result.key, 'new-value');
271
+ assert.equal(result.other, 'unchanged');
272
+ });
273
+
274
+ it('deep merge: nested objects are merged recursively', () => {
275
+ const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
276
+ writeJson(configPath, {
277
+ governor: {
278
+ callers: ['agent-a', 'agent-b'],
279
+ spendWarnThreshold: 5,
280
+ },
281
+ });
282
+
283
+ hal.saveSkillConfig('test-skill', tmpDir, {
284
+ governor: { spendWarnThreshold: 10 },
285
+ });
286
+
287
+ const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
288
+ // Existing nested key preserved
289
+ assert.deepStrictEqual(result.governor.callers, ['agent-a', 'agent-b']);
290
+ // Nested key updated
291
+ assert.equal(result.governor.spendWarnThreshold, 10);
292
+ });
293
+
294
+ it('deep merge: arrays are replaced atomically', () => {
295
+ const configPath = path.join(tmpDir, 'hal', 'config', 'test-skill.json');
296
+ writeJson(configPath, { tags: ['alpha', 'beta', 'gamma'] });
297
+
298
+ hal.saveSkillConfig('test-skill', tmpDir, { tags: ['delta'] });
299
+
300
+ const result = JSON.parse(fs.readFileSync(configPath, 'utf8'));
301
+ assert.deepStrictEqual(result.tags, ['delta']);
302
+ });
303
+
304
+ it('writes valid JSON with 2-space indentation', () => {
305
+ hal.saveSkillConfig('indent-skill', tmpDir, { a: 1, b: { c: 2 } });
306
+ const filePath = path.join(tmpDir, 'hal', 'config', 'indent-skill.json');
307
+ const raw = fs.readFileSync(filePath, 'utf8');
308
+ // 2-space indent: second line should start with two spaces
309
+ const lines = raw.split('\n');
310
+ assert.ok(lines.length > 1, 'should be multi-line JSON');
311
+ assert.ok(lines[1].startsWith(' '), `second line should start with 2 spaces: "${lines[1]}"`);
312
+ });
313
+
314
+ it('written file ends with a trailing newline', () => {
315
+ hal.saveSkillConfig('newline-skill', tmpDir, { x: true });
316
+ const filePath = path.join(tmpDir, 'hal', 'config', 'newline-skill.json');
317
+ const raw = fs.readFileSync(filePath, 'utf8');
318
+ assert.ok(raw.endsWith('\n'), 'file must end with a newline character');
319
+ });
320
+
321
+ it('always writes to {workspace}/hal/config/{skillName}.json', () => {
322
+ hal.saveSkillConfig('location-check', tmpDir, { ok: true });
323
+ const expected = path.join(tmpDir, 'hal', 'config', 'location-check.json');
324
+ assert.ok(fs.existsSync(expected), `file must be at ${expected}`);
325
+ });
326
+ });