@jamaynor/hal-config 1.0.2 → 1.1.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.
- package/index.d.ts +4 -0
- package/lib/config.d.ts +111 -0
- package/lib/config.js +257 -18
- package/lib/types.d.ts +218 -0
- package/package.json +23 -4
- package/security/index.d.ts +33 -0
- package/test-utils.d.ts +30 -0
- package/CLAUDE.md +0 -84
- package/publish.ps1 +0 -30
- package/test/config-io.test.js +0 -326
- package/test/security.test.js +0 -488
- package/test/test-utils.test.js +0 -360
- package/test/test.js +0 -586
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export interface ValidateResult {
|
|
2
|
+
allowed: boolean;
|
|
3
|
+
reason?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SanitizeResult {
|
|
7
|
+
cleaned: string;
|
|
8
|
+
stats: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface SecurityApi {
|
|
12
|
+
sanitize: {
|
|
13
|
+
text(text: string, opts?: Record<string, unknown>): SanitizeResult;
|
|
14
|
+
};
|
|
15
|
+
redact: {
|
|
16
|
+
secrets(text: string): string;
|
|
17
|
+
pii(text: string, config: Record<string, unknown>): string;
|
|
18
|
+
all(text: string, config: Record<string, unknown>): string;
|
|
19
|
+
};
|
|
20
|
+
validate: {
|
|
21
|
+
path(filePath: string, allowedDirs: string[]): ValidateResult;
|
|
22
|
+
url(url: string, resolver?: unknown): Promise<ValidateResult>;
|
|
23
|
+
filename(name: string): ValidateResult;
|
|
24
|
+
};
|
|
25
|
+
governor: {
|
|
26
|
+
create(opts?: Record<string, unknown>): unknown;
|
|
27
|
+
GovernorError: new (...args: unknown[]) => Error;
|
|
28
|
+
DEFAULTS: Record<string, unknown>;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare const security: SecurityApi;
|
|
33
|
+
export default security;
|
package/test-utils.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export declare function makeTmpDir(prefix?: string): string;
|
|
2
|
+
export declare function makeTmpDirAsync(prefix?: string): Promise<string>;
|
|
3
|
+
export declare function cleanup(dir: string): void;
|
|
4
|
+
export declare function cleanupAsync(dir: string): Promise<void>;
|
|
5
|
+
|
|
6
|
+
export declare function scaffold(
|
|
7
|
+
baseDir: string,
|
|
8
|
+
opts?: {
|
|
9
|
+
dirs?: string[];
|
|
10
|
+
files?: Record<string, string | object>;
|
|
11
|
+
}
|
|
12
|
+
): void;
|
|
13
|
+
|
|
14
|
+
export declare function captureLog(): { lines: string[]; restore: () => void };
|
|
15
|
+
export declare function captureStderr(): { lines: string[]; restore: () => void };
|
|
16
|
+
export declare function captureWarn(): { lines: string[]; restore: () => void };
|
|
17
|
+
export declare function captureOutput(): {
|
|
18
|
+
stdout: string[];
|
|
19
|
+
stderr: string[];
|
|
20
|
+
raw: unknown[];
|
|
21
|
+
restore: () => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export declare function silent<T>(fn: () => T): T;
|
|
25
|
+
export declare function withCapturedLog<T>(fn: () => T): string[] | Promise<string[]>;
|
|
26
|
+
export declare function writeJson(filePath: string, data: unknown): void;
|
|
27
|
+
export declare function mockProcessExit(): {
|
|
28
|
+
readonly code: number | undefined;
|
|
29
|
+
restore: () => void;
|
|
30
|
+
};
|
package/CLAUDE.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# CLAUDE.md — @jamaynor/hal-config
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
Shared configuration loader for HAL OpenClaw skills. Reads `hal-system-config.json` once, caches for the process lifetime, and provides accessors for shared data and skill discovery.
|
|
6
|
-
|
|
7
|
-
Zero runtime dependencies. ESM (ES modules). Node >= 22.
|
|
8
|
-
|
|
9
|
-
## API
|
|
10
|
-
|
|
11
|
-
```js
|
|
12
|
-
import halConfig from '@jamaynor/hal-config';
|
|
13
|
-
|
|
14
|
-
// Loading
|
|
15
|
-
halConfig.load(configDir?) // Read hal-system-config.json (defaults to HAL_SYSTEM_CONFIG env)
|
|
16
|
-
halConfig.reload() // Bust cache and re-read
|
|
17
|
-
halConfig.raw() // Full config object
|
|
18
|
-
halConfig.configDir() // Resolved config directory path
|
|
19
|
-
|
|
20
|
-
// Shared data
|
|
21
|
-
halConfig.vaults() // hal-obsidian-vaults array (raw)
|
|
22
|
-
halConfig.accounts(provider?) // hal-communication-accounts, optionally filtered
|
|
23
|
-
halConfig.timezone() // string or null
|
|
24
|
-
halConfig.workHours() // { start, end } or null
|
|
25
|
-
|
|
26
|
-
// Skill discovery
|
|
27
|
-
halConfig.skill(name) // Full skill config block or null
|
|
28
|
-
halConfig.isEnabled(name) // boolean — exists and enabled !== false
|
|
29
|
-
halConfig.isInstalled(name) // boolean — enabled + binary on PATH (sync)
|
|
30
|
-
halConfig.isInstalledAsync(name) // Promise<boolean> — async version
|
|
31
|
-
halConfig.skillWorkspace(name) // runtime.workspace or null
|
|
32
|
-
halConfig.skillBinaries(name) // string[] of binary names from install metadata
|
|
33
|
-
halConfig.homeAgent(name) // homeAgent string or null (null = primary agent)
|
|
34
|
-
|
|
35
|
-
// Deterministic locations (no searching)
|
|
36
|
-
halConfig.openclawSharedWorkspaceRoot() // /data/openclaw/workspace
|
|
37
|
-
halConfig.openclawConfigFilePath() // /data/openclaw/openclaw.json
|
|
38
|
-
halConfig.halSystemConfigDir() // /data/openclaw/hal (or HAL_SYSTEM_CONFIG)
|
|
39
|
-
halConfig.halSystemConfigFilePath() // {halSystemConfigDir}/hal-system-config.json
|
|
40
|
-
halConfig.halSkillAdminRoot(skillName) // /data/openclaw/hal/{skillName} (or HAL_SKILL_ADMIN_ROOT)
|
|
41
|
-
halConfig.halSkillConfigDir(skillName) // /data/openclaw/hal/{skillName}/config
|
|
42
|
-
halConfig.skillConfigPath(skillName) // /data/openclaw/hal/{skillName}/config/{skillName}.json
|
|
43
|
-
halConfig.skillConfigDir(skillName) // /data/openclaw/hal/{skillName}/config
|
|
44
|
-
halConfig.halSkillLogDir(skillName) // /data/openclaw/hal/{skillName}/log
|
|
45
|
-
|
|
46
|
-
// Per-skill config files (read/write)
|
|
47
|
-
halConfig.halSkillConfig.read(skillNameOrScope) // {} if missing; throws on invalid JSON
|
|
48
|
-
halConfig.halSkillConfig.getSetting(skillNameOrScope, key) // returns value or undefined
|
|
49
|
-
halConfig.halSkillConfig.write(skillNameOrScope, obj) // overwrites entire {skill}.json (atomic)
|
|
50
|
-
halConfig.halSkillConfig.merge(skillNameOrScope, patchObj) // deep merge (objects merge, arrays replace) + write (atomic)
|
|
51
|
-
halConfig.halSkillConfig.setSetting(skillNameOrScope, key, value) // merge single key + write (atomic)
|
|
52
|
-
|
|
53
|
-
// Registration (for init wizards to write back)
|
|
54
|
-
halConfig.register(name, data) // Merge into skills[name].runtime
|
|
55
|
-
halConfig.writeAccounts(accounts) // Merge-by-label upsert into hal-communication-accounts
|
|
56
|
-
halConfig.writeSkillConfig(name, data) // Merge into skills[name]
|
|
57
|
-
halConfig.writeSharedSettings(settings) // Write top-level keys (hal-timezone, etc.)
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Config Location
|
|
61
|
-
|
|
62
|
-
Resolved in order: `configDir` argument > `HAL_SYSTEM_CONFIG` env > `/data/openclaw/hal`
|
|
63
|
-
|
|
64
|
-
File: `hal-system-config.json` (v2.0)
|
|
65
|
-
|
|
66
|
-
## Error Handling
|
|
67
|
-
|
|
68
|
-
- Missing file → returns `{}` (graceful). Skills that require the file (email-triage) enforce this at their wrapper level.
|
|
69
|
-
- Corrupt JSON → throws `SyntaxError`
|
|
70
|
-
- Auto-loads on first accessor call if `load()` not called explicitly
|
|
71
|
-
|
|
72
|
-
## Testing
|
|
73
|
-
|
|
74
|
-
```bash
|
|
75
|
-
npm test
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
147 tests using `node:test`. Tests use temp directories with synthetic configs — no real system-config needed.
|
|
79
|
-
|
|
80
|
-
## How Skills Use It
|
|
81
|
-
|
|
82
|
-
Each skill adds `"hal-config": "file:../hal-config"` to its `package.json` dependencies. When `npm install -g` runs inside the container, npm resolves the `file:` path relative to the skill's source directory (`/repos/jamaynor/<skill>/`).
|
|
83
|
-
|
|
84
|
-
Skills keep thin wrappers in `lib/config/system-config.js` that delegate to this package while preserving their existing function signatures.
|
package/publish.ps1
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
$ErrorActionPreference = 'Stop'
|
|
2
|
-
|
|
3
|
-
Write-Host "Publishing @jamaynor/hal-config from this directory..."
|
|
4
|
-
|
|
5
|
-
# Prompt for token without echoing it to the console.
|
|
6
|
-
$secure = Read-Host -Prompt "Paste npm automation token (input hidden)" -AsSecureString
|
|
7
|
-
$token = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
|
8
|
-
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)
|
|
9
|
-
)
|
|
10
|
-
|
|
11
|
-
if (-not $token -or -not $token.Trim()) {
|
|
12
|
-
throw "No token provided."
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
try {
|
|
16
|
-
$env:NPM_TOKEN = $token.Trim()
|
|
17
|
-
|
|
18
|
-
Write-Host ""
|
|
19
|
-
Write-Host "npm whoami:"
|
|
20
|
-
npm whoami
|
|
21
|
-
|
|
22
|
-
Write-Host ""
|
|
23
|
-
Write-Host "npm publish:"
|
|
24
|
-
npm publish
|
|
25
|
-
} finally {
|
|
26
|
-
Remove-Item Env:NPM_TOKEN -ErrorAction SilentlyContinue
|
|
27
|
-
# Reduce lifetime of plaintext token in memory.
|
|
28
|
-
$token = $null
|
|
29
|
-
}
|
|
30
|
-
|
package/test/config-io.test.js
DELETED
|
@@ -1,326 +0,0 @@
|
|
|
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
|
-
});
|