qa360 2.1.5 → 2.1.7

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.
@@ -30,6 +30,8 @@ export declare function aiGenerateCommand(prompt: string, options?: {
30
30
  provider?: string;
31
31
  type?: string;
32
32
  json?: boolean;
33
+ pack?: boolean;
34
+ output?: string;
33
35
  }): Promise<void>;
34
36
  /**
35
37
  * Show AI configuration
@@ -236,6 +236,11 @@ export async function aiBenchmarkCommand(options = {}) {
236
236
  export async function aiGenerateCommand(prompt, options = {}) {
237
237
  let spinner;
238
238
  try {
239
+ // Use pack generation if --pack flag is set
240
+ if (options.pack) {
241
+ await generatePackCommand(prompt, options);
242
+ return;
243
+ }
239
244
  const systemPrompt = buildSystemPrompt(options.type || 'api');
240
245
  const provider = options.provider
241
246
  ? await createLLMProvider({ preferred: options.provider })
@@ -289,10 +294,61 @@ export async function aiGenerateCommand(prompt, options = {}) {
289
294
  console.error(chalk.red('\nAnthropic Error:'), error.message);
290
295
  }
291
296
  else {
292
- console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error));
297
+ const errMsg = error instanceof Error ? error.message : String(error);
298
+ console.error(chalk.red('Error:'), errMsg);
293
299
  }
294
300
  }
295
301
  }
302
+ /**
303
+ * Generate a QA360 pack YAML using AI
304
+ */
305
+ async function generatePackCommand(prompt, options = {}) {
306
+ const spinner = ora('Generating QA360 pack...').start();
307
+ try {
308
+ const systemPrompt = buildPackSystemPrompt();
309
+ const provider = options.provider
310
+ ? await createLLMProvider({ preferred: options.provider })
311
+ : await createBest();
312
+ const providerType = provider.getProviderType();
313
+ const enhancedPrompt = `Generate a QA360 v2 pack YAML for: ${prompt}
314
+
315
+ Important: Return ONLY valid YAML starting with "version: 2". No code blocks, no explanations, just the YAML.`;
316
+ const response = await provider.generate({
317
+ prompt: enhancedPrompt,
318
+ systemPrompt,
319
+ maxTokens: 4096,
320
+ temperature: 0.3, // Lower temperature for more structured output
321
+ });
322
+ spinner.succeed(`Generated pack using ${chalk.cyan(providerType)}!\n`);
323
+ // Extract YAML from response (remove code blocks if present)
324
+ let yamlContent = response.content;
325
+ // Remove markdown code blocks
326
+ yamlContent = yamlContent.replace(/```yaml\n?/g, '').replace(/```\n?/g, '');
327
+ // Remove leading/trailing whitespace
328
+ yamlContent = yamlContent.trim();
329
+ // Determine output path
330
+ const outputPath = options.output || './qa360-pack.yml';
331
+ // Save the pack
332
+ const { writeFileSync } = await import('fs');
333
+ writeFileSync(outputPath, yamlContent, 'utf-8');
334
+ console.log(chalk.bold('Generated QA360 Pack:\n'));
335
+ console.log(chalk.gray('─'.repeat(70)));
336
+ console.log(yamlContent);
337
+ console.log(chalk.gray('─'.repeat(70)));
338
+ console.log(chalk.green(`\n✓ Pack saved to: ${outputPath}`));
339
+ console.log(chalk.gray(`\n[INFO] Tokens: ${response.usage.totalTokens} (${response.usage.promptTokens} in + ${response.usage.completionTokens} out)`));
340
+ console.log(chalk.gray(`[INFO] Provider: ${providerType} (${response.model})\n`));
341
+ // Show usage instructions
342
+ console.log(chalk.yellow('Next steps:'));
343
+ console.log(chalk.gray(` 1. Review the pack: cat ${outputPath}`));
344
+ console.log(chalk.gray(` 2. Run tests: qa360 run ${outputPath}`));
345
+ console.log(chalk.gray(` 3. Or validate: qa360 pack validate ${outputPath}\n`));
346
+ }
347
+ catch (error) {
348
+ spinner.fail('Pack generation failed');
349
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error));
350
+ }
351
+ }
296
352
  /**
297
353
  * Show AI configuration
298
354
  */
@@ -436,6 +492,53 @@ For accessibility tests:
436
492
  };
437
493
  return typePrompts[type] || typePrompts.api;
438
494
  }
495
+ /**
496
+ * Build system prompt for QA360 pack YAML generation
497
+ */
498
+ function buildPackSystemPrompt() {
499
+ return `You are a QA360 pack YAML generator. Generate VALID QA360 v2 pack YAML files.
500
+
501
+ QA360 Pack v2 Structure:
502
+ \`\`\`yaml
503
+ version: 2
504
+ name: test-pack-name
505
+ description: Test pack description
506
+ gates:
507
+ gate-name:
508
+ adapter: playwright-api|playwright-ui|k6-perf
509
+ enabled: true
510
+ config:
511
+ baseUrl: "https://example.com"
512
+ # API adapter: use "smoke" array with "METHOD path -> status" format
513
+ smoke:
514
+ - "GET /api/health -> 200"
515
+ - "POST /api/users -> 201"
516
+ # UI adapter: use "scenario" array or "pages" array
517
+ scenario:
518
+ - action: navigate
519
+ url: /login
520
+ - action: fill
521
+ selector: "#email"
522
+ value: "test@example.com"
523
+ - action: click
524
+ selector: "button[type='submit']"
525
+ options:
526
+ timeout: 10000
527
+ budgets:
528
+ a11y_min: 70
529
+ perf_p95_ms: 3000
530
+ \`\`\`
531
+
532
+ Rules:
533
+ 1. ALWAYS start with "version: 2"
534
+ 2. Use "adapter:" not "type:"
535
+ 3. For API tests: use adapter: playwright-api with smoke: array
536
+ 4. For UI tests: use adapter: playwright-ui with scenario: array or pages: array
537
+ 5. Smoke format: "METHOD path -> status" (e.g., "GET /users -> 200")
538
+ 6. Scenario actions: navigate, fill, click, waitForSelector, assertText, assertVisible
539
+ 7. Return ONLY the YAML, no markdown code blocks, no explanations
540
+ 8. Make sure YAML is valid and properly indented`;
541
+ }
439
542
  /**
440
543
  * Create AI commands
441
544
  */
@@ -486,6 +589,8 @@ export function createAICommands() {
486
589
  .option('-P, --provider <provider>', 'Use specific provider (deepseek, ollama, openai, anthropic)')
487
590
  .option('-t, --type <type>', 'Test type (api, ui, perf, a11y)', 'api')
488
591
  .option('--json', 'Output as JSON')
592
+ .option('--pack', 'Generate a QA360 pack YAML (instead of test code)')
593
+ .option('-o, --output <path>', 'Output file path (for --pack: ./qa360-pack.yml)')
489
594
  .action(async (prompt, options) => {
490
595
  try {
491
596
  await aiGenerateCommand(prompt, options);
@@ -530,9 +530,25 @@ export async function initCommand(options = {}) {
530
530
  try {
531
531
  console.log(chalk.bold.cyan('\n🚀 QA360 Pack Generator (v2)\n'));
532
532
  // Determine output file
533
- const outputFile = options.output
534
- ? resolve(options.output)
535
- : join(process.cwd(), 'qa360.yml');
533
+ let outputFile;
534
+ if (options.output) {
535
+ const resolved = resolve(options.output);
536
+ // Check if it's a directory or if it doesn't have an extension
537
+ if (options.output.endsWith('/') || !options.output.includes('.')) {
538
+ // It's a directory path, create the file inside it
539
+ mkdirSync(resolved, { recursive: true });
540
+ outputFile = join(resolved, 'qa360.yml');
541
+ }
542
+ else {
543
+ // It's a file path, ensure parent directory exists
544
+ const dir = resolve(resolved, '..');
545
+ mkdirSync(dir, { recursive: true });
546
+ outputFile = resolved;
547
+ }
548
+ }
549
+ else {
550
+ outputFile = join(process.cwd(), 'qa360.yml');
551
+ }
536
552
  // Check if file exists
537
553
  if (existsSync(outputFile) && !options.force) {
538
554
  const overwrite = await inquirer.prompt([
@@ -36,6 +36,9 @@ export class AnthropicProvider {
36
36
  }
37
37
  // Try a minimal API call
38
38
  try {
39
+ // Use timeout with AbortController for compatibility
40
+ const controller = new AbortController();
41
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
39
42
  const response = await fetch(`${this.baseURL}/v1/messages`, {
40
43
  method: 'POST',
41
44
  headers: {
@@ -43,13 +46,14 @@ export class AnthropicProvider {
43
46
  'anthropic-version': this.version,
44
47
  'content-type': 'application/json',
45
48
  },
46
- signal: AbortSignal.timeout(5000),
49
+ signal: controller.signal,
47
50
  body: JSON.stringify({
48
51
  model: this.defaultModel,
49
52
  max_tokens: 1,
50
53
  messages: [{ role: 'user', content: 'Hi' }]
51
54
  })
52
55
  });
56
+ clearTimeout(timeoutId);
53
57
  // Anthropic may return 400 for malformed request but still be available
54
58
  // We consider it available if we get any response (not a network error)
55
59
  return response.status !== 401 && response.status !== 403;
@@ -65,46 +69,54 @@ export class AnthropicProvider {
65
69
  });
66
70
  }
67
71
  const messages = this.buildMessages(request);
68
- const response = await fetch(`${this.baseURL}/v1/messages`, {
69
- method: 'POST',
70
- headers: {
71
- 'x-api-key': this.apiKey,
72
- 'anthropic-version': this.version,
73
- 'content-type': 'application/json',
74
- },
75
- signal: AbortSignal.timeout(this.timeout),
76
- body: JSON.stringify({
77
- model: this.defaultModel,
78
- messages,
79
- system: request.systemPrompt,
80
- temperature: request.temperature ?? 0.7,
81
- max_tokens: request.maxTokens ?? 4096,
82
- })
83
- });
84
- if (!response.ok) {
85
- const error = await response.text().catch(() => 'Unknown error');
86
- throw new AnthropicError(`Anthropic request failed: ${response.status} ${error}`, {
87
- status: response.status,
88
- response: error
72
+ // Use timeout with AbortController for compatibility
73
+ const controller = new AbortController();
74
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
75
+ try {
76
+ const response = await fetch(`${this.baseURL}/v1/messages`, {
77
+ method: 'POST',
78
+ headers: {
79
+ 'x-api-key': this.apiKey,
80
+ 'anthropic-version': this.version,
81
+ 'content-type': 'application/json',
82
+ },
83
+ signal: controller.signal,
84
+ body: JSON.stringify({
85
+ model: this.defaultModel,
86
+ messages,
87
+ system: request.systemPrompt,
88
+ temperature: request.temperature ?? 0.7,
89
+ max_tokens: request.maxTokens ?? 4096,
90
+ })
89
91
  });
92
+ if (!response.ok) {
93
+ const error = await response.text().catch(() => 'Unknown error');
94
+ throw new AnthropicError(`Anthropic request failed: ${response.status} ${error}`, {
95
+ status: response.status,
96
+ response: error
97
+ });
98
+ }
99
+ const data = await response.json();
100
+ if (data.error) {
101
+ throw new AnthropicError(`Anthropic API error: ${data.error.message}`, {
102
+ type: data.error.type
103
+ });
104
+ }
105
+ const content = this.extractContent(data.content);
106
+ return {
107
+ content,
108
+ usage: {
109
+ promptTokens: data.usage?.input_tokens || 0,
110
+ completionTokens: data.usage?.output_tokens || 0,
111
+ totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
112
+ },
113
+ model: data.model,
114
+ finishReason: data.stop_reason === 'end_turn' ? 'stop' : 'length'
115
+ };
90
116
  }
91
- const data = await response.json();
92
- if (data.error) {
93
- throw new AnthropicError(`Anthropic API error: ${data.error.message}`, {
94
- type: data.error.type
95
- });
117
+ finally {
118
+ clearTimeout(timeoutId);
96
119
  }
97
- const content = this.extractContent(data.content);
98
- return {
99
- content,
100
- usage: {
101
- promptTokens: data.usage?.input_tokens || 0,
102
- completionTokens: data.usage?.output_tokens || 0,
103
- totalTokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
104
- },
105
- model: data.model,
106
- finishReason: data.stop_reason === 'end_turn' ? 'stop' : 'length'
107
- };
108
120
  }
109
121
  async *stream(request) {
110
122
  if (!this.apiKey) {
@@ -48,13 +48,17 @@ export class DeepSeekProvider {
48
48
  }
49
49
  // Try a minimal API call
50
50
  try {
51
+ // Use timeout with AbortController for compatibility
52
+ const controller = new AbortController();
53
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
51
54
  const response = await fetch(`${this.baseURL}/v1/models`, {
52
55
  method: 'GET',
53
56
  headers: {
54
57
  'Authorization': `Bearer ${this.apiKey}`,
55
58
  },
56
- signal: AbortSignal.timeout(5000),
59
+ signal: controller.signal,
57
60
  });
61
+ clearTimeout(timeoutId);
58
62
  return response.ok;
59
63
  }
60
64
  catch {
@@ -69,42 +73,50 @@ export class DeepSeekProvider {
69
73
  });
70
74
  }
71
75
  const messages = this.buildMessages(request);
72
- const response = await fetch(`${this.baseURL}/v1/chat/completions`, {
73
- method: 'POST',
74
- headers: {
75
- 'Authorization': `Bearer ${this.apiKey}`,
76
- 'Content-Type': 'application/json',
77
- },
78
- signal: AbortSignal.timeout(this.timeout),
79
- body: JSON.stringify({
80
- model: this.defaultModel,
81
- messages,
82
- temperature: request.temperature ?? 0.7,
83
- max_tokens: request.maxTokens ?? 4096,
84
- })
85
- });
86
- if (!response.ok) {
87
- const errorText = await response.text().catch(() => 'Unknown error');
88
- throw new DeepSeekError(`DeepSeek request failed: ${response.status} ${errorText}`, {
89
- status: response.status,
90
- response: errorText
76
+ // Use timeout with AbortController for compatibility
77
+ const controller = new AbortController();
78
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
79
+ try {
80
+ const response = await fetch(`${this.baseURL}/v1/chat/completions`, {
81
+ method: 'POST',
82
+ headers: {
83
+ 'Authorization': `Bearer ${this.apiKey}`,
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ signal: controller.signal,
87
+ body: JSON.stringify({
88
+ model: this.defaultModel,
89
+ messages,
90
+ temperature: request.temperature ?? 0.7,
91
+ max_tokens: request.maxTokens ?? 4096,
92
+ })
91
93
  });
94
+ if (!response.ok) {
95
+ const errorText = await response.text().catch(() => 'Unknown error');
96
+ throw new DeepSeekError(`DeepSeek request failed: ${response.status} ${errorText}`, {
97
+ status: response.status,
98
+ response: errorText
99
+ });
100
+ }
101
+ const data = await response.json();
102
+ const choice = data.choices?.[0];
103
+ if (!choice) {
104
+ throw new DeepSeekError('No choices returned from DeepSeek');
105
+ }
106
+ return {
107
+ content: choice.message.content,
108
+ usage: {
109
+ promptTokens: data.usage?.prompt_tokens || 0,
110
+ completionTokens: data.usage?.completion_tokens || 0,
111
+ totalTokens: data.usage?.total_tokens || 0,
112
+ },
113
+ model: data.model,
114
+ finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
115
+ };
92
116
  }
93
- const data = await response.json();
94
- const choice = data.choices?.[0];
95
- if (!choice) {
96
- throw new DeepSeekError('No choices returned from DeepSeek');
117
+ finally {
118
+ clearTimeout(timeoutId);
97
119
  }
98
- return {
99
- content: choice.message.content,
100
- usage: {
101
- promptTokens: data.usage?.prompt_tokens || 0,
102
- completionTokens: data.usage?.completion_tokens || 0,
103
- totalTokens: data.usage?.total_tokens || 0,
104
- },
105
- model: data.model,
106
- finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
107
- };
108
120
  }
109
121
  async *stream(request) {
110
122
  if (!this.apiKey) {
@@ -32,9 +32,13 @@ export class OllamaProvider {
32
32
  }
33
33
  async isAvailable() {
34
34
  try {
35
+ // Use timeout with AbortSignal - wrap for compatibility
36
+ const controller = new AbortController();
37
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
35
38
  const response = await fetch(`${this.baseUrl}/api/tags`, {
36
- signal: AbortSignal.timeout(5000),
39
+ signal: controller.signal,
37
40
  });
41
+ clearTimeout(timeoutId);
38
42
  return response.ok;
39
43
  }
40
44
  catch {
@@ -43,37 +47,45 @@ export class OllamaProvider {
43
47
  }
44
48
  async generate(request) {
45
49
  const fullPrompt = this.buildFullPrompt(request);
46
- const response = await fetch(`${this.baseUrl}/api/generate`, {
47
- method: 'POST',
48
- headers: { 'Content-Type': 'application/json' },
49
- signal: AbortSignal.timeout(this.timeout),
50
- body: JSON.stringify({
50
+ // Use timeout with AbortController for compatibility
51
+ const controller = new AbortController();
52
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
53
+ try {
54
+ const response = await fetch(`${this.baseUrl}/api/generate`, {
55
+ method: 'POST',
56
+ headers: { 'Content-Type': 'application/json' },
57
+ signal: controller.signal,
58
+ body: JSON.stringify({
59
+ model: this.defaultModel,
60
+ prompt: fullPrompt,
61
+ stream: false,
62
+ options: {
63
+ temperature: request.temperature ?? 0.7,
64
+ num_predict: request.maxTokens ?? 4096,
65
+ }
66
+ })
67
+ });
68
+ if (!response.ok) {
69
+ throw new OllamaError(`Ollama request failed: ${response.status} ${response.statusText}`, { response: await response.text().catch(() => 'Unknown error') });
70
+ }
71
+ const data = await response.json();
72
+ if (data.error) {
73
+ throw new OllamaError(data.error);
74
+ }
75
+ return {
76
+ content: data.response,
77
+ usage: {
78
+ promptTokens: data.prompt_eval_count || 0,
79
+ completionTokens: data.eval_count || 0,
80
+ totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
81
+ },
51
82
  model: this.defaultModel,
52
- prompt: fullPrompt,
53
- stream: false,
54
- options: {
55
- temperature: request.temperature ?? 0.7,
56
- num_predict: request.maxTokens ?? 4096,
57
- }
58
- })
59
- });
60
- if (!response.ok) {
61
- throw new OllamaError(`Ollama request failed: ${response.status} ${response.statusText}`, { response: await response.text().catch(() => 'Unknown error') });
83
+ finishReason: data.done_reason || 'stop',
84
+ };
62
85
  }
63
- const data = await response.json();
64
- if (data.error) {
65
- throw new OllamaError(data.error);
86
+ finally {
87
+ clearTimeout(timeoutId);
66
88
  }
67
- return {
68
- content: data.response,
69
- usage: {
70
- promptTokens: data.prompt_eval_count || 0,
71
- completionTokens: data.eval_count || 0,
72
- totalTokens: (data.prompt_eval_count || 0) + (data.eval_count || 0),
73
- },
74
- model: this.defaultModel,
75
- finishReason: data.done_reason || 'stop',
76
- };
77
89
  }
78
90
  async *stream(request) {
79
91
  const fullPrompt = this.buildFullPrompt(request);
@@ -36,13 +36,17 @@ export class OpenAIProvider {
36
36
  }
37
37
  // Try a minimal API call
38
38
  try {
39
+ // Use timeout with AbortController for compatibility
40
+ const controller = new AbortController();
41
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
39
42
  const response = await fetch(`${this.baseURL}/models`, {
40
43
  method: 'GET',
41
44
  headers: {
42
45
  'Authorization': `Bearer ${this.apiKey}`,
43
46
  },
44
- signal: AbortSignal.timeout(5000),
47
+ signal: controller.signal,
45
48
  });
49
+ clearTimeout(timeoutId);
46
50
  return response.ok;
47
51
  }
48
52
  catch {
@@ -56,42 +60,50 @@ export class OpenAIProvider {
56
60
  });
57
61
  }
58
62
  const messages = this.buildMessages(request);
59
- const response = await fetch(`${this.baseURL}/chat/completions`, {
60
- method: 'POST',
61
- headers: {
62
- 'Authorization': `Bearer ${this.apiKey}`,
63
- 'Content-Type': 'application/json',
64
- },
65
- signal: AbortSignal.timeout(this.timeout),
66
- body: JSON.stringify({
67
- model: this.defaultModel,
68
- messages,
69
- temperature: request.temperature ?? 0.7,
70
- max_tokens: request.maxTokens ?? 4096,
71
- })
72
- });
73
- if (!response.ok) {
74
- const error = await response.text().catch(() => 'Unknown error');
75
- throw new OpenAIError(`OpenAI request failed: ${response.status} ${error}`, {
76
- status: response.status,
77
- response: error
63
+ // Use timeout with AbortController for compatibility
64
+ const controller = new AbortController();
65
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
66
+ try {
67
+ const response = await fetch(`${this.baseURL}/chat/completions`, {
68
+ method: 'POST',
69
+ headers: {
70
+ 'Authorization': `Bearer ${this.apiKey}`,
71
+ 'Content-Type': 'application/json',
72
+ },
73
+ signal: controller.signal,
74
+ body: JSON.stringify({
75
+ model: this.defaultModel,
76
+ messages,
77
+ temperature: request.temperature ?? 0.7,
78
+ max_tokens: request.maxTokens ?? 4096,
79
+ })
78
80
  });
81
+ if (!response.ok) {
82
+ const error = await response.text().catch(() => 'Unknown error');
83
+ throw new OpenAIError(`OpenAI request failed: ${response.status} ${error}`, {
84
+ status: response.status,
85
+ response: error
86
+ });
87
+ }
88
+ const data = await response.json();
89
+ const choice = data.choices?.[0];
90
+ if (!choice) {
91
+ throw new OpenAIError('No choices returned from OpenAI');
92
+ }
93
+ return {
94
+ content: choice.message.content,
95
+ usage: {
96
+ promptTokens: data.usage?.prompt_tokens || 0,
97
+ completionTokens: data.usage?.completion_tokens || 0,
98
+ totalTokens: data.usage?.total_tokens || 0,
99
+ },
100
+ model: data.model,
101
+ finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
102
+ };
79
103
  }
80
- const data = await response.json();
81
- const choice = data.choices?.[0];
82
- if (!choice) {
83
- throw new OpenAIError('No choices returned from OpenAI');
104
+ finally {
105
+ clearTimeout(timeoutId);
84
106
  }
85
- return {
86
- content: choice.message.content,
87
- usage: {
88
- promptTokens: data.usage?.prompt_tokens || 0,
89
- completionTokens: data.usage?.completion_tokens || 0,
90
- totalTokens: data.usage?.total_tokens || 0,
91
- },
92
- model: data.model,
93
- finishReason: choice.finish_reason === 'stop' ? 'stop' : 'length'
94
- };
95
107
  }
96
108
  async *stream(request) {
97
109
  if (!this.apiKey) {
@@ -2,6 +2,7 @@
2
2
  * QA360 Pack v2 Loader
3
3
  *
4
4
  * Loads and parses pack.yml v2 files from the filesystem.
5
+ * Supports environment variable substitution with ${VAR} and ${VAR:-default} syntax.
5
6
  */
6
7
  import { PackConfigV2, PackValidationResultV2, ValidationError } from '../types/pack-v2.js';
7
8
  export interface PackLoadResult {
@@ -2,12 +2,41 @@
2
2
  * QA360 Pack v2 Loader
3
3
  *
4
4
  * Loads and parses pack.yml v2 files from the filesystem.
5
+ * Supports environment variable substitution with ${VAR} and ${VAR:-default} syntax.
5
6
  */
6
7
  import { readFileSync, existsSync } from 'fs';
7
8
  import { resolve } from 'path';
8
9
  import * as yaml from 'js-yaml';
9
10
  import { PackValidatorV2 } from './validator.js';
10
11
  import { PackMigrator } from './migrator.js';
12
+ /**
13
+ * Substitute environment variables in a string
14
+ * Supports ${VAR} and ${VAR:-default} syntax
15
+ */
16
+ function substituteEnvVars(str) {
17
+ return str.replace(/\$\{([^}:]+)(:-([^}]*))?\}/g, (match, varName, _, defaultValue) => {
18
+ return process.env[varName] ?? defaultValue ?? match;
19
+ });
20
+ }
21
+ /**
22
+ * Recursively substitute environment variables in an object
23
+ */
24
+ function substituteEnvVarsRecursive(obj) {
25
+ if (typeof obj === 'string') {
26
+ return substituteEnvVars(obj);
27
+ }
28
+ if (Array.isArray(obj)) {
29
+ return obj.map(substituteEnvVarsRecursive);
30
+ }
31
+ if (obj !== null && typeof obj === 'object') {
32
+ const result = {};
33
+ for (const [key, value] of Object.entries(obj)) {
34
+ result[key] = substituteEnvVarsRecursive(value);
35
+ }
36
+ return result;
37
+ }
38
+ return obj;
39
+ }
11
40
  export class PackLoaderV2 {
12
41
  cache = new Map();
13
42
  /**
@@ -30,16 +59,18 @@ export class PackLoaderV2 {
30
59
  const content = readFileSync(resolvedPath, 'utf-8');
31
60
  // Parse YAML
32
61
  const parsed = yaml.load(content);
62
+ // Substitute environment variables
63
+ const substituted = substituteEnvVarsRecursive(parsed);
33
64
  // Detect version
34
65
  const migrator = new PackMigrator();
35
- const detectedVersion = migrator.detectVersion(parsed);
66
+ const detectedVersion = migrator.detectVersion(substituted);
36
67
  // Set format
37
68
  const format = detectedVersion === 1 ? 'v1' : detectedVersion === 2 ? 'v2' : 'unknown';
38
69
  // For v1 without migration
39
70
  if (detectedVersion === 1 && options?.migrate === false) {
40
71
  return {
41
72
  success: true,
42
- pack: parsed,
73
+ pack: substituted,
43
74
  format: 'v1',
44
75
  migrated: false,
45
76
  sourcePath: resolvedPath
@@ -51,10 +82,10 @@ export class PackLoaderV2 {
51
82
  let migrationWarnings = [];
52
83
  if (detectedVersion === 1) {
53
84
  // Check for legacy format before migrating
54
- if (migrator.isLegacyFormat(parsed)) {
85
+ if (migrator.isLegacyFormat(substituted)) {
55
86
  return {
56
87
  success: false,
57
- error: new Error(`Legacy pack format detected (version ${parsed.version || '0.9.x'}). ` +
88
+ error: new Error(`Legacy pack format detected (version ${substituted.version || '0.9.x'}). ` +
58
89
  `This format is no longer supported.\n\n` +
59
90
  `To fix this:\n` +
60
91
  ` 1. Delete the old pack file: ${resolvedPath}\n` +
@@ -65,7 +96,7 @@ export class PackLoaderV2 {
65
96
  };
66
97
  }
67
98
  // Migrate v1 to v2
68
- const migrationResult = migrator.migrate(parsed);
99
+ const migrationResult = migrator.migrate(substituted);
69
100
  if (!migrationResult.success) {
70
101
  return {
71
102
  success: false,
@@ -80,14 +111,14 @@ export class PackLoaderV2 {
80
111
  migrationWarnings = migrationResult.warnings;
81
112
  }
82
113
  else if (detectedVersion === 2) {
83
- pack = parsed;
114
+ pack = substituted;
84
115
  }
85
116
  else {
86
117
  // Check for legacy format and provide helpful error
87
- if (migrator.isLegacyFormat(parsed)) {
118
+ if (migrator.isLegacyFormat(substituted)) {
88
119
  return {
89
120
  success: false,
90
- error: new Error(`Legacy pack format detected (version ${parsed.version || '0.9.x'}). ` +
121
+ error: new Error(`Legacy pack format detected (version ${substituted.version || '0.9.x'}). ` +
91
122
  `This format is no longer supported.\n\n` +
92
123
  `To fix this:\n` +
93
124
  ` 1. Delete the old pack file: ${resolvedPath}\n` +
@@ -99,7 +130,7 @@ export class PackLoaderV2 {
99
130
  }
100
131
  return {
101
132
  success: false,
102
- error: new Error(`Unknown pack format. Version: ${parsed.version}`),
133
+ error: new Error(`Unknown pack format. Version: ${substituted.version}`),
103
134
  sourcePath: resolvedPath,
104
135
  format: 'unknown'
105
136
  };
@@ -189,13 +220,15 @@ export class PackLoaderV2 {
189
220
  async loadFromString(yamlContent, options) {
190
221
  try {
191
222
  const parsed = yaml.load(yamlContent);
223
+ // Substitute environment variables
224
+ const substituted = substituteEnvVarsRecursive(parsed);
192
225
  // Detect version and migrate if needed
193
226
  const migrator = new PackMigrator();
194
- const detectedVersion = migrator.detectVersion(parsed);
227
+ const detectedVersion = migrator.detectVersion(substituted);
195
228
  let pack;
196
229
  let migrated = false;
197
230
  if (detectedVersion === 1) {
198
- const migrationResult = migrator.migrate(parsed);
231
+ const migrationResult = migrator.migrate(substituted);
199
232
  if (!migrationResult.success) {
200
233
  return {
201
234
  success: false,
@@ -206,12 +239,12 @@ export class PackLoaderV2 {
206
239
  migrated = true;
207
240
  }
208
241
  else if (detectedVersion === 2) {
209
- pack = parsed;
242
+ pack = substituted;
210
243
  }
211
244
  else {
212
245
  return {
213
246
  success: false,
214
- error: new Error(`Unknown pack format. Version: ${parsed.version}`)
247
+ error: new Error(`Unknown pack format. Version: ${substituted.version}`)
215
248
  };
216
249
  }
217
250
  // Validate if requested
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qa360",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
4
4
  "description": "QA360 Proof CLI - Quality as Cryptographic Proof",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",