@haystackeditor/cli 0.2.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,177 @@
1
+ /**
2
+ * Haystack CLI Types
3
+ *
4
+ * These types define the .haystack.yml schema that sandbox.py reads.
5
+ */
6
+ /**
7
+ * Main configuration file schema (.haystack.yml)
8
+ */
9
+ export interface HaystackConfig {
10
+ /** Schema version - must be "1" */
11
+ version: '1';
12
+ /** Project name for display */
13
+ name?: string;
14
+ /** Package manager (auto-detected if not specified) */
15
+ package_manager?: 'npm' | 'pnpm' | 'yarn' | 'bun';
16
+ /**
17
+ * Single dev server configuration.
18
+ * Use this for simple projects with one service.
19
+ * Mutually exclusive with `services`.
20
+ */
21
+ dev_server?: DevServer;
22
+ /**
23
+ * Multiple services configuration.
24
+ * Use this for monorepos with multiple services.
25
+ * Mutually exclusive with `dev_server`.
26
+ */
27
+ services?: Record<string, Service>;
28
+ /** Verification configuration */
29
+ verification?: VerificationConfig;
30
+ }
31
+ /**
32
+ * Dev server configuration (simple mode)
33
+ */
34
+ export interface DevServer {
35
+ /** Command to start the dev server (e.g., "pnpm dev") */
36
+ command: string;
37
+ /** Port the server runs on */
38
+ port: number;
39
+ /** Pattern in stdout indicating server is ready (e.g., "Local:") */
40
+ ready_pattern?: string;
41
+ /** Environment variables to set (e.g., { SKIP_AUTH: "true" }) */
42
+ env?: Record<string, string>;
43
+ }
44
+ /**
45
+ * Service configuration (monorepo mode)
46
+ */
47
+ export interface Service {
48
+ /** Directory relative to repo root (e.g., "infra/api-worker") */
49
+ root?: string;
50
+ /** Command to start the service */
51
+ command: string;
52
+ /** Port the service runs on (omit for batch jobs) */
53
+ port?: number;
54
+ /** Pattern in stdout indicating service is ready */
55
+ ready_pattern?: string;
56
+ /** Service type: "server" (default) or "batch" */
57
+ type?: 'server' | 'batch';
58
+ /** Environment variables to set */
59
+ env?: Record<string, string>;
60
+ }
61
+ /**
62
+ * Verification configuration
63
+ */
64
+ export interface VerificationConfig {
65
+ /** Commands to run during sandbox creation */
66
+ commands?: VerificationCommand[];
67
+ /** Fixture definitions - array of pattern/source pairs */
68
+ fixtures?: Fixture[];
69
+ }
70
+ /**
71
+ * Fixture definition - pattern to match and source to load from
72
+ */
73
+ export interface Fixture {
74
+ /** URL pattern to match (e.g., "/api/github/repos/*") */
75
+ pattern: string;
76
+ /**
77
+ * Source to load data from. Supports environment variable substitution:
78
+ * - $VAR, ${VAR}, ${VAR:-default}
79
+ *
80
+ * Source types:
81
+ * - file://path/to/file.json - Local file
82
+ * - https://staging.example.com/api/... - Remote URL (e.g., staging server)
83
+ * - https://${STAGING_HOST}/api/... - Remote URL with env var
84
+ * - s3://bucket/key - AWS S3 (uses AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
85
+ * - r2://bucket/key - Cloudflare R2 (uses R2_ACCOUNT_ID, R2_ACCESS_KEY_ID)
86
+ * - passthrough - Don't intercept, let request through to real API
87
+ *
88
+ * @example
89
+ * // Pull from staging with auth
90
+ * source: "https://${STAGING_URL:-staging.example.com}/api/users"
91
+ *
92
+ * @example
93
+ * // S3 with region override
94
+ * source: "s3://my-bucket/fixtures/data.json"
95
+ */
96
+ source: string;
97
+ /**
98
+ * Headers for remote sources.
99
+ * Supports environment variable substitution: $VAR, ${VAR}, ${VAR:-default}
100
+ *
101
+ * @example
102
+ * headers:
103
+ * Authorization: "Bearer $STAGING_API_TOKEN"
104
+ * Cookie: "$STAGING_SESSION_COOKIE"
105
+ * X-Api-Key: "${API_KEY:-test-key}"
106
+ */
107
+ headers?: Record<string, string>;
108
+ }
109
+ /**
110
+ * A verification command (e.g., build, lint, typecheck)
111
+ */
112
+ export interface VerificationCommand {
113
+ /** Human-readable name */
114
+ name: string;
115
+ /** Shell command to run */
116
+ run: string;
117
+ }
118
+ /**
119
+ * @deprecated Use Fixture interface instead. This is kept for backwards compatibility.
120
+ */
121
+ export interface FixtureEntry {
122
+ source: string;
123
+ headers?: Record<string, string>;
124
+ }
125
+ /**
126
+ * Project detection results
127
+ */
128
+ export interface DetectedProject {
129
+ /** Detected framework */
130
+ framework?: 'vite' | 'nextjs' | 'remix' | 'nuxt' | 'sveltekit' | 'astro' | 'cra' | 'cloudflare-worker';
131
+ /** Detected package manager */
132
+ packageManager: 'npm' | 'pnpm' | 'yarn' | 'bun';
133
+ /** Whether this is a monorepo */
134
+ isMonorepo: boolean;
135
+ /** Monorepo tool if detected */
136
+ monorepoTool?: 'pnpm-workspaces' | 'lerna' | 'nx' | 'turborepo' | 'rush';
137
+ /** Detected services in monorepo */
138
+ services?: DetectedService[];
139
+ /** Suggested dev command */
140
+ suggestedDevCommand?: string;
141
+ /** Suggested port */
142
+ suggestedPort?: number;
143
+ /** Suggested ready pattern */
144
+ suggestedReadyPattern?: string;
145
+ /** Suggested auth bypass env var */
146
+ suggestedAuthBypass?: string;
147
+ }
148
+ /**
149
+ * Detected service in a monorepo
150
+ */
151
+ export interface DetectedService {
152
+ name: string;
153
+ root: string;
154
+ type: 'server' | 'batch';
155
+ framework?: string;
156
+ suggestedCommand?: string;
157
+ suggestedPort?: number;
158
+ }
159
+ /**
160
+ * Auth credentials stored in Haystack
161
+ */
162
+ export interface HaystackCredentials {
163
+ /** GitHub access token */
164
+ token: string;
165
+ /** Token expiration (ISO string) */
166
+ expiresAt?: string;
167
+ /** Associated GitHub username */
168
+ username?: string;
169
+ }
170
+ /**
171
+ * Stored secrets (in Haystack platform)
172
+ */
173
+ export interface StoredSecret {
174
+ key: string;
175
+ createdAt: string;
176
+ updatedAt: string;
177
+ }
package/dist/types.js ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Haystack CLI Types
3
+ *
4
+ * These types define the .haystack.yml schema that sandbox.py reads.
5
+ */
6
+ export {};
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Configuration file utilities
3
+ */
4
+ import type { HaystackConfig } from '../types.js';
5
+ /**
6
+ * Find the config file by walking up the directory tree
7
+ */
8
+ export declare function findConfigPath(startDir?: string): Promise<string | null>;
9
+ /**
10
+ * Load and parse the config file
11
+ */
12
+ export declare function loadConfig(configPath?: string): Promise<HaystackConfig | null>;
13
+ /**
14
+ * Save config to file
15
+ */
16
+ export declare function saveConfig(config: HaystackConfig, configPath?: string): Promise<string>;
17
+ /**
18
+ * Check if config exists
19
+ */
20
+ export declare function configExists(startDir?: string): Promise<boolean>;
21
+ /**
22
+ * Get the project root (directory containing .haystack.yml or current dir)
23
+ */
24
+ export declare function getProjectRoot(): Promise<string>;
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Configuration file utilities
3
+ */
4
+ import * as fs from 'fs/promises';
5
+ import * as path from 'path';
6
+ import * as yaml from 'yaml';
7
+ const CONFIG_FILENAME = '.haystack.yml';
8
+ /**
9
+ * Find the config file by walking up the directory tree
10
+ */
11
+ export async function findConfigPath(startDir = process.cwd()) {
12
+ let dir = startDir;
13
+ while (true) {
14
+ const configPath = path.join(dir, CONFIG_FILENAME);
15
+ try {
16
+ await fs.access(configPath);
17
+ return configPath;
18
+ }
19
+ catch {
20
+ // Not found, try parent
21
+ }
22
+ const parent = path.dirname(dir);
23
+ if (parent === dir) {
24
+ // Reached root
25
+ return null;
26
+ }
27
+ dir = parent;
28
+ }
29
+ }
30
+ /**
31
+ * Load and parse the config file
32
+ */
33
+ export async function loadConfig(configPath) {
34
+ const resolvedPath = configPath || (await findConfigPath());
35
+ if (!resolvedPath) {
36
+ return null;
37
+ }
38
+ try {
39
+ const content = await fs.readFile(resolvedPath, 'utf-8');
40
+ return yaml.parse(content);
41
+ }
42
+ catch (err) {
43
+ throw new Error(`Failed to parse ${resolvedPath}: ${err.message}`);
44
+ }
45
+ }
46
+ /**
47
+ * Save config to file
48
+ */
49
+ export async function saveConfig(config, configPath) {
50
+ const resolvedPath = configPath || path.join(process.cwd(), CONFIG_FILENAME);
51
+ const content = yaml.stringify(config, {
52
+ indent: 2,
53
+ lineWidth: 0, // Don't wrap lines
54
+ });
55
+ await fs.writeFile(resolvedPath, content, 'utf-8');
56
+ return resolvedPath;
57
+ }
58
+ /**
59
+ * Check if config exists
60
+ */
61
+ export async function configExists(startDir) {
62
+ const configPath = await findConfigPath(startDir);
63
+ return configPath !== null;
64
+ }
65
+ /**
66
+ * Get the project root (directory containing .haystack.yml or current dir)
67
+ */
68
+ export async function getProjectRoot() {
69
+ const configPath = await findConfigPath();
70
+ if (configPath) {
71
+ return path.dirname(configPath);
72
+ }
73
+ return process.cwd();
74
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Project detection utilities
3
+ *
4
+ * Auto-detects framework, package manager, monorepo structure, and services.
5
+ */
6
+ import type { DetectedProject } from '../types.js';
7
+ /**
8
+ * Detect project configuration
9
+ */
10
+ export declare function detectProject(rootDir?: string): Promise<DetectedProject>;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Project detection utilities
3
+ *
4
+ * Auto-detects framework, package manager, monorepo structure, and services.
5
+ */
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ /**
9
+ * Detect project configuration
10
+ */
11
+ export async function detectProject(rootDir = process.cwd()) {
12
+ const result = {
13
+ packageManager: await detectPackageManager(rootDir),
14
+ isMonorepo: false,
15
+ };
16
+ // Detect monorepo
17
+ const monorepo = await detectMonorepo(rootDir);
18
+ if (monorepo) {
19
+ result.isMonorepo = true;
20
+ result.monorepoTool = monorepo.tool;
21
+ result.services = await detectServices(rootDir, monorepo.workspaces);
22
+ }
23
+ // Detect framework
24
+ result.framework = await detectFramework(rootDir);
25
+ // Set suggestions based on framework
26
+ setSuggestions(result);
27
+ // Detect auth bypass from .env.example
28
+ result.suggestedAuthBypass = await detectAuthBypass(rootDir);
29
+ return result;
30
+ }
31
+ /**
32
+ * Detect package manager from lockfiles
33
+ */
34
+ async function detectPackageManager(rootDir) {
35
+ const checks = [
36
+ { file: 'pnpm-lock.yaml', manager: 'pnpm' },
37
+ { file: 'yarn.lock', manager: 'yarn' },
38
+ { file: 'bun.lockb', manager: 'bun' },
39
+ { file: 'package-lock.json', manager: 'npm' },
40
+ ];
41
+ for (const { file, manager } of checks) {
42
+ try {
43
+ await fs.access(path.join(rootDir, file));
44
+ return manager;
45
+ }
46
+ catch {
47
+ // Not found, continue
48
+ }
49
+ }
50
+ return 'npm'; // Default
51
+ }
52
+ /**
53
+ * Detect monorepo structure
54
+ */
55
+ async function detectMonorepo(rootDir) {
56
+ // Check pnpm workspaces
57
+ try {
58
+ const pnpmWorkspace = await fs.readFile(path.join(rootDir, 'pnpm-workspace.yaml'), 'utf-8');
59
+ const match = pnpmWorkspace.match(/packages:\s*\n([\s\S]*?)(?:\n\w|$)/);
60
+ if (match) {
61
+ const workspaces = match[1]
62
+ .split('\n')
63
+ .map((line) => line.trim().replace(/^-\s*['"]?|['"]?$/g, ''))
64
+ .filter(Boolean);
65
+ return { tool: 'pnpm-workspaces', workspaces };
66
+ }
67
+ }
68
+ catch {
69
+ // Not found
70
+ }
71
+ // Check package.json workspaces (yarn/npm)
72
+ try {
73
+ const pkgJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf-8'));
74
+ if (pkgJson.workspaces) {
75
+ const workspaces = Array.isArray(pkgJson.workspaces)
76
+ ? pkgJson.workspaces
77
+ : pkgJson.workspaces.packages || [];
78
+ return { tool: 'pnpm-workspaces', workspaces };
79
+ }
80
+ }
81
+ catch {
82
+ // Not found
83
+ }
84
+ // Check for nx.json
85
+ try {
86
+ await fs.access(path.join(rootDir, 'nx.json'));
87
+ return { tool: 'nx', workspaces: ['packages/*', 'apps/*'] };
88
+ }
89
+ catch {
90
+ // Not found
91
+ }
92
+ // Check for turbo.json
93
+ try {
94
+ await fs.access(path.join(rootDir, 'turbo.json'));
95
+ return { tool: 'turborepo', workspaces: ['packages/*', 'apps/*'] };
96
+ }
97
+ catch {
98
+ // Not found
99
+ }
100
+ // Check for lerna.json
101
+ try {
102
+ await fs.access(path.join(rootDir, 'lerna.json'));
103
+ return { tool: 'lerna', workspaces: ['packages/*'] };
104
+ }
105
+ catch {
106
+ // Not found
107
+ }
108
+ return null;
109
+ }
110
+ /**
111
+ * Detect services in a monorepo
112
+ */
113
+ async function detectServices(rootDir, workspacePatterns) {
114
+ const services = [];
115
+ const glob = await import('fast-glob');
116
+ for (const pattern of workspacePatterns) {
117
+ const matches = await glob.default(pattern, {
118
+ cwd: rootDir,
119
+ onlyDirectories: true,
120
+ ignore: ['node_modules'],
121
+ });
122
+ for (const match of matches) {
123
+ const service = await detectService(rootDir, match);
124
+ if (service) {
125
+ services.push(service);
126
+ }
127
+ }
128
+ }
129
+ // Also check common directories
130
+ const commonDirs = ['infra', 'services', 'apps', 'packages'];
131
+ for (const dir of commonDirs) {
132
+ try {
133
+ const entries = await fs.readdir(path.join(rootDir, dir), {
134
+ withFileTypes: true,
135
+ });
136
+ for (const entry of entries) {
137
+ if (entry.isDirectory()) {
138
+ const servicePath = path.join(dir, entry.name);
139
+ // Skip if already detected
140
+ if (services.some((s) => s.root === servicePath))
141
+ continue;
142
+ const service = await detectService(rootDir, servicePath);
143
+ if (service) {
144
+ services.push(service);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ catch {
150
+ // Directory doesn't exist
151
+ }
152
+ }
153
+ return services;
154
+ }
155
+ /**
156
+ * Detect a single service
157
+ */
158
+ async function detectService(rootDir, servicePath) {
159
+ const fullPath = path.join(rootDir, servicePath);
160
+ // Check for package.json
161
+ let pkgJson = null;
162
+ try {
163
+ pkgJson = JSON.parse(await fs.readFile(path.join(fullPath, 'package.json'), 'utf-8'));
164
+ }
165
+ catch {
166
+ return null; // No package.json, not a service
167
+ }
168
+ const name = pkgJson?.name?.replace(/^@[^/]+\//, '') || path.basename(servicePath);
169
+ const scripts = pkgJson?.scripts || {};
170
+ // Detect if it's a Cloudflare Worker
171
+ const isWorker = await hasFile(fullPath, 'wrangler.toml') ||
172
+ await hasFile(fullPath, 'wrangler.jsonc') ||
173
+ await hasFile(fullPath, 'wrangler.json');
174
+ // Detect framework
175
+ let framework;
176
+ let suggestedCommand;
177
+ let suggestedPort;
178
+ if (isWorker) {
179
+ framework = 'cloudflare-worker';
180
+ suggestedCommand = 'pnpm wrangler dev --local';
181
+ suggestedPort = 8787;
182
+ }
183
+ else if (await hasFile(fullPath, 'vite.config.ts') || await hasFile(fullPath, 'vite.config.js')) {
184
+ framework = 'vite';
185
+ suggestedCommand = 'pnpm dev';
186
+ suggestedPort = 5173;
187
+ }
188
+ else if (await hasFile(fullPath, 'next.config.js') || await hasFile(fullPath, 'next.config.ts')) {
189
+ framework = 'nextjs';
190
+ suggestedCommand = 'pnpm dev';
191
+ suggestedPort = 3000;
192
+ }
193
+ else if (scripts.dev) {
194
+ suggestedCommand = 'pnpm dev';
195
+ suggestedPort = 3000;
196
+ }
197
+ else if (scripts.start) {
198
+ suggestedCommand = 'pnpm start';
199
+ }
200
+ // Determine if it's a batch job or server
201
+ const type = name.includes('analysis') || name.includes('batch') || name.includes('job')
202
+ ? 'batch'
203
+ : 'server';
204
+ return {
205
+ name,
206
+ root: servicePath,
207
+ type,
208
+ framework,
209
+ suggestedCommand,
210
+ suggestedPort: type === 'server' ? suggestedPort : undefined,
211
+ };
212
+ }
213
+ /**
214
+ * Detect framework from config files
215
+ */
216
+ async function detectFramework(rootDir) {
217
+ const checks = [
218
+ { files: ['vite.config.ts', 'vite.config.js'], framework: 'vite' },
219
+ { files: ['next.config.js', 'next.config.ts', 'next.config.mjs'], framework: 'nextjs' },
220
+ { files: ['remix.config.js', 'remix.config.ts'], framework: 'remix' },
221
+ { files: ['nuxt.config.ts', 'nuxt.config.js'], framework: 'nuxt' },
222
+ { files: ['svelte.config.js', 'svelte.config.ts'], framework: 'sveltekit' },
223
+ { files: ['astro.config.mjs', 'astro.config.ts'], framework: 'astro' },
224
+ { files: ['wrangler.toml', 'wrangler.jsonc'], framework: 'cloudflare-worker' },
225
+ ];
226
+ for (const { files, framework } of checks) {
227
+ for (const file of files) {
228
+ if (await hasFile(rootDir, file)) {
229
+ return framework;
230
+ }
231
+ }
232
+ }
233
+ // Check for CRA
234
+ try {
235
+ const pkgJson = JSON.parse(await fs.readFile(path.join(rootDir, 'package.json'), 'utf-8'));
236
+ if (pkgJson.dependencies?.['react-scripts']) {
237
+ return 'cra';
238
+ }
239
+ }
240
+ catch {
241
+ // Not found
242
+ }
243
+ return undefined;
244
+ }
245
+ /**
246
+ * Detect auth bypass env var from .env.example
247
+ */
248
+ async function detectAuthBypass(rootDir) {
249
+ const envFiles = ['.env.example', '.env.local.example', '.env.development'];
250
+ for (const file of envFiles) {
251
+ try {
252
+ const content = await fs.readFile(path.join(rootDir, file), 'utf-8');
253
+ // Common patterns
254
+ const patterns = [
255
+ /^(SKIP_AUTH)=/m,
256
+ /^(BYPASS_AUTH)=/m,
257
+ /^(AUTH_DISABLED)=/m,
258
+ /^(MOCK_AUTH)=/m,
259
+ /^(NEXT_PUBLIC_MOCK_AUTH)=/m,
260
+ /^(VITE_MOCK_AUTH)=/m,
261
+ /^(VITE_SKIP_AUTH)=/m,
262
+ ];
263
+ for (const pattern of patterns) {
264
+ const match = content.match(pattern);
265
+ if (match) {
266
+ return `${match[1]}=true`;
267
+ }
268
+ }
269
+ }
270
+ catch {
271
+ // File not found
272
+ }
273
+ }
274
+ return 'SKIP_AUTH=true'; // Default suggestion
275
+ }
276
+ /**
277
+ * Set suggestions based on detected framework
278
+ */
279
+ function setSuggestions(result) {
280
+ const frameworkDefaults = {
281
+ vite: { command: 'pnpm dev', port: 5173, ready: 'Local:' },
282
+ nextjs: { command: 'pnpm dev', port: 3000, ready: 'Ready' },
283
+ remix: { command: 'pnpm dev', port: 3000, ready: 'started' },
284
+ nuxt: { command: 'pnpm dev', port: 3000, ready: 'Local:' },
285
+ sveltekit: { command: 'pnpm dev', port: 5173, ready: 'Local:' },
286
+ astro: { command: 'pnpm dev', port: 4321, ready: 'Local:' },
287
+ cra: { command: 'pnpm start', port: 3000, ready: 'Compiled' },
288
+ 'cloudflare-worker': { command: 'pnpm wrangler dev', port: 8787, ready: 'Ready' },
289
+ };
290
+ const defaults = result.framework ? frameworkDefaults[result.framework] : null;
291
+ result.suggestedDevCommand = defaults?.command || `${result.packageManager} dev`;
292
+ result.suggestedPort = defaults?.port || 3000;
293
+ result.suggestedReadyPattern = defaults?.ready || 'ready|started|listening|Local:';
294
+ }
295
+ /**
296
+ * Check if a file exists
297
+ */
298
+ async function hasFile(dir, file) {
299
+ try {
300
+ await fs.access(path.join(dir, file));
301
+ return true;
302
+ }
303
+ catch {
304
+ return false;
305
+ }
306
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Secret Scanning & Validation
3
+ *
4
+ * Detects potential hardcoded secrets in configuration files
5
+ * before they can be committed to version control.
6
+ *
7
+ * Based on patterns from:
8
+ * - GitHub secret scanning
9
+ * - detect-secrets
10
+ * - truffleHog
11
+ */
12
+ /**
13
+ * Detected secret finding
14
+ */
15
+ export interface SecretFinding {
16
+ pattern: string;
17
+ description: string;
18
+ severity: 'high' | 'medium' | 'low';
19
+ line: number;
20
+ column: number;
21
+ match: string;
22
+ file: string;
23
+ }
24
+ /**
25
+ * Scan content for potential secrets
26
+ */
27
+ export declare function scanForSecrets(content: string, filename: string): SecretFinding[];
28
+ /**
29
+ * Scan a file for secrets
30
+ */
31
+ export declare function scanFile(filePath: string): Promise<SecretFinding[]>;
32
+ /**
33
+ * Scan .haystack.yml specifically for secrets
34
+ */
35
+ export declare function scanHaystackConfig(configPath: string): Promise<SecretFinding[]>;
36
+ /**
37
+ * Validate that a config doesn't contain hardcoded secrets
38
+ * Returns true if safe, false if secrets detected
39
+ */
40
+ export declare function validateConfigSecurity(configPath: string): Promise<{
41
+ safe: boolean;
42
+ findings: SecretFinding[];
43
+ }>;
44
+ /**
45
+ * Get a security report string for display
46
+ */
47
+ export declare function formatSecurityReport(findings: SecretFinding[]): string;