cli4ai 0.8.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/README.md +275 -0
- package/package.json +49 -0
- package/src/bin.ts +120 -0
- package/src/cli.ts +256 -0
- package/src/commands/add.ts +530 -0
- package/src/commands/browse.ts +449 -0
- package/src/commands/config.ts +126 -0
- package/src/commands/info.ts +102 -0
- package/src/commands/init.test.ts +163 -0
- package/src/commands/init.ts +560 -0
- package/src/commands/list.ts +89 -0
- package/src/commands/mcp-config.ts +59 -0
- package/src/commands/remove.ts +72 -0
- package/src/commands/routines.ts +393 -0
- package/src/commands/run.ts +45 -0
- package/src/commands/search.ts +148 -0
- package/src/commands/secrets.ts +273 -0
- package/src/commands/start.ts +40 -0
- package/src/commands/update.ts +218 -0
- package/src/core/config.test.ts +188 -0
- package/src/core/config.ts +649 -0
- package/src/core/execute.ts +507 -0
- package/src/core/link.test.ts +238 -0
- package/src/core/link.ts +190 -0
- package/src/core/lockfile.test.ts +337 -0
- package/src/core/lockfile.ts +308 -0
- package/src/core/manifest.test.ts +327 -0
- package/src/core/manifest.ts +319 -0
- package/src/core/routine-engine.test.ts +139 -0
- package/src/core/routine-engine.ts +725 -0
- package/src/core/routines.ts +111 -0
- package/src/core/secrets.test.ts +79 -0
- package/src/core/secrets.ts +430 -0
- package/src/lib/cli.ts +234 -0
- package/src/mcp/adapter.test.ts +132 -0
- package/src/mcp/adapter.ts +123 -0
- package/src/mcp/config-gen.test.ts +214 -0
- package/src/mcp/config-gen.ts +106 -0
- package/src/mcp/server.ts +363 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for lockfile.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import {
|
|
10
|
+
getLockfilePath,
|
|
11
|
+
lockfileExists,
|
|
12
|
+
loadLockfile,
|
|
13
|
+
saveLockfile,
|
|
14
|
+
lockPackage,
|
|
15
|
+
unlockPackage,
|
|
16
|
+
getLockedPackage,
|
|
17
|
+
getLockedPackages,
|
|
18
|
+
isPackageLocked,
|
|
19
|
+
clearLockfile,
|
|
20
|
+
formatLockfile,
|
|
21
|
+
LOCKFILE_NAME,
|
|
22
|
+
LOCKFILE_VERSION,
|
|
23
|
+
type Lockfile,
|
|
24
|
+
type LockedPackage
|
|
25
|
+
} from './lockfile.js';
|
|
26
|
+
|
|
27
|
+
describe('lockfile', () => {
|
|
28
|
+
let tempDir: string;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-lockfile-test-'));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('getLockfilePath', () => {
|
|
39
|
+
test('returns correct path', () => {
|
|
40
|
+
const path = getLockfilePath(tempDir);
|
|
41
|
+
expect(path).toBe(join(tempDir, LOCKFILE_NAME));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('lockfileExists', () => {
|
|
46
|
+
test('returns false when no lockfile', () => {
|
|
47
|
+
expect(lockfileExists(tempDir)).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('returns true when lockfile exists', () => {
|
|
51
|
+
writeFileSync(join(tempDir, LOCKFILE_NAME), '{}');
|
|
52
|
+
expect(lockfileExists(tempDir)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('loadLockfile', () => {
|
|
57
|
+
test('returns empty lockfile when none exists', () => {
|
|
58
|
+
const lockfile = loadLockfile(tempDir);
|
|
59
|
+
|
|
60
|
+
expect(lockfile.lockfileVersion).toBe(LOCKFILE_VERSION);
|
|
61
|
+
expect(lockfile.packages).toEqual({});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('loads existing lockfile', () => {
|
|
65
|
+
const existing: Lockfile = {
|
|
66
|
+
lockfileVersion: 1,
|
|
67
|
+
packages: {
|
|
68
|
+
github: {
|
|
69
|
+
name: 'github',
|
|
70
|
+
version: '1.0.0',
|
|
71
|
+
resolved: '/path/to/github'
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
writeFileSync(
|
|
77
|
+
join(tempDir, LOCKFILE_NAME),
|
|
78
|
+
JSON.stringify(existing)
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const lockfile = loadLockfile(tempDir);
|
|
82
|
+
expect(lockfile.packages.github.name).toBe('github');
|
|
83
|
+
expect(lockfile.packages.github.version).toBe('1.0.0');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('handles invalid JSON gracefully', () => {
|
|
87
|
+
writeFileSync(join(tempDir, LOCKFILE_NAME), 'invalid json');
|
|
88
|
+
|
|
89
|
+
const lockfile = loadLockfile(tempDir);
|
|
90
|
+
expect(lockfile.lockfileVersion).toBe(LOCKFILE_VERSION);
|
|
91
|
+
expect(lockfile.packages).toEqual({});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('saveLockfile', () => {
|
|
96
|
+
test('saves lockfile to disk', () => {
|
|
97
|
+
const lockfile: Lockfile = {
|
|
98
|
+
lockfileVersion: 1,
|
|
99
|
+
packages: {
|
|
100
|
+
tool: {
|
|
101
|
+
name: 'tool',
|
|
102
|
+
version: '2.0.0',
|
|
103
|
+
resolved: '/some/path'
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
saveLockfile(tempDir, lockfile);
|
|
109
|
+
|
|
110
|
+
const content = readFileSync(join(tempDir, LOCKFILE_NAME), 'utf-8');
|
|
111
|
+
const parsed = JSON.parse(content);
|
|
112
|
+
|
|
113
|
+
expect(parsed.lockfileVersion).toBe(1);
|
|
114
|
+
expect(parsed.packages.tool.version).toBe('2.0.0');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('formats JSON with indentation', () => {
|
|
118
|
+
const lockfile: Lockfile = {
|
|
119
|
+
lockfileVersion: 1,
|
|
120
|
+
packages: {}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
saveLockfile(tempDir, lockfile);
|
|
124
|
+
|
|
125
|
+
const content = readFileSync(join(tempDir, LOCKFILE_NAME), 'utf-8');
|
|
126
|
+
expect(content).toContain(' '); // Has indentation
|
|
127
|
+
expect(content.endsWith('\n')).toBe(true); // Trailing newline
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('lockPackage', () => {
|
|
132
|
+
test('adds new package to lockfile', () => {
|
|
133
|
+
const pkg: LockedPackage = {
|
|
134
|
+
name: 'new-tool',
|
|
135
|
+
version: '1.0.0',
|
|
136
|
+
resolved: '/path/to/tool'
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
lockPackage(tempDir, pkg);
|
|
140
|
+
|
|
141
|
+
const lockfile = loadLockfile(tempDir);
|
|
142
|
+
expect(lockfile.packages['new-tool']).toEqual(pkg);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('updates existing package', () => {
|
|
146
|
+
lockPackage(tempDir, {
|
|
147
|
+
name: 'tool',
|
|
148
|
+
version: '1.0.0',
|
|
149
|
+
resolved: '/old/path'
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
lockPackage(tempDir, {
|
|
153
|
+
name: 'tool',
|
|
154
|
+
version: '2.0.0',
|
|
155
|
+
resolved: '/new/path'
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const lockfile = loadLockfile(tempDir);
|
|
159
|
+
expect(lockfile.packages.tool.version).toBe('2.0.0');
|
|
160
|
+
expect(lockfile.packages.tool.resolved).toBe('/new/path');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('preserves other packages when adding', () => {
|
|
164
|
+
lockPackage(tempDir, { name: 'a', version: '1.0.0', resolved: '/a' });
|
|
165
|
+
lockPackage(tempDir, { name: 'b', version: '1.0.0', resolved: '/b' });
|
|
166
|
+
lockPackage(tempDir, { name: 'c', version: '1.0.0', resolved: '/c' });
|
|
167
|
+
|
|
168
|
+
const lockfile = loadLockfile(tempDir);
|
|
169
|
+
expect(Object.keys(lockfile.packages)).toEqual(['a', 'b', 'c']);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('unlockPackage', () => {
|
|
174
|
+
test('removes package from lockfile', () => {
|
|
175
|
+
lockPackage(tempDir, { name: 'tool', version: '1.0.0', resolved: '/path' });
|
|
176
|
+
unlockPackage(tempDir, 'tool');
|
|
177
|
+
|
|
178
|
+
const lockfile = loadLockfile(tempDir);
|
|
179
|
+
expect(lockfile.packages.tool).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('handles non-existent package gracefully', () => {
|
|
183
|
+
// Should not throw
|
|
184
|
+
unlockPackage(tempDir, 'nonexistent');
|
|
185
|
+
|
|
186
|
+
const lockfile = loadLockfile(tempDir);
|
|
187
|
+
expect(lockfile.packages.nonexistent).toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('preserves other packages when removing', () => {
|
|
191
|
+
lockPackage(tempDir, { name: 'a', version: '1.0.0', resolved: '/a' });
|
|
192
|
+
lockPackage(tempDir, { name: 'b', version: '1.0.0', resolved: '/b' });
|
|
193
|
+
lockPackage(tempDir, { name: 'c', version: '1.0.0', resolved: '/c' });
|
|
194
|
+
|
|
195
|
+
unlockPackage(tempDir, 'b');
|
|
196
|
+
|
|
197
|
+
const lockfile = loadLockfile(tempDir);
|
|
198
|
+
expect(Object.keys(lockfile.packages)).toEqual(['a', 'c']);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('getLockedPackage', () => {
|
|
203
|
+
test('returns package when found', () => {
|
|
204
|
+
lockPackage(tempDir, {
|
|
205
|
+
name: 'github',
|
|
206
|
+
version: '1.0.0',
|
|
207
|
+
resolved: '/path'
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const pkg = getLockedPackage(tempDir, 'github');
|
|
211
|
+
expect(pkg).not.toBeNull();
|
|
212
|
+
expect(pkg?.name).toBe('github');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('returns null when not found', () => {
|
|
216
|
+
const pkg = getLockedPackage(tempDir, 'nonexistent');
|
|
217
|
+
expect(pkg).toBeNull();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('getLockedPackages', () => {
|
|
222
|
+
test('returns empty array when no packages', () => {
|
|
223
|
+
const packages = getLockedPackages(tempDir);
|
|
224
|
+
expect(packages).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('returns all packages', () => {
|
|
228
|
+
lockPackage(tempDir, { name: 'a', version: '1.0.0', resolved: '/a' });
|
|
229
|
+
lockPackage(tempDir, { name: 'b', version: '2.0.0', resolved: '/b' });
|
|
230
|
+
|
|
231
|
+
const packages = getLockedPackages(tempDir);
|
|
232
|
+
expect(packages).toHaveLength(2);
|
|
233
|
+
expect(packages.map(p => p.name).sort()).toEqual(['a', 'b']);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('isPackageLocked', () => {
|
|
238
|
+
test('returns true when package is locked', () => {
|
|
239
|
+
lockPackage(tempDir, { name: 'tool', version: '1.0.0', resolved: '/path' });
|
|
240
|
+
expect(isPackageLocked(tempDir, 'tool')).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('returns false when package not locked', () => {
|
|
244
|
+
expect(isPackageLocked(tempDir, 'nonexistent')).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('clearLockfile', () => {
|
|
249
|
+
test('removes all packages', () => {
|
|
250
|
+
lockPackage(tempDir, { name: 'a', version: '1.0.0', resolved: '/a' });
|
|
251
|
+
lockPackage(tempDir, { name: 'b', version: '1.0.0', resolved: '/b' });
|
|
252
|
+
|
|
253
|
+
clearLockfile(tempDir);
|
|
254
|
+
|
|
255
|
+
const lockfile = loadLockfile(tempDir);
|
|
256
|
+
expect(lockfile.packages).toEqual({});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('preserves lockfile version', () => {
|
|
260
|
+
clearLockfile(tempDir);
|
|
261
|
+
|
|
262
|
+
const lockfile = loadLockfile(tempDir);
|
|
263
|
+
expect(lockfile.lockfileVersion).toBe(LOCKFILE_VERSION);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe('formatLockfile', () => {
|
|
268
|
+
test('formats empty lockfile', () => {
|
|
269
|
+
const lockfile: Lockfile = {
|
|
270
|
+
lockfileVersion: 1,
|
|
271
|
+
packages: {}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const output = formatLockfile(lockfile);
|
|
275
|
+
expect(output).toContain('# cli4ai.lock');
|
|
276
|
+
expect(output).toContain('lockfileVersion: 1');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('formats packages alphabetically', () => {
|
|
280
|
+
const lockfile: Lockfile = {
|
|
281
|
+
lockfileVersion: 1,
|
|
282
|
+
packages: {
|
|
283
|
+
zebra: { name: 'zebra', version: '1.0.0', resolved: '/z' },
|
|
284
|
+
apple: { name: 'apple', version: '2.0.0', resolved: '/a' },
|
|
285
|
+
monkey: { name: 'monkey', version: '3.0.0', resolved: '/m' }
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const output = formatLockfile(lockfile);
|
|
290
|
+
const appleIndex = output.indexOf('apple@');
|
|
291
|
+
const monkeyIndex = output.indexOf('monkey@');
|
|
292
|
+
const zebraIndex = output.indexOf('zebra@');
|
|
293
|
+
|
|
294
|
+
expect(appleIndex).toBeLessThan(monkeyIndex);
|
|
295
|
+
expect(monkeyIndex).toBeLessThan(zebraIndex);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('includes integrity when present', () => {
|
|
299
|
+
const lockfile: Lockfile = {
|
|
300
|
+
lockfileVersion: 1,
|
|
301
|
+
packages: {
|
|
302
|
+
tool: {
|
|
303
|
+
name: 'tool',
|
|
304
|
+
version: '1.0.0',
|
|
305
|
+
resolved: '/path',
|
|
306
|
+
integrity: 'sha256-abc123'
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const output = formatLockfile(lockfile);
|
|
312
|
+
expect(output).toContain('integrity: sha256-abc123');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('includes dependencies when present', () => {
|
|
316
|
+
const lockfile: Lockfile = {
|
|
317
|
+
lockfileVersion: 1,
|
|
318
|
+
packages: {
|
|
319
|
+
tool: {
|
|
320
|
+
name: 'tool',
|
|
321
|
+
version: '1.0.0',
|
|
322
|
+
resolved: '/path',
|
|
323
|
+
dependencies: {
|
|
324
|
+
'dep-a': '1.0.0',
|
|
325
|
+
'dep-b': '2.0.0'
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
const output = formatLockfile(lockfile);
|
|
332
|
+
expect(output).toContain('dependencies:');
|
|
333
|
+
expect(output).toContain('dep-a: "1.0.0"');
|
|
334
|
+
expect(output).toContain('dep-b: "2.0.0"');
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lock file management (cli4ai.lock)
|
|
3
|
+
*
|
|
4
|
+
* Tracks installed packages with exact versions and sources
|
|
5
|
+
* for reproducible installations.
|
|
6
|
+
*
|
|
7
|
+
* SECURITY: Includes SRI (Subresource Integrity) hash verification
|
|
8
|
+
* to detect tampered packages.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { resolve, join, relative } from 'path';
|
|
13
|
+
import { createHash } from 'crypto';
|
|
14
|
+
|
|
15
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
16
|
+
// TYPES
|
|
17
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
18
|
+
|
|
19
|
+
export interface LockedPackage {
|
|
20
|
+
name: string;
|
|
21
|
+
version: string;
|
|
22
|
+
resolved: string; // Source path or registry URL
|
|
23
|
+
integrity?: string; // Future: content hash
|
|
24
|
+
dependencies?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Lockfile {
|
|
28
|
+
lockfileVersion: number;
|
|
29
|
+
packages: Record<string, LockedPackage>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
33
|
+
// CONSTANTS
|
|
34
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
|
|
36
|
+
export const LOCKFILE_NAME = 'cli4ai.lock';
|
|
37
|
+
export const LOCKFILE_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
// INTEGRITY HASHING (SRI - Subresource Integrity)
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute SHA-512 hash of a file and return SRI format string
|
|
45
|
+
*/
|
|
46
|
+
export function computeFileIntegrity(filePath: string): string {
|
|
47
|
+
const content = readFileSync(filePath);
|
|
48
|
+
const hash = createHash('sha512').update(content).digest('base64');
|
|
49
|
+
return `sha512-${hash}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute SHA-512 hash of a directory's contents (deterministic).
|
|
54
|
+
* Hashes all files in sorted order to produce a reproducible hash.
|
|
55
|
+
*/
|
|
56
|
+
export function computeDirectoryIntegrity(dirPath: string): string {
|
|
57
|
+
const hash = createHash('sha512');
|
|
58
|
+
const files: string[] = [];
|
|
59
|
+
|
|
60
|
+
// Recursively collect all files
|
|
61
|
+
function collectFiles(dir: string): void {
|
|
62
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
// Skip node_modules and hidden directories
|
|
65
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
66
|
+
|
|
67
|
+
const fullPath = join(dir, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
collectFiles(fullPath);
|
|
70
|
+
} else if (entry.isFile()) {
|
|
71
|
+
files.push(fullPath);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
collectFiles(dirPath);
|
|
77
|
+
|
|
78
|
+
// Sort files for deterministic ordering
|
|
79
|
+
files.sort();
|
|
80
|
+
|
|
81
|
+
// Hash each file's relative path and content
|
|
82
|
+
for (const filePath of files) {
|
|
83
|
+
const relativePath = relative(dirPath, filePath);
|
|
84
|
+
const content = readFileSync(filePath);
|
|
85
|
+
|
|
86
|
+
// Include path in hash for structural integrity
|
|
87
|
+
hash.update(relativePath);
|
|
88
|
+
hash.update(content);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return `sha512-${hash.digest('base64')}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Verify a package's integrity against stored hash
|
|
96
|
+
*/
|
|
97
|
+
export function verifyIntegrity(dirPath: string, expectedIntegrity: string): boolean {
|
|
98
|
+
if (!expectedIntegrity) return true; // No integrity check if not stored
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const actualIntegrity = computeDirectoryIntegrity(dirPath);
|
|
102
|
+
return actualIntegrity === expectedIntegrity;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Result of integrity verification
|
|
110
|
+
*/
|
|
111
|
+
export interface IntegrityCheckResult {
|
|
112
|
+
valid: boolean;
|
|
113
|
+
expected?: string;
|
|
114
|
+
actual?: string;
|
|
115
|
+
error?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check integrity of a locked package
|
|
120
|
+
*/
|
|
121
|
+
export function checkPackageIntegrity(
|
|
122
|
+
projectDir: string,
|
|
123
|
+
packageName: string,
|
|
124
|
+
packagePath: string
|
|
125
|
+
): IntegrityCheckResult {
|
|
126
|
+
const locked = getLockedPackage(projectDir, packageName);
|
|
127
|
+
|
|
128
|
+
if (!locked) {
|
|
129
|
+
return { valid: true }; // No lock entry, can't verify
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!locked.integrity) {
|
|
133
|
+
return { valid: true }; // No integrity hash stored
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const actualIntegrity = computeDirectoryIntegrity(packagePath);
|
|
138
|
+
|
|
139
|
+
if (actualIntegrity === locked.integrity) {
|
|
140
|
+
return { valid: true, expected: locked.integrity, actual: actualIntegrity };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
valid: false,
|
|
145
|
+
expected: locked.integrity,
|
|
146
|
+
actual: actualIntegrity,
|
|
147
|
+
error: 'Integrity mismatch - package may have been tampered with'
|
|
148
|
+
};
|
|
149
|
+
} catch (err) {
|
|
150
|
+
return {
|
|
151
|
+
valid: false,
|
|
152
|
+
expected: locked.integrity,
|
|
153
|
+
error: `Failed to compute integrity: ${err instanceof Error ? err.message : String(err)}`
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
159
|
+
// FUNCTIONS
|
|
160
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get lock file path for a project
|
|
164
|
+
*/
|
|
165
|
+
export function getLockfilePath(projectDir: string): string {
|
|
166
|
+
return resolve(projectDir, LOCKFILE_NAME);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Check if lock file exists
|
|
171
|
+
*/
|
|
172
|
+
export function lockfileExists(projectDir: string): boolean {
|
|
173
|
+
return existsSync(getLockfilePath(projectDir));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Load lock file, returns empty lockfile if doesn't exist
|
|
178
|
+
*/
|
|
179
|
+
export function loadLockfile(projectDir: string): Lockfile {
|
|
180
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
181
|
+
|
|
182
|
+
if (!existsSync(lockfilePath)) {
|
|
183
|
+
return {
|
|
184
|
+
lockfileVersion: LOCKFILE_VERSION,
|
|
185
|
+
packages: {}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const content = readFileSync(lockfilePath, 'utf-8');
|
|
191
|
+
const data = JSON.parse(content) as Lockfile;
|
|
192
|
+
|
|
193
|
+
// Validate version
|
|
194
|
+
if (data.lockfileVersion !== LOCKFILE_VERSION) {
|
|
195
|
+
console.error(`Warning: Lock file version mismatch (${data.lockfileVersion} vs ${LOCKFILE_VERSION})`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return data;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
console.error('Warning: Could not parse lock file, starting fresh');
|
|
201
|
+
return {
|
|
202
|
+
lockfileVersion: LOCKFILE_VERSION,
|
|
203
|
+
packages: {}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Save lock file
|
|
210
|
+
*/
|
|
211
|
+
export function saveLockfile(projectDir: string, lockfile: Lockfile): void {
|
|
212
|
+
const lockfilePath = getLockfilePath(projectDir);
|
|
213
|
+
const content = JSON.stringify(lockfile, null, 2) + '\n';
|
|
214
|
+
writeFileSync(lockfilePath, content);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Add or update a package in the lock file
|
|
219
|
+
*/
|
|
220
|
+
export function lockPackage(
|
|
221
|
+
projectDir: string,
|
|
222
|
+
pkg: LockedPackage
|
|
223
|
+
): void {
|
|
224
|
+
const lockfile = loadLockfile(projectDir);
|
|
225
|
+
lockfile.packages[pkg.name] = pkg;
|
|
226
|
+
saveLockfile(projectDir, lockfile);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Remove a package from the lock file
|
|
231
|
+
*/
|
|
232
|
+
export function unlockPackage(projectDir: string, packageName: string): void {
|
|
233
|
+
const lockfile = loadLockfile(projectDir);
|
|
234
|
+
|
|
235
|
+
if (lockfile.packages[packageName]) {
|
|
236
|
+
delete lockfile.packages[packageName];
|
|
237
|
+
saveLockfile(projectDir, lockfile);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Get a locked package by name
|
|
243
|
+
*/
|
|
244
|
+
export function getLockedPackage(
|
|
245
|
+
projectDir: string,
|
|
246
|
+
packageName: string
|
|
247
|
+
): LockedPackage | null {
|
|
248
|
+
const lockfile = loadLockfile(projectDir);
|
|
249
|
+
return lockfile.packages[packageName] || null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Get all locked packages
|
|
254
|
+
*/
|
|
255
|
+
export function getLockedPackages(projectDir: string): LockedPackage[] {
|
|
256
|
+
const lockfile = loadLockfile(projectDir);
|
|
257
|
+
return Object.values(lockfile.packages);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check if a package is locked
|
|
262
|
+
*/
|
|
263
|
+
export function isPackageLocked(projectDir: string, packageName: string): boolean {
|
|
264
|
+
const lockfile = loadLockfile(projectDir);
|
|
265
|
+
return packageName in lockfile.packages;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Clear all packages from lock file
|
|
270
|
+
*/
|
|
271
|
+
export function clearLockfile(projectDir: string): void {
|
|
272
|
+
saveLockfile(projectDir, {
|
|
273
|
+
lockfileVersion: LOCKFILE_VERSION,
|
|
274
|
+
packages: {}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Format lock file as human-readable string
|
|
280
|
+
*/
|
|
281
|
+
export function formatLockfile(lockfile: Lockfile): string {
|
|
282
|
+
const lines: string[] = [
|
|
283
|
+
`# cli4ai.lock - Auto-generated, do not edit`,
|
|
284
|
+
`# lockfileVersion: ${lockfile.lockfileVersion}`,
|
|
285
|
+
``
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
const sortedPackages = Object.values(lockfile.packages).sort((a, b) =>
|
|
289
|
+
a.name.localeCompare(b.name)
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
for (const pkg of sortedPackages) {
|
|
293
|
+
lines.push(`${pkg.name}@${pkg.version}:`);
|
|
294
|
+
lines.push(` resolved: "${pkg.resolved}"`);
|
|
295
|
+
if (pkg.integrity) {
|
|
296
|
+
lines.push(` integrity: ${pkg.integrity}`);
|
|
297
|
+
}
|
|
298
|
+
if (pkg.dependencies && Object.keys(pkg.dependencies).length > 0) {
|
|
299
|
+
lines.push(` dependencies:`);
|
|
300
|
+
for (const [depName, depVersion] of Object.entries(pkg.dependencies)) {
|
|
301
|
+
lines.push(` ${depName}: "${depVersion}"`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
lines.push(``);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return lines.join('\n');
|
|
308
|
+
}
|