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,238 @@
1
+ /**
2
+ * Tests for link.ts
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
6
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
7
+ import { join, resolve } from 'path';
8
+ import { tmpdir, homedir } from 'os';
9
+ import {
10
+ ensureBinDir,
11
+ linkPackage,
12
+ linkPackageDirect,
13
+ unlinkPackage,
14
+ isPackageLinked,
15
+ getPathInstructions,
16
+ isBinInPath,
17
+ C4AI_BIN
18
+ } from './link.js';
19
+ import type { Manifest } from './manifest.js';
20
+
21
+ describe('link', () => {
22
+ let tempDir: string;
23
+ let testBinDir: string;
24
+
25
+ beforeEach(() => {
26
+ tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-link-test-'));
27
+ testBinDir = join(tempDir, 'bin');
28
+ mkdirSync(testBinDir);
29
+ });
30
+
31
+ afterEach(() => {
32
+ rmSync(tempDir, { recursive: true, force: true });
33
+ });
34
+
35
+ const createMockManifest = (name: string, version = '1.0.0'): Manifest => ({
36
+ name,
37
+ version,
38
+ entry: 'run.ts',
39
+ runtime: 'bun'
40
+ });
41
+
42
+ describe('C4AI_BIN constant', () => {
43
+ test('is in home directory', () => {
44
+ expect(C4AI_BIN).toBe(resolve(homedir(), '.cli4ai', 'bin'));
45
+ });
46
+ });
47
+
48
+ describe('ensureBinDir', () => {
49
+ test('creates bin directory if not exists', () => {
50
+ // This test affects real filesystem, so we just verify function doesn't throw
51
+ expect(() => ensureBinDir()).not.toThrow();
52
+ expect(existsSync(C4AI_BIN)).toBe(true);
53
+ });
54
+ });
55
+
56
+ describe('linkPackage', () => {
57
+ test('creates wrapper script', () => {
58
+ const manifest = createMockManifest('test-tool');
59
+ const packagePath = join(tempDir, 'test-tool');
60
+
61
+ const binPath = linkPackage(manifest, packagePath);
62
+
63
+ expect(existsSync(binPath)).toBe(true);
64
+ const content = readFileSync(binPath, 'utf-8');
65
+ expect(content).toContain('#!/bin/sh');
66
+ // SECURITY: Package names are shell-escaped with single quotes
67
+ expect(content).toContain("cli4ai run 'test-tool'");
68
+ });
69
+
70
+ test('makes script executable', () => {
71
+ const manifest = createMockManifest('test-tool');
72
+ const packagePath = join(tempDir, 'test-tool');
73
+
74
+ const binPath = linkPackage(manifest, packagePath);
75
+
76
+ const stat = statSync(binPath);
77
+ // Check executable bit (0o755 = rwxr-xr-x)
78
+ const isExecutable = (stat.mode & 0o111) !== 0;
79
+ expect(isExecutable).toBe(true);
80
+ });
81
+
82
+ test('includes version in comment', () => {
83
+ const manifest = createMockManifest('my-tool', '2.5.0');
84
+ const packagePath = join(tempDir, 'my-tool');
85
+
86
+ const binPath = linkPackage(manifest, packagePath);
87
+
88
+ const content = readFileSync(binPath, 'utf-8');
89
+ // SECURITY: Version is shell-escaped in comment
90
+ expect(content).toContain("'my-tool'@'2.5.0'");
91
+ });
92
+ });
93
+
94
+ describe('linkPackageDirect', () => {
95
+ test('creates direct wrapper script', () => {
96
+ const manifest = createMockManifest('direct-tool');
97
+ const packagePath = join(tempDir, 'direct-tool');
98
+
99
+ const binPath = linkPackageDirect(manifest, packagePath);
100
+
101
+ expect(existsSync(binPath)).toBe(true);
102
+ const content = readFileSync(binPath, 'utf-8');
103
+ expect(content).toContain('#!/bin/sh');
104
+ expect(content).toContain('bun run');
105
+ expect(content).toContain('run.ts');
106
+ });
107
+
108
+ test('uses correct runtime from manifest (node)', () => {
109
+ const manifest: Manifest = {
110
+ name: 'node-tool',
111
+ version: '1.0.0',
112
+ entry: 'index.js',
113
+ runtime: 'node'
114
+ };
115
+ const packagePath = join(tempDir, 'node-tool');
116
+
117
+ const binPath = linkPackageDirect(manifest, packagePath);
118
+
119
+ const content = readFileSync(binPath, 'utf-8');
120
+ // Node.js doesn't use 'run' subcommand - it's just 'node <file>'
121
+ // SECURITY: Paths are now shell-escaped with single quotes
122
+ expect(content).toContain("exec node '");
123
+ expect(content).not.toContain('node run');
124
+ });
125
+
126
+ test('defaults to bun runtime', () => {
127
+ const manifest: Manifest = {
128
+ name: 'no-runtime',
129
+ version: '1.0.0',
130
+ entry: 'run.ts'
131
+ // no runtime specified
132
+ };
133
+ const packagePath = join(tempDir, 'no-runtime');
134
+
135
+ const binPath = linkPackageDirect(manifest, packagePath);
136
+
137
+ const content = readFileSync(binPath, 'utf-8');
138
+ expect(content).toContain('bun run');
139
+ });
140
+
141
+ test('includes full path to entry', () => {
142
+ const manifest = createMockManifest('path-tool');
143
+ const packagePath = join(tempDir, 'path-tool');
144
+
145
+ const binPath = linkPackageDirect(manifest, packagePath);
146
+
147
+ const content = readFileSync(binPath, 'utf-8');
148
+ expect(content).toContain(resolve(packagePath, 'run.ts'));
149
+ });
150
+ });
151
+
152
+ describe('unlinkPackage', () => {
153
+ test('removes linked package', () => {
154
+ const manifest = createMockManifest('unlink-test');
155
+ const packagePath = join(tempDir, 'unlink-test');
156
+
157
+ const binPath = linkPackage(manifest, packagePath);
158
+ expect(existsSync(binPath)).toBe(true);
159
+
160
+ const result = unlinkPackage('unlink-test');
161
+
162
+ expect(result).toBe(true);
163
+ expect(existsSync(binPath)).toBe(false);
164
+ });
165
+
166
+ test('returns false for non-existent package', () => {
167
+ const result = unlinkPackage('nonexistent-package-name');
168
+ expect(result).toBe(false);
169
+ });
170
+ });
171
+
172
+ describe('isPackageLinked', () => {
173
+ test('returns true for linked package', () => {
174
+ const manifest = createMockManifest('linked-tool');
175
+ const packagePath = join(tempDir, 'linked-tool');
176
+
177
+ linkPackage(manifest, packagePath);
178
+
179
+ expect(isPackageLinked('linked-tool')).toBe(true);
180
+ });
181
+
182
+ test('returns false for unlinked package', () => {
183
+ expect(isPackageLinked('not-linked-tool')).toBe(false);
184
+ });
185
+ });
186
+
187
+ describe('getPathInstructions', () => {
188
+ test('returns instructions string', () => {
189
+ const instructions = getPathInstructions();
190
+
191
+ expect(instructions).toContain('export PATH=');
192
+ expect(instructions).toContain(C4AI_BIN);
193
+ expect(instructions).toContain('source');
194
+ });
195
+
196
+ test('detects zsh shell', () => {
197
+ const originalShell = process.env.SHELL;
198
+ process.env.SHELL = '/bin/zsh';
199
+
200
+ const instructions = getPathInstructions();
201
+
202
+ expect(instructions).toContain('.zshrc');
203
+
204
+ process.env.SHELL = originalShell;
205
+ });
206
+
207
+ test('defaults to bash', () => {
208
+ const originalShell = process.env.SHELL;
209
+ process.env.SHELL = '/bin/bash';
210
+
211
+ const instructions = getPathInstructions();
212
+
213
+ expect(instructions).toContain('.bashrc');
214
+
215
+ process.env.SHELL = originalShell;
216
+ });
217
+ });
218
+
219
+ describe('isBinInPath', () => {
220
+ test('returns true when bin in PATH', () => {
221
+ const originalPath = process.env.PATH;
222
+ process.env.PATH = `${C4AI_BIN}:${originalPath}`;
223
+
224
+ expect(isBinInPath()).toBe(true);
225
+
226
+ process.env.PATH = originalPath;
227
+ });
228
+
229
+ test('returns false when bin not in PATH', () => {
230
+ const originalPath = process.env.PATH;
231
+ process.env.PATH = '/usr/bin:/usr/local/bin';
232
+
233
+ expect(isBinInPath()).toBe(false);
234
+
235
+ process.env.PATH = originalPath;
236
+ });
237
+ });
238
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * PATH linking for global packages
3
+ *
4
+ * Creates executable symlinks in ~/.cli4ai/bin/ that can be added to PATH
5
+ */
6
+
7
+ import { existsSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, chmodSync } from 'fs';
8
+ import { resolve, join } from 'path';
9
+ import { homedir } from 'os';
10
+ import { type Manifest } from './manifest.js';
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // SECURITY: Shell escaping for safe script generation
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ /**
17
+ * Validate that a string is safe for shell script embedding.
18
+ * Only allows alphanumeric, hyphens, underscores, and dots.
19
+ * This is a strict allowlist approach for defense in depth.
20
+ */
21
+ function isShellSafe(value: string): boolean {
22
+ return /^[a-zA-Z0-9._-]+$/.test(value);
23
+ }
24
+
25
+ /**
26
+ * Escape a string for safe embedding in shell scripts.
27
+ * Uses single quotes which prevent all shell interpretation except for single quotes themselves.
28
+ */
29
+ function shellEscape(value: string): string {
30
+ // Single quotes prevent shell interpretation; escape any single quotes in the value
31
+ // by ending the single-quoted string, adding an escaped single quote, and starting a new single-quoted string
32
+ return "'" + value.replace(/'/g, "'\\''") + "'";
33
+ }
34
+
35
+ // ═══════════════════════════════════════════════════════════════════════════
36
+ // PATHS
37
+ // ═══════════════════════════════════════════════════════════════════════════
38
+
39
+ export const C4AI_BIN = resolve(homedir(), '.cli4ai', 'bin');
40
+
41
+ // ═══════════════════════════════════════════════════════════════════════════
42
+ // FUNCTIONS
43
+ // ═══════════════════════════════════════════════════════════════════════════
44
+
45
+ /**
46
+ * Ensure bin directory exists
47
+ */
48
+ export function ensureBinDir(): void {
49
+ if (!existsSync(C4AI_BIN)) {
50
+ mkdirSync(C4AI_BIN, { recursive: true });
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Create executable wrapper script for a package
56
+ *
57
+ * Creates a shell script that invokes `cli4ai run <package> [args]`
58
+ */
59
+ export function linkPackage(manifest: Manifest, packagePath: string): string {
60
+ ensureBinDir();
61
+
62
+ // SECURITY: Validate manifest values before embedding in shell script
63
+ if (!isShellSafe(manifest.name)) {
64
+ throw new Error(`Invalid package name for shell script: ${manifest.name}`);
65
+ }
66
+ if (!isShellSafe(manifest.version)) {
67
+ throw new Error(`Invalid package version for shell script: ${manifest.version}`);
68
+ }
69
+
70
+ const binPath = join(C4AI_BIN, manifest.name);
71
+
72
+ // Create wrapper script with escaped values for defense in depth
73
+ const safeName = shellEscape(manifest.name);
74
+ const safeVersion = shellEscape(manifest.version);
75
+
76
+ const script = `#!/bin/sh
77
+ # cli4ai wrapper for ${safeName}@${safeVersion}
78
+ # Auto-generated - do not edit
79
+
80
+ exec cli4ai run ${safeName} "$@"
81
+ `;
82
+
83
+ writeFileSync(binPath, script);
84
+ chmodSync(binPath, 0o755);
85
+
86
+ return binPath;
87
+ }
88
+
89
+ /**
90
+ * Create direct executable wrapper that runs the tool directly
91
+ *
92
+ * This is faster than going through cli4ai run
93
+ */
94
+ export function linkPackageDirect(manifest: Manifest, packagePath: string): string {
95
+ ensureBinDir();
96
+
97
+ // SECURITY: Validate manifest values before embedding in shell script
98
+ if (!isShellSafe(manifest.name)) {
99
+ throw new Error(`Invalid package name for shell script: ${manifest.name}`);
100
+ }
101
+ if (!isShellSafe(manifest.version)) {
102
+ throw new Error(`Invalid package version for shell script: ${manifest.version}`);
103
+ }
104
+
105
+ const binPath = join(C4AI_BIN, manifest.name);
106
+ const entryPath = resolve(packagePath, manifest.entry);
107
+ const runtime = manifest.runtime || 'bun';
108
+
109
+ // SECURITY: Shell-escape the entry path to prevent injection
110
+ const safeEntryPath = shellEscape(entryPath);
111
+ const safeName = shellEscape(manifest.name);
112
+ const safeVersion = shellEscape(manifest.version);
113
+
114
+ // Build runtime command based on runtime type
115
+ // - bun: bun run <file>
116
+ // - node: node <file>
117
+ let execCommand: string;
118
+ switch (runtime) {
119
+ case 'node':
120
+ execCommand = `node ${safeEntryPath}`;
121
+ break;
122
+ case 'bun':
123
+ default:
124
+ execCommand = `bun run ${safeEntryPath}`;
125
+ break;
126
+ }
127
+
128
+ // Create wrapper script that runs the tool directly
129
+ const script = `#!/bin/sh
130
+ # cli4ai wrapper for ${safeName}@${safeVersion}
131
+ # Auto-generated - do not edit
132
+
133
+ exec ${execCommand} "$@"
134
+ `;
135
+
136
+ writeFileSync(binPath, script);
137
+ chmodSync(binPath, 0o755);
138
+
139
+ return binPath;
140
+ }
141
+
142
+ /**
143
+ * Remove executable link for a package
144
+ */
145
+ export function unlinkPackage(packageName: string): boolean {
146
+ const binPath = join(C4AI_BIN, packageName);
147
+
148
+ if (existsSync(binPath)) {
149
+ unlinkSync(binPath);
150
+ return true;
151
+ }
152
+
153
+ return false;
154
+ }
155
+
156
+ /**
157
+ * Check if a package is linked
158
+ */
159
+ export function isPackageLinked(packageName: string): boolean {
160
+ return existsSync(join(C4AI_BIN, packageName));
161
+ }
162
+
163
+ /**
164
+ * Get PATH setup instructions
165
+ */
166
+ export function getPathInstructions(): string {
167
+ const shell = process.env.SHELL || '/bin/bash';
168
+ const isZsh = shell.includes('zsh');
169
+ const rcFile = isZsh ? '~/.zshrc' : '~/.bashrc';
170
+
171
+ return `
172
+ To use globally installed cli4ai packages from anywhere, add this to your ${rcFile}:
173
+
174
+ export PATH="${C4AI_BIN}:$PATH"
175
+
176
+ Then reload your shell:
177
+
178
+ source ${rcFile}
179
+
180
+ Or start a new terminal session.
181
+ `.trim();
182
+ }
183
+
184
+ /**
185
+ * Check if bin directory is in PATH
186
+ */
187
+ export function isBinInPath(): boolean {
188
+ const pathEnv = process.env.PATH || '';
189
+ return pathEnv.split(':').some(p => resolve(p) === C4AI_BIN);
190
+ }