qa360 2.1.5 → 2.1.6

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.
@@ -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.6",
4
4
  "description": "QA360 Proof CLI - Quality as Cryptographic Proof",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",