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.
@@ -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
+ }