@microsoft/m365-copilot-eval 1.1.1-preview.1 → 1.2.0-preview.1
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.
- package/README.md +64 -18
- package/package.json +4 -2
- package/schema/CHANGELOG.md +21 -0
- package/schema/v1/eval-document.schema.json +236 -0
- package/schema/v1/examples/invalid/empty-items.json +4 -0
- package/schema/v1/examples/invalid/invalid-semver.json +8 -0
- package/schema/v1/examples/invalid/missing-schema-version.json +7 -0
- package/schema/v1/examples/invalid/wrong-type.json +6 -0
- package/schema/v1/examples/valid/comprehensive.json +92 -0
- package/schema/v1/examples/valid/minimal.json +8 -0
- package/schema/version.json +6 -0
- package/src/clients/cli/custom_evaluators/CitationsEvaluator.py +77 -33
- package/src/clients/cli/main.py +197 -30
- package/src/clients/cli/readme.md +5 -5
- package/src/clients/cli/requirements.txt +2 -0
- package/src/clients/cli/samples/starter.json +13 -10
- package/src/clients/cli/schema_handler.py +349 -0
- package/src/clients/cli/version_check.py +139 -0
- package/src/clients/node-js/bin/runevals.js +34 -103
- package/src/clients/node-js/config/default.js +1 -1
- package/src/clients/node-js/lib/env-loader.js +126 -0
- package/src/clients/node-js/lib/progress.js +36 -36
- package/src/clients/node-js/lib/python-runtime.js +4 -6
- package/src/clients/node-js/lib/venv-manager.js +60 -18
|
@@ -9,6 +9,7 @@ import { ensureVenv, executePythonCli } from '../lib/venv-manager.js';
|
|
|
9
9
|
import { getCacheStats, clearCache, formatBytes } from '../lib/cache-utils.js';
|
|
10
10
|
import { checkPackageExpiry } from '../lib/expiry-check.js';
|
|
11
11
|
import { ProgressReporter } from '../lib/progress.js';
|
|
12
|
+
import { _loadEnvFile as loadEnvFile, _loadUserEnvOverride } from '../lib/env-loader.js';
|
|
12
13
|
|
|
13
14
|
// Check package expiry (exits if expired, warns if close to expiry)
|
|
14
15
|
checkPackageExpiry();
|
|
@@ -49,58 +50,12 @@ async function setDefaultEnvironmentConstants() {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
*
|
|
53
|
+
* Check for required environment variables and provide helpful guidance.
|
|
54
|
+
* @param {string} envName - Environment name (e.g. 'dev')
|
|
55
|
+
* @param {boolean} [quiet=false] - Suppress output
|
|
56
|
+
* @returns {boolean} true if all required vars are present
|
|
53
57
|
*/
|
|
54
|
-
function
|
|
55
|
-
if (!fs.existsSync(envFilePath)) {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
const envVars = {};
|
|
60
|
-
try {
|
|
61
|
-
const content = fs.readFileSync(envFilePath, 'utf-8');
|
|
62
|
-
const lines = content.split('\n');
|
|
63
|
-
|
|
64
|
-
// Protected keys that cannot be overridden from .env files
|
|
65
|
-
const PROTECTED_KEYS = [
|
|
66
|
-
'M365_EVAL_CLIENT_ID',
|
|
67
|
-
'COPILOT_API_ENDPOINT',
|
|
68
|
-
'COPILOT_SCOPES',
|
|
69
|
-
'X_SCENARIO_HEADER'
|
|
70
|
-
];
|
|
71
|
-
|
|
72
|
-
for (const line of lines) {
|
|
73
|
-
const trimmedLine = line.trim();
|
|
74
|
-
if (trimmedLine && !trimmedLine.startsWith('#')) {
|
|
75
|
-
const [key, ...valueParts] = trimmedLine.split('=');
|
|
76
|
-
const keyName = key.trim();
|
|
77
|
-
const value = valueParts.join('=').trim().replace(/['"]/g, '');
|
|
78
|
-
|
|
79
|
-
// Skip built-in defaults - they cannot be overridden.
|
|
80
|
-
if (PROTECTED_KEYS.includes(keyName)) {
|
|
81
|
-
console.warn(`⚠️ Ignoring ${keyName} from .env file (using built-in value)`);
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (keyName && value) {
|
|
86
|
-
envVars[keyName] = value;
|
|
87
|
-
// Also set in process.env for Python script
|
|
88
|
-
process.env[keyName] = value;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch (error) {
|
|
93
|
-
console.error(`Failed to read environment file: ${error.message}`);
|
|
94
|
-
return null;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
return envVars;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Check for required environment variables and provide helpful guidance
|
|
102
|
-
*/
|
|
103
|
-
function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
|
|
58
|
+
function validateEnvironmentVariables(envName, quiet = false) {
|
|
104
59
|
const required = [
|
|
105
60
|
{ key: 'TENANT_ID', description: 'Your Tenant ID' },
|
|
106
61
|
{ key: 'AZURE_AI_OPENAI_ENDPOINT', description: 'Azure OpenAI endpoint URL' },
|
|
@@ -108,8 +63,8 @@ function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
|
|
|
108
63
|
];
|
|
109
64
|
|
|
110
65
|
const detected = [
|
|
111
|
-
{ key: '
|
|
112
|
-
].filter(opt => process.env[opt.key]
|
|
66
|
+
{ key: 'M365_AGENT_ID', description: 'M365 Agent ID' }
|
|
67
|
+
].filter(opt => process.env[opt.key]);
|
|
113
68
|
|
|
114
69
|
const missing = required.filter(req => !process.env[req.key]);
|
|
115
70
|
|
|
@@ -121,41 +76,26 @@ function validateEnvironmentVariables(envName, detectedVars, quiet = false) {
|
|
|
121
76
|
if (!quiet) {
|
|
122
77
|
console.error('\n❌ Missing required environment variables:\n');
|
|
123
78
|
|
|
124
|
-
const envFile = envName ? `env/.env.${envName}` : '.env.local or env
|
|
79
|
+
const envFile = envName ? `env/.env.${envName}` : '.env.local or env/.env.local';
|
|
125
80
|
console.error(`Create ${envFile} with:\n`);
|
|
126
81
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
82
|
+
for (const req of missing) {
|
|
83
|
+
console.error(` ${req.key}="<your-${req.description.toLowerCase().replace(/\s+/g, '-')}>"`);
|
|
84
|
+
}
|
|
130
85
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
86
|
+
if (detected.length > 0) {
|
|
87
|
+
console.error(`\n✓ Already detected:`);
|
|
88
|
+
for (const det of detected) {
|
|
89
|
+
console.error(` ${det.key}="${process.env[det.key]}"`);
|
|
90
|
+
}
|
|
136
91
|
}
|
|
137
|
-
}
|
|
138
92
|
|
|
139
|
-
|
|
93
|
+
console.error('\n📖 Setup guide: https://www.npmjs.com/package/@microsoft/m365-copilot-eval?activeTab=readme\n');
|
|
140
94
|
}
|
|
141
95
|
|
|
142
96
|
return false;
|
|
143
97
|
}
|
|
144
98
|
|
|
145
|
-
/**
|
|
146
|
-
* Construct agent ID from M365 variables
|
|
147
|
-
*/
|
|
148
|
-
function constructAgentId(envVars) {
|
|
149
|
-
const m365TitleId = envVars['M365_TITLE_ID'];
|
|
150
|
-
|
|
151
|
-
if (!m365TitleId) {
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Construct agent ID: {M365_TITLE_ID}.declarativeAgent
|
|
156
|
-
return `${m365TitleId}.declarativeAgent`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
99
|
/**
|
|
160
100
|
* Initialize the Python environment (download, venv, pip install)
|
|
161
101
|
* @param {boolean} [verbose=false] - Enable verbose output
|
|
@@ -209,7 +149,7 @@ async function main() {
|
|
|
209
149
|
.option('--prompts-file <file>', 'JSON file with prompts and expected responses')
|
|
210
150
|
.option('-o, --output <file>', 'output file (JSON, CSV, or HTML)')
|
|
211
151
|
.option('-i, --interactive', 'interactive mode (enter prompts interactively)')
|
|
212
|
-
.option('--agent-id <id>', 'agent ID (overrides env vars and auto-construction)')
|
|
152
|
+
.option('--m365-agent-id <id>', 'agent ID (overrides env vars and auto-construction)')
|
|
213
153
|
.option('--env <environment>', 'environment name (loads env/.env.<environment>)', 'local')
|
|
214
154
|
.option('--init-only', 'only initialize Python environment, don\'t run evaluations')
|
|
215
155
|
.option('--cache-info', 'show cache information and statistics')
|
|
@@ -286,7 +226,7 @@ async function main() {
|
|
|
286
226
|
|
|
287
227
|
// Load environment files
|
|
288
228
|
const envVars = {};
|
|
289
|
-
let resolvedAgentId = options.
|
|
229
|
+
let resolvedAgentId = options.m365AgentId;
|
|
290
230
|
|
|
291
231
|
// Check for .env.local in current directory (ATK projects)
|
|
292
232
|
let localEnvPath = path.join(process.cwd(), '.env.local');
|
|
@@ -301,12 +241,12 @@ async function main() {
|
|
|
301
241
|
localEnvFound = true;
|
|
302
242
|
}
|
|
303
243
|
|
|
304
|
-
// If not found, check for env.local in env subfolder
|
|
244
|
+
// If not found, check for .env.local in env subfolder
|
|
305
245
|
if (!localEnvFound) {
|
|
306
|
-
localEnvPath = path.join(process.cwd(), 'env', 'env.local');
|
|
246
|
+
localEnvPath = path.join(process.cwd(), 'env', '.env.local');
|
|
307
247
|
if (fs.existsSync(localEnvPath)) {
|
|
308
248
|
if (!options.quiet && options.verbose) {
|
|
309
|
-
console.log(`📂 Loading env.local from current directory env folder`);
|
|
249
|
+
console.log(`📂 Loading .env.local from current directory env folder`);
|
|
310
250
|
}
|
|
311
251
|
const localEnvVars = loadEnvFile(localEnvPath) || {};
|
|
312
252
|
Object.assign(envVars, localEnvVars);
|
|
@@ -314,6 +254,9 @@ async function main() {
|
|
|
314
254
|
}
|
|
315
255
|
}
|
|
316
256
|
|
|
257
|
+
// Auto-load .env.local.user as a user-specific override (never checked in, safe for secrets)
|
|
258
|
+
_loadUserEnvOverride(envVars, options);
|
|
259
|
+
|
|
317
260
|
if (options.env) {
|
|
318
261
|
// First check current directory's env folder
|
|
319
262
|
let envFilePath = path.join(process.cwd(), 'env', `.env.${options.env}`);
|
|
@@ -340,15 +283,7 @@ async function main() {
|
|
|
340
283
|
}
|
|
341
284
|
}
|
|
342
285
|
|
|
343
|
-
if (envFileFound) {
|
|
344
|
-
// Auto-construct agent ID if not explicitly provided
|
|
345
|
-
if (!resolvedAgentId) {
|
|
346
|
-
resolvedAgentId = constructAgentId(envVars);
|
|
347
|
-
if (resolvedAgentId && !options.quiet) {
|
|
348
|
-
console.log(`🤖 Agent ID (from M365_TITLE_ID): ${resolvedAgentId}`);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
} else if (options.env !== 'dev') {
|
|
286
|
+
if (!envFileFound && options.env !== 'dev') {
|
|
352
287
|
// Only warn if non-default env specified
|
|
353
288
|
console.warn(`⚠️ Environment file not found: .env.${options.env}`);
|
|
354
289
|
console.warn(` Searched in: ${path.join(process.cwd(), 'env')} and ${path.join(__dirname, '..', 'env')}`);
|
|
@@ -356,21 +291,17 @@ async function main() {
|
|
|
356
291
|
}
|
|
357
292
|
}
|
|
358
293
|
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
294
|
+
// Resolve agent ID from environment if not explicitly provided via CLI flag
|
|
295
|
+
// loadEnvFile already resolved aliases (e.g. M365_TITLE_ID) into M365_AGENT_ID
|
|
296
|
+
if (!resolvedAgentId) {
|
|
297
|
+
resolvedAgentId = envVars['M365_AGENT_ID'] || process.env.M365_AGENT_ID;
|
|
362
298
|
if (resolvedAgentId && !options.quiet) {
|
|
363
|
-
console.log(`🤖 Agent ID
|
|
299
|
+
console.log(`🤖 Agent ID: ${resolvedAgentId}`);
|
|
364
300
|
}
|
|
365
301
|
}
|
|
366
302
|
|
|
367
|
-
// Fallback to M365_AGENT_ID env var if still not resolved
|
|
368
|
-
if (!resolvedAgentId) {
|
|
369
|
-
resolvedAgentId = process.env.M365_AGENT_ID;
|
|
370
|
-
}
|
|
371
|
-
|
|
372
303
|
// Validate required environment variables (always validate, quiet just suppresses output)
|
|
373
|
-
if (!validateEnvironmentVariables(options.env,
|
|
304
|
+
if (!validateEnvironmentVariables(options.env, options.quiet)) {
|
|
374
305
|
if (options.quiet) {
|
|
375
306
|
console.error('📖 Setup guide: https://www.npmjs.com/package/@microsoft/m365-copilot-eval?activeTab=readme\n');
|
|
376
307
|
}
|
|
@@ -383,7 +314,7 @@ async function main() {
|
|
|
383
314
|
if (options.verbose) pythonArgs.push('--verbose');
|
|
384
315
|
if (options.quiet) pythonArgs.push('--quiet');
|
|
385
316
|
if (options.interactive) pythonArgs.push('--interactive');
|
|
386
|
-
if (resolvedAgentId) pythonArgs.push('--agent-id', resolvedAgentId);
|
|
317
|
+
if (resolvedAgentId) pythonArgs.push('--m365-agent-id', resolvedAgentId);
|
|
387
318
|
|
|
388
319
|
// Handle signout
|
|
389
320
|
if (options.signout) {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment file loading utilities.
|
|
3
|
+
* Handles .env.local, .env.local.user, and other env file formats.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
|
|
9
|
+
// Keys that cannot be overridden from .env files (baked in via default.js config)
|
|
10
|
+
const PROTECTED_KEYS = [
|
|
11
|
+
'M365_EVAL_CLIENT_ID',
|
|
12
|
+
'COPILOT_API_ENDPOINT',
|
|
13
|
+
'COPILOT_SCOPES',
|
|
14
|
+
'X_SCENARIO_HEADER',
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
// Aliases resolved into M365_AGENT_ID (first match wins)
|
|
18
|
+
const AGENT_ID_ALIASES = [
|
|
19
|
+
{ key: 'M365_TITLE_ID', transform: (v) => `${v}.declarativeAgent` },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Load environment variables from a .env-style file.
|
|
24
|
+
* Skips blank lines and comments. Protected keys are ignored with a warning.
|
|
25
|
+
* Malformed lines (no '=' separator) are skipped with a warning.
|
|
26
|
+
* @param {string} envFilePath - Absolute path to the env file
|
|
27
|
+
* @returns {Object|null} Parsed key-value pairs, or null if file cannot be read
|
|
28
|
+
*/
|
|
29
|
+
export function _loadEnvFile(envFilePath) {
|
|
30
|
+
if (!fs.existsSync(envFilePath)) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const envVars = {};
|
|
35
|
+
try {
|
|
36
|
+
const content = fs.readFileSync(envFilePath, 'utf-8');
|
|
37
|
+
const lines = content.split('\n');
|
|
38
|
+
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
const trimmedLine = line.trim();
|
|
41
|
+
if (!trimmedLine || trimmedLine.startsWith('#')) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const eqIndex = trimmedLine.indexOf('=');
|
|
46
|
+
if (eqIndex === -1) {
|
|
47
|
+
console.warn(
|
|
48
|
+
`⚠️ Ignoring malformed line in env file (missing '='): ${trimmedLine}`
|
|
49
|
+
);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const keyName = trimmedLine.slice(0, eqIndex).trim();
|
|
54
|
+
const value = trimmedLine
|
|
55
|
+
.slice(eqIndex + 1)
|
|
56
|
+
.trim()
|
|
57
|
+
.replace(/^(['"])(.*)\1$/, '$2');
|
|
58
|
+
|
|
59
|
+
if (!keyName) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (PROTECTED_KEYS.includes(keyName)) {
|
|
64
|
+
console.warn(
|
|
65
|
+
`⚠️ Ignoring ${keyName} from .env file (using built-in value)`
|
|
66
|
+
);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (value) {
|
|
71
|
+
envVars[keyName] = value;
|
|
72
|
+
process.env[keyName] = value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`Failed to read environment file: ${error.message}`);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Resolve agent ID aliases into M365_AGENT_ID (first match wins)
|
|
81
|
+
if (!envVars['M365_AGENT_ID']) {
|
|
82
|
+
for (const alias of AGENT_ID_ALIASES) {
|
|
83
|
+
if (envVars[alias.key]) {
|
|
84
|
+
const agentId = alias.transform
|
|
85
|
+
? alias.transform(envVars[alias.key])
|
|
86
|
+
: envVars[alias.key];
|
|
87
|
+
envVars['M365_AGENT_ID'] = agentId;
|
|
88
|
+
process.env['M365_AGENT_ID'] = agentId;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return envVars;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Auto-load .env.local.user as a user-specific override on top of the base env vars.
|
|
99
|
+
* Checks cwd first, then env/ subfolder (first found wins).
|
|
100
|
+
* This mirrors the ATK convention: .env.local is shared/checked-in, .env.local.user holds personal secrets.
|
|
101
|
+
* @param {Object} envVars - Accumulated env vars object to merge overrides into (mutated in-place)
|
|
102
|
+
* @param {{ quiet?: boolean, verbose?: boolean }} options - CLI options
|
|
103
|
+
* @param {string} [cwd] - Working directory to resolve paths from (defaults to process.cwd())
|
|
104
|
+
* @returns {boolean} Whether a user override file was found and applied
|
|
105
|
+
*/
|
|
106
|
+
export function _loadUserEnvOverride(envVars, options, cwd = process.cwd()) {
|
|
107
|
+
const userEnvPaths = [
|
|
108
|
+
path.join(cwd, '.env.local.user'),
|
|
109
|
+
path.join(cwd, 'env', '.env.local.user'),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
for (const userEnvPath of userEnvPaths) {
|
|
113
|
+
if (fs.existsSync(userEnvPath)) {
|
|
114
|
+
if (!options.quiet && options.verbose) {
|
|
115
|
+
console.log(
|
|
116
|
+
`📂 Loading user overrides from ${path.relative(cwd, userEnvPath)}`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const userEnvVars = _loadEnvFile(userEnvPath) || {};
|
|
120
|
+
Object.assign(envVars, userEnvVars);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
@@ -65,14 +65,18 @@ export function isInteractiveTerminal() {
|
|
|
65
65
|
if (!process.stdout.isTTY) return false;
|
|
66
66
|
|
|
67
67
|
// Common CI environment variables
|
|
68
|
-
if (
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
if (
|
|
69
|
+
process.env.CI ||
|
|
70
|
+
process.env.GITHUB_ACTIONS ||
|
|
71
|
+
process.env.JENKINS_URL ||
|
|
72
|
+
process.env.GITLAB_CI ||
|
|
73
|
+
process.env.TF_BUILD || // Azure Pipelines
|
|
74
|
+
process.env.CIRCLECI ||
|
|
75
|
+
process.env.TRAVIS ||
|
|
76
|
+
process.env.BUILDKITE
|
|
77
|
+
) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
76
80
|
|
|
77
81
|
return true;
|
|
78
82
|
}
|
|
@@ -83,8 +87,7 @@ export function isInteractiveTerminal() {
|
|
|
83
87
|
* @returns {string} Formatted string (e.g., "45.2 MB")
|
|
84
88
|
*/
|
|
85
89
|
export function formatBytes(bytes) {
|
|
86
|
-
if (bytes
|
|
87
|
-
if (bytes < 0) return '0 B';
|
|
90
|
+
if (bytes <= 0) return '0 B';
|
|
88
91
|
|
|
89
92
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
90
93
|
const k = 1024;
|
|
@@ -302,6 +305,26 @@ export class ProgressReporter {
|
|
|
302
305
|
}
|
|
303
306
|
}
|
|
304
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Print verbose message with consistent formatting
|
|
310
|
+
* @param {string} message - Verbose message
|
|
311
|
+
* @param {Object} [options] - Display options
|
|
312
|
+
* @param {boolean} [options.clearInteractiveLine] - Clear current interactive line before printing
|
|
313
|
+
*/
|
|
314
|
+
_printVerboseMessage(message, { clearInteractiveLine = false } = {}) {
|
|
315
|
+
if (!this.options.verbose || !message) return;
|
|
316
|
+
|
|
317
|
+
if (this.isInteractive) {
|
|
318
|
+
if (clearInteractiveLine) {
|
|
319
|
+
readline.clearLine(process.stdout, 0);
|
|
320
|
+
readline.cursorTo(process.stdout, 0);
|
|
321
|
+
}
|
|
322
|
+
console.log(` → ${message}`);
|
|
323
|
+
} else {
|
|
324
|
+
console.log(` → ${message}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
305
328
|
/**
|
|
306
329
|
* Start a new phase
|
|
307
330
|
* @param {string} phaseId - Phase identifier
|
|
@@ -470,13 +493,7 @@ export class ProgressReporter {
|
|
|
470
493
|
}
|
|
471
494
|
|
|
472
495
|
// Verbose mode: show additional details
|
|
473
|
-
|
|
474
|
-
if (this.isInteractive) {
|
|
475
|
-
console.log(`\n → ${message}`);
|
|
476
|
-
} else {
|
|
477
|
-
console.log(` → ${message}`);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
496
|
+
this._printVerboseMessage(message);
|
|
480
497
|
} else if (
|
|
481
498
|
phase.progressType === 'determinate' &&
|
|
482
499
|
current !== undefined &&
|
|
@@ -532,15 +549,7 @@ export class ProgressReporter {
|
|
|
532
549
|
}
|
|
533
550
|
|
|
534
551
|
// Verbose mode: show package name
|
|
535
|
-
|
|
536
|
-
if (this.isInteractive) {
|
|
537
|
-
readline.clearLine(process.stdout, 0);
|
|
538
|
-
readline.cursorTo(process.stdout, 0);
|
|
539
|
-
console.log(` → ${message}`);
|
|
540
|
-
} else {
|
|
541
|
-
console.log(` → ${message}`);
|
|
542
|
-
}
|
|
543
|
-
}
|
|
552
|
+
this._printVerboseMessage(message, { clearInteractiveLine: true });
|
|
544
553
|
} else if (phase.progressType === 'indeterminate') {
|
|
545
554
|
// For indeterminate phases, spinner handles display in interactive mode
|
|
546
555
|
// In CI mode, output periodically
|
|
@@ -553,16 +562,7 @@ export class ProgressReporter {
|
|
|
553
562
|
}
|
|
554
563
|
|
|
555
564
|
// Verbose mode: show message if provided
|
|
556
|
-
|
|
557
|
-
if (this.isInteractive) {
|
|
558
|
-
// Clear spinner line and print message
|
|
559
|
-
readline.clearLine(process.stdout, 0);
|
|
560
|
-
readline.cursorTo(process.stdout, 0);
|
|
561
|
-
console.log(` → ${message}`);
|
|
562
|
-
} else {
|
|
563
|
-
console.log(` → ${message}`);
|
|
564
|
-
}
|
|
565
|
-
}
|
|
565
|
+
this._printVerboseMessage(message, { clearInteractiveLine: true });
|
|
566
566
|
}
|
|
567
567
|
}
|
|
568
568
|
|
|
@@ -230,12 +230,10 @@ export async function ensurePythonRuntime(verbose = false, onProgress) {
|
|
|
230
230
|
const archivePath = path.join(cacheDir, 'downloads', distribution.filename);
|
|
231
231
|
|
|
232
232
|
// Determine Python executable path based on platform
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
pythonExe = path.join(pythonDir, 'bin', 'python3');
|
|
238
|
-
}
|
|
233
|
+
const pythonExe =
|
|
234
|
+
process.platform === 'win32'
|
|
235
|
+
? path.join(pythonDir, 'python.exe')
|
|
236
|
+
: path.join(pythonDir, 'bin', 'python3');
|
|
239
237
|
|
|
240
238
|
// Check if Python is already installed (silent skip per FR-007)
|
|
241
239
|
try {
|
|
@@ -12,19 +12,12 @@ import { getPythonExecutable, getCacheDir } from './python-runtime.js';
|
|
|
12
12
|
* @param {boolean} [options.verbose] - Enable verbose output
|
|
13
13
|
* @param {Function} [options.onStdout] - Callback for stdout data
|
|
14
14
|
*/
|
|
15
|
-
function
|
|
15
|
+
export function _execCommand(command, args, options = {}) {
|
|
16
16
|
return new Promise((resolve, reject) => {
|
|
17
|
-
|
|
18
|
-
// separately with shell:true triggers DEP0190. Join into single command.
|
|
19
|
-
const isWindows = process.platform === 'win32';
|
|
20
|
-
const spawnArgs = isWindows ? [] : args;
|
|
21
|
-
const spawnCommand = isWindows
|
|
22
|
-
? `"${command}" ${args.map((a) => `"${a}"`).join(' ')}`
|
|
23
|
-
: command;
|
|
24
|
-
|
|
25
|
-
const proc = spawn(spawnCommand, spawnArgs, {
|
|
17
|
+
const proc = spawn(command, args, {
|
|
26
18
|
...options,
|
|
27
|
-
shell:
|
|
19
|
+
shell: false,
|
|
20
|
+
windowsVerbatimArguments: false,
|
|
28
21
|
});
|
|
29
22
|
|
|
30
23
|
let stdout = '';
|
|
@@ -109,7 +102,7 @@ async function createVenv(pythonExe, venvDir, verbose = false, onProgress) {
|
|
|
109
102
|
|
|
110
103
|
await fs.mkdir(venvDir, { recursive: true });
|
|
111
104
|
|
|
112
|
-
await
|
|
105
|
+
await _execCommand(pythonExe, ['-m', 'venv', venvDir], { verbose });
|
|
113
106
|
|
|
114
107
|
if (verbose && !onProgress) {
|
|
115
108
|
console.log('Virtual environment created ✓');
|
|
@@ -123,7 +116,7 @@ async function createVenv(pythonExe, venvDir, verbose = false, onProgress) {
|
|
|
123
116
|
* @param {boolean} [verbose=false] - Enable verbose output
|
|
124
117
|
* @param {Function} [onProgress] - Optional progress callback
|
|
125
118
|
*/
|
|
126
|
-
async function
|
|
119
|
+
export async function _installRequirements(
|
|
127
120
|
venvDir,
|
|
128
121
|
requirementsPath,
|
|
129
122
|
verbose = false,
|
|
@@ -140,7 +133,7 @@ async function installRequirements(
|
|
|
140
133
|
}
|
|
141
134
|
|
|
142
135
|
// Upgrade pip first (use python -m pip to avoid Windows file locking issues)
|
|
143
|
-
await
|
|
136
|
+
await _execCommand(venvPython, ['-m', 'pip', 'install', '--upgrade', 'pip'], {
|
|
144
137
|
verbose,
|
|
145
138
|
});
|
|
146
139
|
|
|
@@ -150,8 +143,16 @@ async function installRequirements(
|
|
|
150
143
|
let cachedCount = 0;
|
|
151
144
|
let pipPhase = 'collecting'; // 'collecting', 'resolving', 'downloading', 'installing'
|
|
152
145
|
|
|
153
|
-
// Install requirements with
|
|
154
|
-
const args = [
|
|
146
|
+
// Install requirements with optimization flags
|
|
147
|
+
const args = [
|
|
148
|
+
'-m',
|
|
149
|
+
'pip',
|
|
150
|
+
'install',
|
|
151
|
+
'--prefer-binary', // Prefer binary wheels over building from source
|
|
152
|
+
'--no-compile', // Defer bytecode compilation to after installation for faster install time
|
|
153
|
+
'-r',
|
|
154
|
+
requirementsPath,
|
|
155
|
+
];
|
|
155
156
|
|
|
156
157
|
// Support proxy and certificate configuration
|
|
157
158
|
if (process.env.PIP_CERT) {
|
|
@@ -232,7 +233,48 @@ async function installRequirements(
|
|
|
232
233
|
}
|
|
233
234
|
: undefined;
|
|
234
235
|
|
|
235
|
-
await
|
|
236
|
+
await _execCommand(venvPython, args, { verbose, onStdout });
|
|
237
|
+
|
|
238
|
+
// Compile bytecode after installation to optimize first run performance
|
|
239
|
+
// This compensates for --no-compile flag and eliminates ~1 min delay on first eval
|
|
240
|
+
if (verbose) {
|
|
241
|
+
console.log('Compiling bytecode for faster first run...');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
// Find site-packages directory based on platform
|
|
246
|
+
let sitePackagesDir;
|
|
247
|
+
if (process.platform === 'win32') {
|
|
248
|
+
sitePackagesDir = path.join(venvDir, 'Lib', 'site-packages');
|
|
249
|
+
} else {
|
|
250
|
+
// shell: false means globs aren't expanded by the shell — resolve in Node
|
|
251
|
+
const pattern = path.join(venvDir, 'lib', 'python*', 'site-packages');
|
|
252
|
+
const matches = await Array.fromAsync(fs.glob(pattern));
|
|
253
|
+
sitePackagesDir = matches[0];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!sitePackagesDir) {
|
|
257
|
+
// Skip pre-compilation; bytecode will be compiled lazily on first import
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await _execCommand(
|
|
262
|
+
venvPython,
|
|
263
|
+
['-m', 'compileall', '-q', '-j', '0', sitePackagesDir],
|
|
264
|
+
{ verbose: false }
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (verbose) {
|
|
268
|
+
console.log('Bytecode compilation complete');
|
|
269
|
+
}
|
|
270
|
+
} catch {
|
|
271
|
+
// Non-fatal: bytecode will be compiled on first import if this fails
|
|
272
|
+
if (verbose) {
|
|
273
|
+
console.warn(
|
|
274
|
+
'Warning: Bytecode compilation failed, will compile on first import'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
236
278
|
|
|
237
279
|
if (!onProgress) {
|
|
238
280
|
console.log('Dependencies installed ✓');
|
|
@@ -320,7 +362,7 @@ export async function ensureVenv(
|
|
|
320
362
|
// Install dependencies phase
|
|
321
363
|
onProgress?.({ type: 'start', phaseId: 'deps' });
|
|
322
364
|
try {
|
|
323
|
-
await
|
|
365
|
+
await _installRequirements(venvDir, requirementsPath, verbose, onProgress);
|
|
324
366
|
onProgress?.({ type: 'complete', phaseId: 'deps' });
|
|
325
367
|
} catch (error) {
|
|
326
368
|
onProgress?.({ type: 'error', phaseId: 'deps', error });
|