@arcadialdev/arcality 2.4.24 → 2.4.26

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.
@@ -1,929 +1,932 @@
1
- #!/usr/bin/env node
2
- // scripts/gen-and-run.mjs
3
- // Main Arcality CLI loop — v3 Single Configuration Mode
4
- // Reads from arcality.config instead of multi-config .env approach.
5
-
6
- import 'dotenv/config';
7
- import fs from "node:fs";
8
- import path from "node:path";
9
- import { createRequire } from 'node:module';
10
- import { spawn, exec } from "node:child_process";
11
- import chalk from "chalk";
12
- import { fileURLToPath, pathToFileURL } from 'url';
13
- const require = createRequire(import.meta.url);
14
- import figlet from "figlet";
15
- import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
16
- import { load as loadYaml } from 'js-yaml';
17
- import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
18
- import {
19
- configExists,
20
- loadProjectConfig,
21
- validateConfig,
22
- injectConfigToEnv,
23
- getYamlOutputDir,
24
- ensureYamlOutputDir,
25
- } from '../src/configManager.mjs';
26
-
27
- // Load global config at startup (injects keys into process.env)
28
- setupProcessEnv();
29
-
30
- const __filename = fileURLToPath(import.meta.url);
31
- const __dirname = path.dirname(__filename);
32
- const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
33
-
34
- const ORIGINAL_CWD = process.cwd();
35
-
36
- // ── Load arcality.config if present ──
37
- const projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
38
- if (projectConfig) {
39
- injectConfigToEnv(projectConfig);
40
- }
41
-
42
- function parseDotEnvFile(p) {
43
- try {
44
- const raw = fs.readFileSync(p, 'utf8');
45
- const lines = raw.split(/\r?\n/);
46
- const out = {};
47
- for (const line of lines) {
48
- const trimmed = line.trim();
49
- if (!trimmed || trimmed.startsWith('#')) continue;
50
- const idx = trimmed.indexOf('=');
51
- if (idx === -1) continue;
52
- let k = trimmed.slice(0, idx).trim();
53
- let v = trimmed.slice(idx + 1).trim();
54
- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
55
- out[k] = v;
56
- }
57
- return out;
58
- } catch {
59
- return {};
60
- }
61
- }
62
-
63
- function updateDotEnvFile(updates) {
64
- const p = path.join(PROJECT_ROOT, '.env');
65
- let content = '';
66
- try {
67
- content = fs.readFileSync(p, 'utf8');
68
- } catch {
69
- content = '';
70
- }
71
-
72
- let lines = content.split(/\r?\n/);
73
- const keys = Object.keys(updates);
74
- const updatedKeys = new Set();
75
-
76
- lines = lines.map(line => {
77
- const trimmed = line.trim();
78
- if (!trimmed || trimmed.startsWith('#')) return line;
79
- const idx = trimmed.indexOf('=');
80
- if (idx === -1) return line;
81
- const k = trimmed.slice(0, idx).trim();
82
- if (keys.includes(k)) {
83
- updatedKeys.add(k);
84
- return `${k}=${updates[k]}`;
85
- }
86
- return line;
87
- });
88
-
89
- keys.forEach(k => {
90
- if (!updatedKeys.has(k)) {
91
- lines.push(`${k}=${updates[k]}`);
92
- }
93
- });
94
-
95
- fs.writeFileSync(p, lines.join('\n'), 'utf8');
96
- keys.forEach(k => { process.env[k] = updates[k]; });
97
- }
98
-
99
- function setupEnvironment() {
100
-
101
- // Use config name from arcality.config or fallback
102
- const configName = projectConfig?.project?.name || 'Default';
103
- const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
104
-
105
- const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
106
- const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
107
-
108
- process.env.DOMAIN_DIR = configDir;
109
- process.env.MISSIONS_DIR = path.join(configDir, 'missions');
110
- process.env.CONTEXT_DIR = path.join(configDir, 'context');
111
- process.env.REPORTS_DIR = path.join(configDir, 'reports');
112
-
113
- [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR].forEach(dir => {
114
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
115
- });
116
-
117
- // Also ensure YAML output dir from arcality.config
118
- if (projectConfig) {
119
- ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
120
- }
121
-
122
- // Framework detection from package.json (NO .git)
123
- let techStack = 'Not detected';
124
- try {
125
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
126
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
127
- if (deps['next']) techStack = 'Next.js 🚀';
128
- else if (deps['vite']) techStack = 'Vite ⚡';
129
- else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
130
- } catch { }
131
-
132
- // Override with arcality.config detection if available
133
- if (projectConfig?.project?.frameworkDetected) {
134
- techStack = projectConfig.project.frameworkDetected;
135
- }
136
-
137
- return { configName, baseUrl, techStack, configDir };
138
- }
139
-
140
- function showBanner() {
141
- console.clear();
142
- const projectName = 'Arcality';
143
-
144
- let version = 'unknown';
145
- try {
146
- const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
147
- version = pkg.version || 'unknown';
148
- } catch { }
149
-
150
- const logo = figlet.textSync(projectName, {
151
- font: 'Standard',
152
- horizontalLayout: 'default',
153
- verticalLayout: 'default',
154
- });
155
-
156
- intro(chalk.cyanBright(logo));
157
-
158
- note(
159
- chalk.magentaBright('Powered by Arcadial') + '\n' +
160
- chalk.gray('─'.repeat(50)) + '\n' +
161
- chalk.bold.white('📦 Version: ') + chalk.yellow(version),
162
- 'System Information'
163
- );
164
-
165
- const { configName, baseUrl, techStack, configDir } = setupEnvironment();
166
-
167
- // Show arcality.config status instead of multi-config switching
168
- const hasConfig = !!projectConfig;
169
- const configStatus = hasConfig
170
- ? chalk.green('✅ Configured')
171
- : chalk.red('❌ Not configured — run `arcality init`');
172
-
173
- const { key: apiKeyVal, source: apiKeySource } = getApiKey();
174
- const apiKeyDisplay = apiKeyVal
175
- ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` (${apiKeySource})`)
176
- : chalk.red('Not configured');
177
-
178
- const infoLines = [
179
- chalk.bold.white('⚙️ Environment: ') + chalk.yellow(process.env.NODE_ENV ?? 'development'),
180
- chalk.bold.white('🔑 API Key: ') + apiKeyDisplay,
181
- chalk.bold.white('🛠️ Stack: ') + chalk.magenta(techStack),
182
- ];
183
-
184
- if (hasConfig) {
185
- infoLines.splice(1, 0,
186
- chalk.bold.white('📋 Project: ') + chalk.cyan(configName) + chalk.gray(` (${baseUrl})`),
187
- chalk.bold.white('🆔 Project ID: ') + chalk.gray(projectConfig.projectId || 'N/A'),
188
- );
189
- } else {
190
- infoLines.push(chalk.bold.white('📋 Config: ') + configStatus);
191
- }
192
-
193
- note(infoLines.join('\n'), 'Active Configuration');
194
- }
195
-
196
- const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
197
- const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-3-5-sonnet-20241022";
198
- const OUT_DIR = path.join(PROJECT_ROOT, "tests");
199
- const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
200
-
201
- async function arcalityChat(messages) {
202
- const internalApiUrl = getApiUrl();
203
- const isProxyMode = !!internalApiUrl;
204
-
205
- if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
206
- throw new Error("Missing ANTHROPIC_API_KEY in .env");
207
- }
208
-
209
- const systemMessage = messages.find(m => m.role === 'system');
210
- const userMessages = messages.filter(m => m.role !== 'system');
211
-
212
- try {
213
- const endpointUrl = isProxyMode
214
- ? `${internalApiUrl}/api/v1/ai/proxy`
215
- : ARCALITY_BRAIN_URL;
216
-
217
- const headers = {
218
- "Content-Type": "application/json"
219
- };
220
-
221
- if (isProxyMode) {
222
- headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
223
- } else {
224
- headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
225
- headers["anthropic-version"] = "2023-06-01";
226
- }
227
-
228
- const res = await fetch(endpointUrl, {
229
- method: "POST",
230
- headers,
231
- body: JSON.stringify({
232
- model: ARCALITY_MODEL,
233
- system: systemMessage ? systemMessage.content : undefined,
234
- messages: userMessages,
235
- max_tokens: 4000,
236
- temperature: 0.3
237
- })
238
- });
239
-
240
- if (!res.ok) {
241
- const txt = await res.text();
242
- throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
243
- }
244
-
245
- const data = await res.json();
246
- const content = data.content?.[0]?.text || "";
247
-
248
- try {
249
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
250
- fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
251
- } catch (e) { /* ignore */ }
252
-
253
- return content;
254
- } catch (err) {
255
- throw new Error(`Error communicating with Arcality brain: ${err.message}`);
256
- }
257
- }
258
-
259
- function extractBlocks(text) {
260
- const cleaned = text
261
- .replace(/```[a-zA-Z]*\n?/g, "")
262
- .replace(/```/g, "")
263
- .trim();
264
-
265
- const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
266
- const contentIdx = cleaned.indexOf("CONTENT:");
267
-
268
- if (!fnMatch || contentIdx === -1) {
269
- throw new Error(
270
- `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
271
- );
272
- }
273
-
274
- const filename = fnMatch[1].trim();
275
- const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
276
-
277
- return { filename, content, cleaned };
278
- }
279
-
280
- function validateSpec(ts) {
281
- const errors = [];
282
- if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
283
- errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
284
- }
285
- if (/from\s+['"]playwright['"]/.test(ts)) {
286
- errors.push("Forbidden to import from core. Use only the Arcality SDK.");
287
- }
288
- if (!ts.includes("import { test, expect } from '@playwright/test'")) {
289
- errors.push("Must import exactly the Arcality engine.");
290
- }
291
- return errors;
292
- }
293
-
294
- function sanitizeFilename(name, fallback) {
295
- if (typeof name !== "string") return fallback;
296
- const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
297
- if (!safe.endsWith(".spec.ts")) return fallback;
298
- return safe;
299
- }
300
-
301
- function run(cmd, args, options = {}) {
302
- const isDebug = process.argv.includes('--debug');
303
- const cwd = options.cwd || process.cwd();
304
-
305
- return new Promise((resolve, reject) => {
306
- // NODE_PATH: let Node resolve modules from both the arcality package and user's project
307
- const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
308
- const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
309
- const existingNodePath = process.env.NODE_PATH || '';
310
- const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
311
-
312
- const env = { ...options.env || process.env, NODE_PATH: newNodePath };
313
- if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
314
- if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
315
-
316
- // Use standard cross-platform spawn. When shell is false, Node's child_process
317
- // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
318
- const p = spawn(cmd, args, {
319
- stdio: isDebug ? ['inherit', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
320
- shell: false,
321
- cwd,
322
- env,
323
- });
324
-
325
- let outputBuffer = '';
326
- const handleData = (data) => {
327
- const str = data.toString();
328
- outputBuffer += str;
329
-
330
- if (str.includes('>>ARCALITY_STATUS>>')) {
331
- const lines = str.split('\n');
332
- for (const line of lines) {
333
- if (line.includes('>>ARCALITY_STATUS>>')) {
334
- const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
335
- if (options.onStatus) options.onStatus(status);
336
- } else if (line.trim()) {
337
- process.stdout.write(line + '\n');
338
- }
339
- }
340
- } else {
341
- process.stdout.write(data);
342
- }
343
- };
344
-
345
- if (!isDebug && p.stdout) {
346
- p.stdout.on('data', handleData);
347
- }
348
- if (!isDebug && p.stderr) {
349
- p.stderr.on('data', (data) => {
350
- process.stderr.write(data);
351
- outputBuffer += data.toString();
352
- });
353
- }
354
-
355
- p.on('exit', (code) => {
356
- const lastRunLog = path.join(LOGS_DIR, 'last-run.log');
357
- try {
358
- if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
359
- fs.writeFileSync(lastRunLog, outputBuffer);
360
- } catch (e) { }
361
-
362
- if (code === 0) resolve();
363
- else {
364
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
365
- reject(new Error(`Process exited with code ${code}`));
366
- }
367
- });
368
-
369
- p.on('error', (err) => {
370
- if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
371
- reject(err);
372
- });
373
- });
374
- }
375
-
376
- /**
377
- * Ping the project to register activity.
378
- * Uses internal API URL — not user-configurable.
379
- */
380
- async function pingProject(projectId, apiKey) {
381
- if (!projectId || !apiKey) return;
382
- try {
383
- await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
384
- method: 'PATCH',
385
- headers: { 'x-api-key': apiKey },
386
- });
387
- } catch { /* silent */ }
388
- }
389
-
390
- async function main() {
391
- process.chdir(PROJECT_ROOT);
392
-
393
- // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
394
- let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
395
- if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
396
- updateDotEnvFile({
397
- ACTIVE_CONFIG: 'Default',
398
- SAVED_CONFIGS: 'Default',
399
- Default_URL: initialEnv.BASE_URL || '',
400
- Default_USER: initialEnv.LOGIN_USER || '',
401
- Default_PASS: initialEnv.LOGIN_PASSWORD || ''
402
- });
403
- initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
404
- }
405
-
406
- const argv = process.argv.slice(2);
407
- let initialDiscoverPath = null;
408
- let initialSmartMode = false;
409
- let initialAgentMode = false;
410
-
411
- const dIndex = argv.findIndex(a => a === '--discover' || a.startsWith('--discover='));
412
- if (dIndex !== -1) {
413
- const token = argv[dIndex];
414
- if (token.includes('=')) initialDiscoverPath = token.split('=')[1];
415
- else if (argv[dIndex + 1]) { initialDiscoverPath = argv[dIndex + 1]; argv.splice(dIndex + 1, 1); }
416
- argv.splice(dIndex, 1);
417
- }
418
- const sIndex = argv.findIndex(a => a === '--smart');
419
- if (sIndex !== -1) { initialSmartMode = true; argv.splice(sIndex, 1); }
420
- const aIndex = argv.findIndex(a => a === '--agent');
421
- if (aIndex !== -1) { initialAgentMode = true; argv.splice(aIndex, 1); }
422
- const debugIndex = argv.findIndex(a => a === '--debug');
423
- if (debugIndex !== -1) { argv.splice(debugIndex, 1); } // Handled by run() naturally now
424
-
425
- const promptArg = argv.join(" ").trim();
426
- let firstRun = true;
427
- let globalReportProcess = null;
428
-
429
- setupEnvironment();
430
-
431
- while (true) {
432
- let prompt = firstRun ? promptArg : "";
433
- let agentMode = firstRun ? initialAgentMode : false;
434
- let smartMode = firstRun ? initialSmartMode : false;
435
- let discoverPath = firstRun ? initialDiscoverPath : null;
436
-
437
- let skipMenu = (firstRun && (prompt || agentMode || smartMode)) || agentMode;
438
- if (agentMode) skipMenu = true; // Hard-lock: No menu in agent mode
439
-
440
- firstRun = false;
441
-
442
- if (!skipMenu) {
443
- showBanner();
444
- try {
445
- // ── Build menu options (single config mode) ──
446
- const missionsDir = process.env.MISSIONS_DIR;
447
- const savedMissions = fs.existsSync(missionsDir)
448
- ? fs.readdirSync(missionsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
449
- : [];
450
-
451
- // Check for YAML files in the yaml output dir from arcality.config
452
- let yamlOutputFiles = [];
453
- if (projectConfig) {
454
- const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
455
- if (fs.existsSync(yamlDir)) {
456
- yamlOutputFiles = fs.readdirSync(yamlDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
457
- }
458
- }
459
-
460
- // Root-level YAML templates
461
- const rootYamlFiles = fs.readdirSync(PROJECT_ROOT).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
462
-
463
- const options = [
464
- { label: '🤖 Run Autonomous Agent (Prompt)', value: 'agent' },
465
- ...(savedMissions.length ? [{ label: '🚀 Launch Saved Mission (.yaml)', value: 'launch_saved' }] : []),
466
- ...(yamlOutputFiles.length ? [{ label: '♻️ Reuse Successful YAML', value: 'reuse_yaml' }] : []),
467
- ...(rootYamlFiles.length ? [{ label: '📂 Choose Root YAML (Templates)', value: 'yaml_list' }] : []),
468
- { label: '📋 View Current Configuration', value: 'view_config' },
469
- { label: '🔄 Reconfigure (arcality init)', value: 'reconfigure' },
470
- { label: ' Exit', value: 'exit' }
471
- ];
472
-
473
- const action = await select({
474
- message: 'What would you like to do?',
475
- options
476
- });
477
-
478
- if (isCancel(action) || action === 'exit') {
479
- outro(chalk.cyan('See you later!'));
480
- break;
481
- }
482
-
483
- if (action === 'agent') {
484
- agentMode = true;
485
- } else if (action === 'view_config') {
486
- if (projectConfig) {
487
- note(
488
- chalk.bold.white('📋 Project: ') + chalk.cyan(projectConfig.project?.name) + '\n' +
489
- chalk.bold.white('🌐 Base URL: ') + chalk.cyan(projectConfig.project?.baseUrl) + '\n' +
490
- chalk.bold.white('🛠️ Framework: ') + chalk.magenta(projectConfig.project?.frameworkDetected || 'N/A') + '\n' +
491
- chalk.bold.white('🆔 Project ID: ') + chalk.gray(projectConfig.projectId) + '\n' +
492
- chalk.bold.white('🏢 Org ID: ') + chalk.gray(projectConfig.organizationId || 'N/A') + '\n' +
493
- chalk.bold.white('👤 User: ') + chalk.cyan(projectConfig.auth?.username) + '\n' +
494
- chalk.bold.white('📂 YAML Dir: ') + chalk.yellow(projectConfig.runtime?.yamlOutputDir) + '\n' +
495
- chalk.bold.white('♻️ Reuse YAMLs: ') + chalk.cyan(projectConfig.runtime?.reuseSuccessfulYamls ? '✅' : '❌') + '\n' +
496
- chalk.bold.white('📦 Version: ') + chalk.yellow(projectConfig.meta?.arcalityVersion || 'N/A'),
497
- 'arcality.config'
498
- );
499
- } else {
500
- note(
501
- chalk.yellow('No arcality.config found.') + '\n' +
502
- chalk.gray('Run `arcality init` to configure this project.'),
503
- 'No Configuration'
504
- );
505
- }
506
- continue;
507
- } else if (action === 'reconfigure') {
508
- // Launch arcality init in the user's project directory
509
- const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
510
- try {
511
- await new Promise((resolve, reject) => {
512
- const child = spawn('node', [initScript], {
513
- stdio: 'inherit',
514
- cwd: ORIGINAL_CWD,
515
- env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
516
- });
517
- child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
518
- child.on('error', reject);
519
- });
520
- } catch { /* user cancelled or error */ }
521
- continue;
522
- } else if (action === 'launch_saved') {
523
- const sel = await select({
524
- message: 'Choose saved mission:',
525
- options: savedMissions.map(f => ({ label: f, value: f }))
526
- });
527
- if (isCancel(sel)) continue;
528
-
529
- const yamlContent = fs.readFileSync(path.join(missionsDir, sel), 'utf8');
530
- const data = loadYaml(yamlContent);
531
- prompt = data.prompt || data.mision || yamlContent;
532
- agentMode = true;
533
- } else if (action === 'reuse_yaml') {
534
- const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
535
- const sel = await select({
536
- message: 'Choose YAML to reuse:',
537
- options: yamlOutputFiles.map(f => ({ label: f, value: f }))
538
- });
539
- if (isCancel(sel)) continue;
540
-
541
- const yamlContent = fs.readFileSync(path.join(yamlDir, sel), 'utf8');
542
- const data = loadYaml(yamlContent);
543
- prompt = data.prompt || data.mision || yamlContent;
544
- agentMode = true;
545
- } else if (action === 'yaml_list') {
546
- const sel = await select({
547
- message: 'Choose YAML file:',
548
- options: rootYamlFiles.map(f => ({ label: f, value: f }))
549
- });
550
- if (isCancel(sel)) continue;
551
-
552
- const yamlContent = fs.readFileSync(path.join(PROJECT_ROOT, sel), 'utf8');
553
- const data = loadYaml(yamlContent);
554
- prompt = data.mision || data.prompt || yamlContent;
555
- agentMode = true;
556
- }
557
- } catch (e) {
558
- console.error("Menu error:", e);
559
- break;
560
- }
561
- }
562
-
563
- // --- VALIDATION AND CAPTURE OF PARAMETERS FOR THE AGENT ---
564
- if (agentMode) {
565
- if (!prompt || prompt.trim().length < 4) {
566
- const p = await text({
567
- message: chalk.cyan('🤖 Mission for the Agent (Required):'),
568
- placeholder: 'E.g., Fill the signup form with random data',
569
- validate: v => {
570
- if (!v || !v.trim()) return 'The mission is required to start.';
571
- if (v.trim().length < 4) return 'The mission must be more descriptive (minimum 4 characters).';
572
- }
573
- });
574
-
575
- if (isCancel(p)) {
576
- cancel('Mission cancelled. Returning to main menu...');
577
- agentMode = false;
578
- prompt = "";
579
- skipMenu = false;
580
- continue;
581
- }
582
- prompt = p;
583
- }
584
-
585
- if (!discoverPath) {
586
- const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
587
- const promptMsg = knownBaseUrl
588
- ? `🌐 Initial navigation path (relative to ${knownBaseUrl}):`
589
- : '🌐 Full Navigation URL (e.g., http://localhost:3000/):';
590
-
591
- const d = await text({
592
- message: chalk.cyan(promptMsg),
593
- placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
594
- validate: v => {
595
- if (!knownBaseUrl && !v.startsWith('http')) return 'Please provide a full URL with http:// or https://';
596
- return undefined;
597
- }
598
- });
599
-
600
- if (isCancel(d)) {
601
- cancel('Mission aborted. Returning to menu...');
602
- agentMode = false;
603
- prompt = "";
604
- skipMenu = false;
605
- continue;
606
- }
607
- discoverPath = d;
608
- }
609
- }
610
-
611
- if (agentMode) {
612
- // Load environment from BOTH the user's project and the library's root
613
- const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
614
- const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
615
- const dotEnv = { ...userDotEnv, ...libDotEnv };
616
-
617
- // ── API Key and Quota Validation ──
618
- const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
619
-
620
- const validation = await validateApiKey();
621
- if (!validation.valid) {
622
- const errorMessages = {
623
- 'no_api_key': '🔑 API Key not found.\n Configure it via `arcality init` or in .env (ARCALITY_API_KEY)',
624
- 'invalid_format': '🔑 Invalid API Key. It must start with "arc_k_"',
625
- 'invalid_api_key': '🔑 API Key not recognized by the server',
626
- 'plan_expired': '💳 Your plan has expired'
627
- };
628
- note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Authentication');
629
- agentMode = false;
630
- prompt = "";
631
- skipMenu = false;
632
- continue;
633
- }
634
-
635
- // Show plan info
636
- const modeLabel = validation.mode === 'mock' ? chalk.gray('(local)') : chalk.green('(live)');
637
- const dailyLimitDisplay = validation.daily_limit === -1 ? '∞' : (validation.daily_limit || 50);
638
- const remainingDisplay = validation.remaining >= 999999 ? '' : validation.remaining;
639
- note(
640
- chalk.bold.white('✅ Valid API Key ') + modeLabel + '\n' +
641
- chalk.bold.white('📋 Plan: ') + chalk.cyan(validation.plan || 'internal') + '\n' +
642
- chalk.bold.white('📊 Missions today: ') + chalk.yellow(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
643
- chalk.bold.white('🎯 Remaining: ') + chalk.green(remainingDisplay),
644
- 'Arcality Account'
645
- );
646
-
647
- // --- RESOLVE PROJECT ID ---
648
- let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
649
- const apiBase = getApiUrl();
650
-
651
- if (!selectedProjectId) {
652
- // If no project ID from config, try to create/select from backend
653
- try {
654
- const projRes = await fetch(`${apiBase}/api/v1/projects`, {
655
- headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' }
656
- });
657
-
658
- if (projRes.ok) {
659
- const data = await projRes.json();
660
- const projects = data.projects || [];
661
-
662
- const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
663
- projOptions.push({ label: '➕ Create new project', value: 'new' });
664
-
665
- const projSelection = await select({
666
- message: chalk.cyan('Choose a project for this mission:'),
667
- options: projOptions
668
- });
669
-
670
- if (isCancel(projSelection)) {
671
- cancel('Mission aborted.');
672
- agentMode = false; prompt = ""; skipMenu = false; continue;
673
- }
674
-
675
- if (projSelection === 'new') {
676
- const newName = await text({ message: 'Project name:', validate: v => !v ? 'Required' : undefined });
677
- if (isCancel(newName)) { cancel('Aborted'); agentMode = false; prompt = ""; skipMenu = false; continue; }
678
-
679
- const createRes = await fetch(`${apiBase}/api/v1/projects`, {
680
- method: 'POST',
681
- headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
682
- body: JSON.stringify({
683
- name: newName,
684
- base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
685
- arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
686
- config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
687
- })
688
- });
689
-
690
- if (createRes.ok) {
691
- const created = await createRes.json();
692
- selectedProjectId = created.id || created.Id;
693
- note(chalk.green(`✅ Project created: ${newName}`));
694
- } else {
695
- note(chalk.red(`❌ Failed to create project.`));
696
- }
697
- } else {
698
- selectedProjectId = projSelection;
699
- }
700
-
701
- if (selectedProjectId) {
702
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
703
- }
704
- }
705
- } catch (e) {
706
- console.log(chalk.yellow(`\n⚠️ Error connecting to project server: ${e.message}. Using default.`));
707
- }
708
- } else {
709
- process.env.ARCALITY_PROJECT_ID = selectedProjectId;
710
- }
711
-
712
- // ── Start Mission ──
713
- const mission = await requestMission(prompt, discoverPath || '/');
714
- if (!mission.allowed) {
715
- note(
716
- chalk.red(`❌ ${mission.error === 'mission_limit_exceeded' ? 'Daily mission limit reached' : mission.error}`) + '\n' +
717
- chalk.yellow(`📊 Used: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
718
- chalk.gray(`🔄 Resets: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'tomorrow'}`),
719
- '⚠️ Limit Reached'
720
- );
721
- agentMode = false;
722
- prompt = "";
723
- skipMenu = false;
724
- continue;
725
- }
726
-
727
- const s = spinner();
728
- if (!process.argv.includes('--debug')) {
729
- s.start(`🤖 [AGENT MODE] Starting mission: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit} today)`)}`);
730
- } else {
731
- console.log(chalk.cyan(`\n🤖 [AGENT MODE] Starting mission: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit} today)`)}`));
732
- }
733
-
734
- const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
735
-
736
- // Build env for the runner — merge arcality.config values.
737
- // ARCALITY_API_URL must be explicit — it comes from the library's internal config,
738
- // not from the user's project .env, so we inject it directly via getApiUrl().
739
- const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
740
-
741
- const mergedEnv = {
742
- ...dotEnv,
743
- ...process.env,
744
- ARCALITY_API_URL: getApiUrl(), // Internal URL — always injected explicitly
745
- ARCALITY_PROJECT_ID: finalProjectId,
746
- BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
747
- LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
748
- LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
749
- DOMAIN_DIR: process.env.DOMAIN_DIR,
750
- MISSIONS_DIR: process.env.MISSIONS_DIR,
751
- CONTEXT_DIR: process.env.CONTEXT_DIR,
752
- REPORTS_DIR: process.env.REPORTS_DIR,
753
- SMART_PROMPT: prompt,
754
- TARGET_PATH: discoverPath || '/'
755
- };
756
-
757
- if (finalProjectId) {
758
- console.log(chalk.gray(`\n>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
759
- }
760
-
761
- // ── Resolve execution paths ──
762
- // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
763
- // We run from ORIGINAL_CWD so relative paths for playwright work.
764
- // For the test file and config (inside the arcality package), we use absolute paths.
765
- //
766
- // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
767
-
768
- const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
769
- const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
770
-
771
- // Resolve playwright CLI relative to PROJECT_ROOT exactly as Node handles module resolution.
772
- // This prevents the "did not expect test() to be called here" singleton duplication error.
773
- let playwrightCli;
774
- try {
775
- // Find the core @playwright/test that the local project uses
776
- const testRunnerPath = require.resolve('@playwright/test', { paths: [PROJECT_ROOT] });
777
- // Target the cli.js file strictly inside its parallel playwright dependency
778
- playwrightCli = path.join(testRunnerPath, '..', '..', '..', 'playwright', 'cli.js');
779
- if (!fs.existsSync(playwrightCli)) {
780
- throw new Error(`CLI not found exactly at: ${playwrightCli}`);
781
- }
782
- } catch (e) {
783
- console.error(chalk.red('\n[FATAL] Playwright core not found! Make sure playwright is properly installed.'));
784
- console.error(chalk.red(e.message));
785
- process.exit(1);
786
- }
787
-
788
- try {
789
- // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
790
- // This eliminates the Windows regex path issue permanently on any user's machine.
791
- await run('node', [
792
- '--no-warnings',
793
- playwrightCli,
794
- 'test',
795
- '--headed',
796
- '--config', 'playwright.config.js'
797
- ], {
798
- cwd: PROJECT_ROOT, // Run from the arcality package root
799
- env: mergedEnv,
800
- onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); }
801
- });
802
- if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Mission completed successfully.'));
803
- else console.log(chalk.green('✅ Mission completed successfully.'));
804
-
805
- // ── End Mission ──
806
- const { endMission } = await import('../src/arcalityClient.mjs');
807
- await endMission(mission.mission_id, 'success');
808
-
809
- // ── Ping Project ──
810
- await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
811
-
812
- // ── Save Mission YAML ──
813
- const saveDecision = await select({
814
- message: 'Do you want to save this mission to run it again later?',
815
- options: [
816
- { label: '✅ Yes, save as YAML', value: 'yes' },
817
- { label: ' No, thanks', value: 'no' }
818
- ]
819
- });
820
-
821
- if (saveDecision === 'yes') {
822
- const name = await text({
823
- message: 'Mission name (e.g., valid_login):',
824
- placeholder: 'my_mission',
825
- validate: v => v.length < 3 ? 'Name too short' : undefined
826
- });
827
-
828
- if (!isCancel(name)) {
829
- const safeName = name.trim().replace(/[^a-z0-9]/gi, '_').toLowerCase();
830
-
831
- // Save to missions dir (existing flow)
832
- const missionsDir = process.env.MISSIONS_DIR;
833
- const yamlPathMissions = path.join(missionsDir, `${safeName}.yaml`);
834
-
835
- let yamlData = `name: "${name}"\nprompt: "${prompt}"\nproject: "${projectConfig?.project?.name || 'Default'}"\ncreated_at: "${new Date().toISOString()}"\n`;
836
-
837
- const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
838
- if (fs.existsSync(smartYamlPath)) {
839
- yamlData = fs.readFileSync(smartYamlPath, 'utf8');
840
- }
841
-
842
- // Internal fallback backup for the agent
843
- fs.writeFileSync(yamlPathMissions, yamlData);
844
-
845
- // Primary save to user's specified directory from arcality.config
846
- if (projectConfig) {
847
- const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
848
- const yamlPathOutput = path.join(yamlOutputDir, `${safeName}.yaml`);
849
- fs.writeFileSync(yamlPathOutput, yamlData);
850
- note(chalk.green(`✅ Mission saved at: ${yamlPathOutput}`), 'Arcality Persistence');
851
- } else {
852
- note(chalk.green(`✅ Mission saved at: ${yamlPathMissions}`), 'Arcality Persistence');
853
- }
854
- }
855
- }
856
- } catch (e) {
857
- s.stop(chalk.red(`❌ Mission could not be completed.`));
858
- console.error(chalk.red(`\nError Details: ${e.message}`));
859
- if (e.stack && process.argv.includes('--debug')) {
860
- console.error(chalk.gray(e.stack));
861
- }
862
-
863
- // End mission with failure
864
- try {
865
- const { endMission } = await import('../src/arcalityClient.mjs');
866
- await endMission(mission.mission_id, 'failed');
867
- } catch { }
868
- } finally {
869
- const sRep = spinner();
870
- sRep.start('📊 Generating report...');
871
- await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
872
-
873
- const reportDir = process.env.REPORTS_DIR || 'tests-report';
874
- const arcalityPath = path.resolve(reportDir, 'index.html');
875
-
876
- if (globalReportProcess) {
877
- try {
878
- if (process.platform === "win32") {
879
- spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
880
- } else {
881
- globalReportProcess.kill();
882
- }
883
- } catch (e) { /* ignore */ }
884
- globalReportProcess = null;
885
- }
886
-
887
- if (fs.existsSync(arcalityPath)) {
888
- console.log(chalk.blue(`🌐 Opening Arcality Report: ${arcalityPath}`));
889
- if (process.platform === "win32") {
890
- exec(`start "" "${arcalityPath}"`);
891
- } else {
892
- exec(`open "${arcalityPath}"`);
893
- }
894
- } else {
895
- console.log(chalk.yellow(`⚠️ Report not generated (test may have crashed before completing).`));
896
- }
897
-
898
- await new Promise(resolve => setTimeout(resolve, 2000));
899
- sRep.stop(chalk.cyan('Report process initialized.'));
900
-
901
- // Ping project after report
902
- await pingProject(
903
- process.env.ARCALITY_PROJECT_ID,
904
- process.env.ARCALITY_API_KEY
905
- );
906
-
907
- await text({
908
- message: 'Press Enter to return to menu...',
909
- placeholder: 'Enter'
910
- });
911
-
912
- if (skipMenu) {
913
- outro(chalk.cyan('Mission finished! See you later.'));
914
- return;
915
- }
916
-
917
- agentMode = false;
918
- prompt = "";
919
- firstRun = false;
920
- }
921
- }
922
- firstRun = false;
923
- }
924
- }
925
-
926
- main().catch(err => {
927
- console.error(err);
928
- process.exit(1);
929
- });
1
+ #!/usr/bin/env node
2
+ // scripts/gen-and-run.mjs
3
+ // Main Arcality CLI loop — v3 Single Configuration Mode
4
+ // Reads from arcality.config instead of multi-config .env approach.
5
+
6
+ import 'dotenv/config';
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { createRequire } from 'node:module';
10
+ import { spawn, exec } from "node:child_process";
11
+ import chalk from "chalk";
12
+ import { fileURLToPath, pathToFileURL } from 'url';
13
+ const require = createRequire(import.meta.url);
14
+ import figlet from "figlet";
15
+ import { intro, outro, select, text, spinner, note, isCancel, cancel } from '@clack/prompts';
16
+ import { load as loadYaml } from 'js-yaml';
17
+ import { loadGlobalConfig, getApiKey, getApiUrl, maskApiKey, setupProcessEnv } from '../src/configLoader.mjs';
18
+ import {
19
+ configExists,
20
+ loadProjectConfig,
21
+ validateConfig,
22
+ injectConfigToEnv,
23
+ getYamlOutputDir,
24
+ ensureYamlOutputDir,
25
+ } from '../src/configManager.mjs';
26
+
27
+ // Load global config at startup (injects keys into process.env)
28
+ setupProcessEnv();
29
+
30
+ const __filename = fileURLToPath(import.meta.url);
31
+ const __dirname = path.dirname(__filename);
32
+ const PROJECT_ROOT = process.env.ARCALITY_ROOT || path.join(__dirname, "..");
33
+
34
+ const ORIGINAL_CWD = process.cwd();
35
+
36
+ // ── Load arcality.config if present ──
37
+ const projectConfig = loadProjectConfig(ORIGINAL_CWD) || loadProjectConfig(PROJECT_ROOT);
38
+ if (projectConfig) {
39
+ injectConfigToEnv(projectConfig);
40
+ }
41
+
42
+ function parseDotEnvFile(p) {
43
+ try {
44
+ const raw = fs.readFileSync(p, 'utf8');
45
+ const lines = raw.split(/\r?\n/);
46
+ const out = {};
47
+ for (const line of lines) {
48
+ const trimmed = line.trim();
49
+ if (!trimmed || trimmed.startsWith('#')) continue;
50
+ const idx = trimmed.indexOf('=');
51
+ if (idx === -1) continue;
52
+ let k = trimmed.slice(0, idx).trim();
53
+ let v = trimmed.slice(idx + 1).trim();
54
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
55
+ out[k] = v;
56
+ }
57
+ return out;
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ function updateDotEnvFile(updates) {
64
+ const p = path.join(PROJECT_ROOT, '.env');
65
+ let content = '';
66
+ try {
67
+ content = fs.readFileSync(p, 'utf8');
68
+ } catch {
69
+ content = '';
70
+ }
71
+
72
+ let lines = content.split(/\r?\n/);
73
+ const keys = Object.keys(updates);
74
+ const updatedKeys = new Set();
75
+
76
+ lines = lines.map(line => {
77
+ const trimmed = line.trim();
78
+ if (!trimmed || trimmed.startsWith('#')) return line;
79
+ const idx = trimmed.indexOf('=');
80
+ if (idx === -1) return line;
81
+ const k = trimmed.slice(0, idx).trim();
82
+ if (keys.includes(k)) {
83
+ updatedKeys.add(k);
84
+ return `${k}=${updates[k]}`;
85
+ }
86
+ return line;
87
+ });
88
+
89
+ keys.forEach(k => {
90
+ if (!updatedKeys.has(k)) {
91
+ lines.push(`${k}=${updates[k]}`);
92
+ }
93
+ });
94
+
95
+ fs.writeFileSync(p, lines.join('\n'), 'utf8');
96
+ keys.forEach(k => { process.env[k] = updates[k]; });
97
+ }
98
+
99
+ function setupEnvironment() {
100
+
101
+ // Use config name from arcality.config or fallback
102
+ const configName = projectConfig?.project?.name || 'Default';
103
+ const baseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL || 'http://localhost';
104
+
105
+ const rootOutDir = path.join(ORIGINAL_CWD, '.arcality', 'out');
106
+ const configDir = path.join(rootOutDir, configName.replace(/[^a-zA-Z0-9_-]/g, '_'));
107
+
108
+ process.env.DOMAIN_DIR = configDir;
109
+ process.env.MISSIONS_DIR = path.join(configDir, 'missions');
110
+ process.env.CONTEXT_DIR = path.join(configDir, 'context');
111
+ process.env.REPORTS_DIR = path.join(configDir, 'reports');
112
+
113
+ [process.env.MISSIONS_DIR, process.env.CONTEXT_DIR, process.env.REPORTS_DIR].forEach(dir => {
114
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
115
+ });
116
+
117
+ // Also ensure YAML output dir from arcality.config
118
+ if (projectConfig) {
119
+ ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
120
+ }
121
+
122
+ // Framework detection from package.json (NO .git)
123
+ let techStack = 'Not detected';
124
+ try {
125
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
126
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
127
+ if (deps['next']) techStack = 'Next.js 🚀';
128
+ else if (deps['vite']) techStack = 'Vite ⚡';
129
+ else if (deps['react-scripts']) techStack = 'Create React App ⚛️';
130
+ } catch { }
131
+
132
+ // Override with arcality.config detection if available
133
+ if (projectConfig?.project?.frameworkDetected) {
134
+ techStack = projectConfig.project.frameworkDetected;
135
+ }
136
+
137
+ return { configName, baseUrl, techStack, configDir };
138
+ }
139
+
140
+ function showBanner() {
141
+ console.clear();
142
+ const projectName = 'Arcality';
143
+
144
+ let version = 'unknown';
145
+ try {
146
+ const pkg = JSON.parse(fs.readFileSync(path.join(PROJECT_ROOT, 'package.json'), 'utf8'));
147
+ version = pkg.version || 'unknown';
148
+ } catch { }
149
+
150
+ const logo = figlet.textSync(projectName, {
151
+ font: 'Standard',
152
+ horizontalLayout: 'default',
153
+ verticalLayout: 'default',
154
+ });
155
+
156
+ intro(chalk.cyanBright(logo));
157
+
158
+ note(
159
+ chalk.magentaBright('Powered by Arcadial') + '\n' +
160
+ chalk.gray('─'.repeat(50)) + '\n' +
161
+ chalk.bold.white('📦 Version: ') + chalk.yellow(version),
162
+ 'System Information'
163
+ );
164
+
165
+ const { configName, baseUrl, techStack, configDir } = setupEnvironment();
166
+
167
+ // Show arcality.config status instead of multi-config switching
168
+ const hasConfig = !!projectConfig;
169
+ const configStatus = hasConfig
170
+ ? chalk.green('✅ Configured')
171
+ : chalk.red('❌ Not configured — run `arcality init`');
172
+
173
+ const { key: apiKeyVal, source: apiKeySource } = getApiKey();
174
+ const apiKeyDisplay = apiKeyVal
175
+ ? chalk.green(maskApiKey(apiKeyVal)) + chalk.gray(` (${apiKeySource})`)
176
+ : chalk.red('Not configured');
177
+
178
+ const infoLines = [
179
+ chalk.bold.white('⚙️ Environment: ') + chalk.yellow(process.env.NODE_ENV ?? 'development'),
180
+ chalk.bold.white('🔑 API Key: ') + apiKeyDisplay,
181
+ chalk.bold.white('🛠️ Stack: ') + chalk.magenta(techStack),
182
+ ];
183
+
184
+ if (hasConfig) {
185
+ infoLines.splice(1, 0,
186
+ chalk.bold.white('📋 Project: ') + chalk.cyan(configName) + chalk.gray(` (${baseUrl})`),
187
+ chalk.bold.white('🆔 Project ID: ') + chalk.gray(projectConfig.projectId || 'N/A'),
188
+ );
189
+ } else {
190
+ infoLines.push(chalk.bold.white('📋 Config: ') + configStatus);
191
+ }
192
+
193
+ note(infoLines.join('\n'), 'Active Configuration');
194
+ }
195
+
196
+ const ARCALITY_BRAIN_URL = "https://api.anthropic.com/v1/messages";
197
+ const ARCALITY_MODEL = process.env.CLAUDE_MODEL || "claude-3-5-sonnet-20241022";
198
+ const OUT_DIR = path.join(PROJECT_ROOT, "tests");
199
+ const LOGS_DIR = path.join(PROJECT_ROOT, "logs");
200
+
201
+ async function arcalityChat(messages) {
202
+ const internalApiUrl = getApiUrl();
203
+ const isProxyMode = !!internalApiUrl;
204
+
205
+ if (!process.env.ANTHROPIC_API_KEY && !isProxyMode) {
206
+ throw new Error("Missing ANTHROPIC_API_KEY in .env");
207
+ }
208
+
209
+ const systemMessage = messages.find(m => m.role === 'system');
210
+ const userMessages = messages.filter(m => m.role !== 'system');
211
+
212
+ try {
213
+ const endpointUrl = isProxyMode
214
+ ? `${internalApiUrl}/api/v1/ai/proxy`
215
+ : ARCALITY_BRAIN_URL;
216
+
217
+ const headers = {
218
+ "Content-Type": "application/json"
219
+ };
220
+
221
+ if (isProxyMode) {
222
+ headers["x-api-key"] = process.env.ARCALITY_API_KEY || "";
223
+ const missionId = process.env.ARCALITY_MISSION_ID || '';
224
+ if (missionId) headers["x-mission-id"] = missionId;
225
+ } else {
226
+ headers["x-api-key"] = process.env.ANTHROPIC_API_KEY;
227
+ headers["anthropic-version"] = "2023-06-01";
228
+ }
229
+
230
+ const res = await fetch(endpointUrl, {
231
+ method: "POST",
232
+ headers,
233
+ body: JSON.stringify({
234
+ model: ARCALITY_MODEL,
235
+ system: systemMessage ? systemMessage.content : undefined,
236
+ messages: userMessages,
237
+ max_tokens: 4000,
238
+ temperature: 0.3
239
+ })
240
+ });
241
+
242
+ if (!res.ok) {
243
+ const txt = await res.text();
244
+ throw new Error(`Arcality Brain Error ${res.status}: ${txt}`);
245
+ }
246
+
247
+ const data = await res.json();
248
+ const content = data.content?.[0]?.text || "";
249
+
250
+ try {
251
+ if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
252
+ fs.writeFileSync(path.join(LOGS_DIR, 'last-ai-response.txt'), content);
253
+ } catch (e) { /* ignore */ }
254
+
255
+ return content;
256
+ } catch (err) {
257
+ throw new Error(`Error communicating with Arcality brain: ${err.message}`);
258
+ }
259
+ }
260
+
261
+ function extractBlocks(text) {
262
+ const cleaned = text
263
+ .replace(/```[a-zA-Z]*\n?/g, "")
264
+ .replace(/```/g, "")
265
+ .trim();
266
+
267
+ const fnMatch = cleaned.match(/^FILENAME:\s*(.+)$/m);
268
+ const contentIdx = cleaned.indexOf("CONTENT:");
269
+
270
+ if (!fnMatch || contentIdx === -1) {
271
+ throw new Error(
272
+ `Invalid format. Expected:\nFILENAME: <file.spec.ts>\nCONTENT:\n<code>\n\nResponse:\n${cleaned}`
273
+ );
274
+ }
275
+
276
+ const filename = fnMatch[1].trim();
277
+ const content = cleaned.slice(contentIdx + "CONTENT:".length).trim();
278
+
279
+ return { filename, content, cleaned };
280
+ }
281
+
282
+ function validateSpec(ts) {
283
+ const errors = [];
284
+ if (!/test\s*\(.*async\s*\(\s*\{\s*page\s*\}\s*\)\s*=>/s.test(ts)) {
285
+ errors.push('Missing fixture: test("...", async ({ page }) => { ... })');
286
+ }
287
+ if (/from\s+['"]playwright['"]/.test(ts)) {
288
+ errors.push("Forbidden to import from core. Use only the Arcality SDK.");
289
+ }
290
+ if (!ts.includes("import { test, expect } from '@playwright/test'")) {
291
+ errors.push("Must import exactly the Arcality engine.");
292
+ }
293
+ return errors;
294
+ }
295
+
296
+ function sanitizeFilename(name, fallback) {
297
+ if (typeof name !== "string") return fallback;
298
+ const safe = name.trim().replace(/[<>:"/\\|?*\u0000-\u001F]/g, "_");
299
+ if (!safe.endsWith(".spec.ts")) return fallback;
300
+ return safe;
301
+ }
302
+
303
+ function run(cmd, args, options = {}) {
304
+ const isDebug = process.argv.includes('--debug');
305
+ const cwd = options.cwd || process.cwd();
306
+
307
+ return new Promise((resolve, reject) => {
308
+ // NODE_PATH: let Node resolve modules from both the arcality package and user's project
309
+ const appNodeModules = path.join(PROJECT_ROOT, 'node_modules');
310
+ const userNodeModules = path.join(ORIGINAL_CWD, 'node_modules');
311
+ const existingNodePath = process.env.NODE_PATH || '';
312
+ const newNodePath = [appNodeModules, userNodeModules, existingNodePath].filter(Boolean).join(path.delimiter);
313
+
314
+ const env = { ...options.env || process.env, NODE_PATH: newNodePath };
315
+ if (isDebug) console.log(chalk.gray(`\n[DEBUG] CWD: ${cwd}`));
316
+ if (isDebug) console.log(chalk.gray(`[DEBUG] Running: ${cmd} ${args.join(' ')}`));
317
+
318
+ // Use standard cross-platform spawn. When shell is false, Node's child_process
319
+ // properly wraps arguments with spaces in quotes natively for CreateProcessW on Windows.
320
+ const p = spawn(cmd, args, {
321
+ stdio: isDebug ? ['inherit', 'inherit', 'inherit'] : ['ignore', 'pipe', 'pipe'],
322
+ shell: false,
323
+ cwd,
324
+ env,
325
+ });
326
+
327
+ let outputBuffer = '';
328
+ const handleData = (data) => {
329
+ const str = data.toString();
330
+ outputBuffer += str;
331
+
332
+ if (str.includes('>>ARCALITY_STATUS>>')) {
333
+ const lines = str.split('\n');
334
+ for (const line of lines) {
335
+ if (line.includes('>>ARCALITY_STATUS>>')) {
336
+ const status = line.split('>>ARCALITY_STATUS>>')[1].trim();
337
+ if (options.onStatus) options.onStatus(status);
338
+ } else if (line.trim()) {
339
+ process.stdout.write(line + '\n');
340
+ }
341
+ }
342
+ } else {
343
+ process.stdout.write(data);
344
+ }
345
+ };
346
+
347
+ if (!isDebug && p.stdout) {
348
+ p.stdout.on('data', handleData);
349
+ }
350
+ if (!isDebug && p.stderr) {
351
+ p.stderr.on('data', (data) => {
352
+ process.stderr.write(data);
353
+ outputBuffer += data.toString();
354
+ });
355
+ }
356
+
357
+ p.on('exit', (code) => {
358
+ const lastRunLog = path.join(LOGS_DIR, 'last-run.log');
359
+ try {
360
+ if (!fs.existsSync(LOGS_DIR)) fs.mkdirSync(LOGS_DIR, { recursive: true });
361
+ fs.writeFileSync(lastRunLog, outputBuffer);
362
+ } catch (e) { }
363
+
364
+ if (code === 0) resolve();
365
+ else {
366
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process exited with code ${code}`));
367
+ reject(new Error(`Process exited with code ${code}`));
368
+ }
369
+ });
370
+
371
+ p.on('error', (err) => {
372
+ if (isDebug) console.error(chalk.red(`\n[DEBUG] Process failed to spawn: ${err.message}`));
373
+ reject(err);
374
+ });
375
+ });
376
+ }
377
+
378
+ /**
379
+ * Ping the project to register activity.
380
+ * Uses internal API URL — not user-configurable.
381
+ */
382
+ async function pingProject(projectId, apiKey) {
383
+ if (!projectId || !apiKey) return;
384
+ try {
385
+ await fetch(`${getApiUrl()}/api/v1/projects/${projectId}/ping`, {
386
+ method: 'PATCH',
387
+ headers: { 'x-api-key': apiKey },
388
+ });
389
+ } catch { /* silent */ }
390
+ }
391
+
392
+ async function main() {
393
+ process.chdir(PROJECT_ROOT);
394
+
395
+ // Backward compat: initialize .env if ACTIVE_CONFIG missing (legacy)
396
+ let initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
397
+ if (!projectConfig && !initialEnv.ACTIVE_CONFIG) {
398
+ updateDotEnvFile({
399
+ ACTIVE_CONFIG: 'Default',
400
+ SAVED_CONFIGS: 'Default',
401
+ Default_URL: initialEnv.BASE_URL || '',
402
+ Default_USER: initialEnv.LOGIN_USER || '',
403
+ Default_PASS: initialEnv.LOGIN_PASSWORD || ''
404
+ });
405
+ initialEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
406
+ }
407
+
408
+ const argv = process.argv.slice(2);
409
+ let initialDiscoverPath = null;
410
+ let initialSmartMode = false;
411
+ let initialAgentMode = false;
412
+
413
+ const dIndex = argv.findIndex(a => a === '--discover' || a.startsWith('--discover='));
414
+ if (dIndex !== -1) {
415
+ const token = argv[dIndex];
416
+ if (token.includes('=')) initialDiscoverPath = token.split('=')[1];
417
+ else if (argv[dIndex + 1]) { initialDiscoverPath = argv[dIndex + 1]; argv.splice(dIndex + 1, 1); }
418
+ argv.splice(dIndex, 1);
419
+ }
420
+ const sIndex = argv.findIndex(a => a === '--smart');
421
+ if (sIndex !== -1) { initialSmartMode = true; argv.splice(sIndex, 1); }
422
+ const aIndex = argv.findIndex(a => a === '--agent');
423
+ if (aIndex !== -1) { initialAgentMode = true; argv.splice(aIndex, 1); }
424
+ const debugIndex = argv.findIndex(a => a === '--debug');
425
+ if (debugIndex !== -1) { argv.splice(debugIndex, 1); } // Handled by run() naturally now
426
+
427
+ const promptArg = argv.join(" ").trim();
428
+ let firstRun = true;
429
+ let globalReportProcess = null;
430
+
431
+ setupEnvironment();
432
+
433
+ while (true) {
434
+ let prompt = firstRun ? promptArg : "";
435
+ let agentMode = firstRun ? initialAgentMode : false;
436
+ let smartMode = firstRun ? initialSmartMode : false;
437
+ let discoverPath = firstRun ? initialDiscoverPath : null;
438
+
439
+ let skipMenu = (firstRun && (prompt || agentMode || smartMode)) || agentMode;
440
+ if (agentMode) skipMenu = true; // Hard-lock: No menu in agent mode
441
+
442
+ firstRun = false;
443
+
444
+ if (!skipMenu) {
445
+ showBanner();
446
+ try {
447
+ // ── Build menu options (single config mode) ──
448
+ const missionsDir = process.env.MISSIONS_DIR;
449
+ const savedMissions = fs.existsSync(missionsDir)
450
+ ? fs.readdirSync(missionsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
451
+ : [];
452
+
453
+ // Check for YAML files in the yaml output dir from arcality.config
454
+ let yamlOutputFiles = [];
455
+ if (projectConfig) {
456
+ const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
457
+ if (fs.existsSync(yamlDir)) {
458
+ yamlOutputFiles = fs.readdirSync(yamlDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
459
+ }
460
+ }
461
+
462
+ // Root-level YAML templates
463
+ const rootYamlFiles = fs.readdirSync(PROJECT_ROOT).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
464
+
465
+ const options = [
466
+ { label: '🤖 Run Autonomous Agent (Prompt)', value: 'agent' },
467
+ ...(savedMissions.length ? [{ label: '🚀 Launch Saved Mission (.yaml)', value: 'launch_saved' }] : []),
468
+ ...(yamlOutputFiles.length ? [{ label: '♻️ Reuse YAML + Auto-Guide (if prev. run exists)', value: 'reuse_yaml' }] : []),
469
+ ...(rootYamlFiles.length ? [{ label: '📂 Choose Root YAML (Templates)', value: 'yaml_list' }] : []),
470
+ { label: '📋 View Current Configuration', value: 'view_config' },
471
+ { label: '🔄 Reconfigure (arcality init)', value: 'reconfigure' },
472
+ { label: '❌ Exit', value: 'exit' }
473
+ ];
474
+
475
+ const action = await select({
476
+ message: 'What would you like to do?',
477
+ options
478
+ });
479
+
480
+ if (isCancel(action) || action === 'exit') {
481
+ outro(chalk.cyan('See you later!'));
482
+ break;
483
+ }
484
+
485
+ if (action === 'agent') {
486
+ agentMode = true;
487
+ } else if (action === 'view_config') {
488
+ if (projectConfig) {
489
+ note(
490
+ chalk.bold.white('📋 Project: ') + chalk.cyan(projectConfig.project?.name) + '\n' +
491
+ chalk.bold.white('🌐 Base URL: ') + chalk.cyan(projectConfig.project?.baseUrl) + '\n' +
492
+ chalk.bold.white('🛠️ Framework: ') + chalk.magenta(projectConfig.project?.frameworkDetected || 'N/A') + '\n' +
493
+ chalk.bold.white('🆔 Project ID: ') + chalk.gray(projectConfig.projectId) + '\n' +
494
+ chalk.bold.white('🏢 Org ID: ') + chalk.gray(projectConfig.organizationId || 'N/A') + '\n' +
495
+ chalk.bold.white('👤 User: ') + chalk.cyan(projectConfig.auth?.username) + '\n' +
496
+ chalk.bold.white('📂 YAML Dir: ') + chalk.yellow(projectConfig.runtime?.yamlOutputDir) + '\n' +
497
+ chalk.bold.white('♻️ Reuse YAMLs: ') + chalk.cyan(projectConfig.runtime?.reuseSuccessfulYamls ? '✅' : '❌') + '\n' +
498
+ chalk.bold.white('📦 Version: ') + chalk.yellow(projectConfig.meta?.arcalityVersion || 'N/A'),
499
+ 'arcality.config'
500
+ );
501
+ } else {
502
+ note(
503
+ chalk.yellow('No arcality.config found.') + '\n' +
504
+ chalk.gray('Run `arcality init` to configure this project.'),
505
+ 'No Configuration'
506
+ );
507
+ }
508
+ continue;
509
+ } else if (action === 'reconfigure') {
510
+ // Launch arcality init in the user's project directory
511
+ const initScript = path.join(PROJECT_ROOT, 'scripts', 'init.mjs');
512
+ try {
513
+ await new Promise((resolve, reject) => {
514
+ const child = spawn('node', [initScript], {
515
+ stdio: 'inherit',
516
+ cwd: ORIGINAL_CWD,
517
+ env: { ...process.env, ARCALITY_ROOT: PROJECT_ROOT }
518
+ });
519
+ child.on('exit', code => code === 0 ? resolve() : reject(new Error(`Init exited with ${code}`)));
520
+ child.on('error', reject);
521
+ });
522
+ } catch { /* user cancelled or error */ }
523
+ continue;
524
+ } else if (action === 'launch_saved') {
525
+ const sel = await select({
526
+ message: 'Choose saved mission:',
527
+ options: savedMissions.map(f => ({ label: f, value: f }))
528
+ });
529
+ if (isCancel(sel)) continue;
530
+
531
+ const yamlContent = fs.readFileSync(path.join(missionsDir, sel), 'utf8');
532
+ const data = loadYaml(yamlContent);
533
+ prompt = data.prompt || data.mision || yamlContent;
534
+ agentMode = true;
535
+ } else if (action === 'reuse_yaml') {
536
+ const yamlDir = getYamlOutputDir(projectConfig, ORIGINAL_CWD);
537
+ const sel = await select({
538
+ message: 'Choose YAML to reuse:',
539
+ options: yamlOutputFiles.map(f => ({ label: f, value: f }))
540
+ });
541
+ if (isCancel(sel)) continue;
542
+
543
+ const yamlContent = fs.readFileSync(path.join(yamlDir, sel), 'utf8');
544
+ const data = loadYaml(yamlContent);
545
+ prompt = data.prompt || data.mision || yamlContent;
546
+ agentMode = true;
547
+ } else if (action === 'yaml_list') {
548
+ const sel = await select({
549
+ message: 'Choose YAML file:',
550
+ options: rootYamlFiles.map(f => ({ label: f, value: f }))
551
+ });
552
+ if (isCancel(sel)) continue;
553
+
554
+ const yamlContent = fs.readFileSync(path.join(PROJECT_ROOT, sel), 'utf8');
555
+ const data = loadYaml(yamlContent);
556
+ prompt = data.mision || data.prompt || yamlContent;
557
+ agentMode = true;
558
+ }
559
+ } catch (e) {
560
+ console.error("Menu error:", e);
561
+ break;
562
+ }
563
+ }
564
+
565
+ // --- VALIDATION AND CAPTURE OF PARAMETERS FOR THE AGENT ---
566
+ if (agentMode) {
567
+ if (!prompt || prompt.trim().length < 4) {
568
+ const p = await text({
569
+ message: chalk.cyan('🤖 Mission for the Agent (Required):'),
570
+ placeholder: 'E.g., Fill the signup form with random data',
571
+ validate: v => {
572
+ if (!v || !v.trim()) return 'The mission is required to start.';
573
+ if (v.trim().length < 4) return 'The mission must be more descriptive (minimum 4 characters).';
574
+ }
575
+ });
576
+
577
+ if (isCancel(p)) {
578
+ cancel('Mission cancelled. Returning to main menu...');
579
+ agentMode = false;
580
+ prompt = "";
581
+ skipMenu = false;
582
+ continue;
583
+ }
584
+ prompt = p;
585
+ }
586
+
587
+ if (!discoverPath) {
588
+ const knownBaseUrl = projectConfig?.project?.baseUrl || process.env.BASE_URL;
589
+ const promptMsg = knownBaseUrl
590
+ ? `🌐 Initial navigation path (relative to ${knownBaseUrl}):`
591
+ : '🌐 Full Navigation URL (e.g., http://localhost:3000/):';
592
+
593
+ const d = await text({
594
+ message: chalk.cyan(promptMsg),
595
+ placeholder: knownBaseUrl ? '/' : 'http://localhost:3000/',
596
+ validate: v => {
597
+ if (!knownBaseUrl && !v.startsWith('http')) return 'Please provide a full URL with http:// or https://';
598
+ return undefined;
599
+ }
600
+ });
601
+
602
+ if (isCancel(d)) {
603
+ cancel('Mission aborted. Returning to menu...');
604
+ agentMode = false;
605
+ prompt = "";
606
+ skipMenu = false;
607
+ continue;
608
+ }
609
+ discoverPath = d;
610
+ }
611
+ }
612
+
613
+ if (agentMode) {
614
+ // Load environment from BOTH the user's project and the library's root
615
+ const userDotEnv = parseDotEnvFile(path.join(ORIGINAL_CWD, '.env'));
616
+ const libDotEnv = parseDotEnvFile(path.join(PROJECT_ROOT, '.env'));
617
+ const dotEnv = { ...userDotEnv, ...libDotEnv };
618
+
619
+ // ── API Key and Quota Validation ──
620
+ const { validateApiKey, startMission: requestMission } = await import('../src/arcalityClient.mjs');
621
+
622
+ const validation = await validateApiKey();
623
+ if (!validation.valid) {
624
+ const errorMessages = {
625
+ 'no_api_key': '🔑 API Key not found.\n Configure it via `arcality init` or in .env (ARCALITY_API_KEY)',
626
+ 'invalid_format': '🔑 Invalid API Key. It must start with "arc_k_"',
627
+ 'invalid_api_key': '🔑 API Key not recognized by the server',
628
+ 'plan_expired': '💳 Your plan has expired'
629
+ };
630
+ note(chalk.red(errorMessages[validation.error] || `Error: ${validation.error}`), '❌ Authentication');
631
+ agentMode = false;
632
+ prompt = "";
633
+ skipMenu = false;
634
+ continue;
635
+ }
636
+
637
+ // Show plan info
638
+ const modeLabel = validation.mode === 'mock' ? chalk.gray('(local)') : chalk.green('(live)');
639
+ const dailyLimitDisplay = validation.daily_limit === -1 ? '∞' : (validation.daily_limit || 50);
640
+ const remainingDisplay = validation.remaining >= 999999 ? '' : validation.remaining;
641
+ note(
642
+ chalk.bold.white(' Valid API Key ') + modeLabel + '\n' +
643
+ chalk.bold.white('📋 Plan: ') + chalk.cyan(validation.plan || 'internal') + '\n' +
644
+ chalk.bold.white('📊 Missions today: ') + chalk.yellow(`${validation.daily_used || 0}/${dailyLimitDisplay}`) + '\n' +
645
+ chalk.bold.white('🎯 Remaining: ') + chalk.green(remainingDisplay),
646
+ 'Arcality Account'
647
+ );
648
+
649
+ // --- RESOLVE PROJECT ID ---
650
+ let selectedProjectId = projectConfig?.projectId || dotEnv.ARCALITY_PROJECT_ID || process.env.ARCALITY_PROJECT_ID;
651
+ const apiBase = getApiUrl();
652
+
653
+ if (!selectedProjectId) {
654
+ // If no project ID from config, try to create/select from backend
655
+ try {
656
+ const projRes = await fetch(`${apiBase}/api/v1/projects`, {
657
+ headers: { 'x-api-key': process.env.ARCALITY_API_KEY || '' }
658
+ });
659
+
660
+ if (projRes.ok) {
661
+ const data = await projRes.json();
662
+ const projects = data.projects || [];
663
+
664
+ const projOptions = projects.map(p => ({ label: p.name || p.Name, value: p.id || p.Id }));
665
+ projOptions.push({ label: '➕ Create new project', value: 'new' });
666
+
667
+ const projSelection = await select({
668
+ message: chalk.cyan('Choose a project for this mission:'),
669
+ options: projOptions
670
+ });
671
+
672
+ if (isCancel(projSelection)) {
673
+ cancel('Mission aborted.');
674
+ agentMode = false; prompt = ""; skipMenu = false; continue;
675
+ }
676
+
677
+ if (projSelection === 'new') {
678
+ const newName = await text({ message: 'Project name:', validate: v => !v ? 'Required' : undefined });
679
+ if (isCancel(newName)) { cancel('Aborted'); agentMode = false; prompt = ""; skipMenu = false; continue; }
680
+
681
+ const createRes = await fetch(`${apiBase}/api/v1/projects`, {
682
+ method: 'POST',
683
+ headers: { 'Content-Type': 'application/json', 'x-api-key': process.env.ARCALITY_API_KEY || '' },
684
+ body: JSON.stringify({
685
+ name: newName,
686
+ base_url: projectConfig?.project?.baseUrl || discoverPath || '/',
687
+ arcality_version: projectConfig?.meta?.arcalityVersion || 'unknown',
688
+ config: { source: 'npm-cli', single_configuration_mode: true, yaml_reuse_enabled: true }
689
+ })
690
+ });
691
+
692
+ if (createRes.ok) {
693
+ const created = await createRes.json();
694
+ selectedProjectId = created.id || created.Id;
695
+ note(chalk.green(`✅ Project created: ${newName}`));
696
+ } else {
697
+ note(chalk.red(`❌ Failed to create project.`));
698
+ }
699
+ } else {
700
+ selectedProjectId = projSelection;
701
+ }
702
+
703
+ if (selectedProjectId) {
704
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
705
+ }
706
+ }
707
+ } catch (e) {
708
+ console.log(chalk.yellow(`\n⚠️ Error connecting to project server: ${e.message}. Using default.`));
709
+ }
710
+ } else {
711
+ process.env.ARCALITY_PROJECT_ID = selectedProjectId;
712
+ }
713
+
714
+ // ── Start Mission ──
715
+ const mission = await requestMission(prompt, discoverPath || '/');
716
+ if (!mission.allowed) {
717
+ note(
718
+ chalk.red(`❌ ${mission.error === 'mission_limit_exceeded' ? 'Daily mission limit reached' : mission.error}`) + '\n' +
719
+ chalk.yellow(`📊 Used: ${mission.daily_used}/${mission.daily_limit}`) + '\n' +
720
+ chalk.gray(`🔄 Resets: ${mission.resets_at ? new Date(mission.resets_at).toLocaleString() : 'tomorrow'}`),
721
+ '⚠️ Limit Reached'
722
+ );
723
+ agentMode = false;
724
+ prompt = "";
725
+ skipMenu = false;
726
+ continue;
727
+ }
728
+
729
+ const s = spinner();
730
+ if (!process.argv.includes('--debug')) {
731
+ s.start(`🤖 [AGENT MODE] Starting mission: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit} today)`)}`);
732
+ } else {
733
+ console.log(chalk.cyan(`\n🤖 [AGENT MODE] Starting mission: "${prompt}" ${chalk.gray(`(${mission.daily_used}/${mission.daily_limit} today)`)}`));
734
+ }
735
+
736
+ const finalProjectId = process.env.ARCALITY_PROJECT_ID || selectedProjectId;
737
+
738
+ // Build env for the runner merge arcality.config values.
739
+ // ARCALITY_API_URL must be explicit it comes from the library's internal config,
740
+ // not from the user's project .env, so we inject it directly via getApiUrl().
741
+ const fallbackBaseUrl = (!projectConfig && discoverPath?.startsWith('http')) ? discoverPath : undefined;
742
+
743
+ const mergedEnv = {
744
+ ...dotEnv,
745
+ ...process.env,
746
+ ARCALITY_API_URL: getApiUrl(), // Internal URL always injected explicitly
747
+ ARCALITY_PROJECT_ID: finalProjectId,
748
+ ARCALITY_MISSION_ID: mission.mission_id || '', // Propagate mission_id to every proxy call
749
+ BASE_URL: projectConfig?.project?.baseUrl || dotEnv.BASE_URL || process.env.BASE_URL || fallbackBaseUrl || 'http://localhost:3000',
750
+ LOGIN_USER: projectConfig?.auth?.username || dotEnv.LOGIN_USER || process.env.LOGIN_USER,
751
+ LOGIN_PASSWORD: projectConfig?.auth?.password || dotEnv.LOGIN_PASSWORD || process.env.LOGIN_PASSWORD,
752
+ DOMAIN_DIR: process.env.DOMAIN_DIR,
753
+ MISSIONS_DIR: process.env.MISSIONS_DIR,
754
+ CONTEXT_DIR: process.env.CONTEXT_DIR,
755
+ REPORTS_DIR: process.env.REPORTS_DIR,
756
+ SMART_PROMPT: prompt,
757
+ TARGET_PATH: discoverPath || '/'
758
+ };
759
+
760
+ if (finalProjectId) {
761
+ console.log(chalk.gray(`\n>> ARCALITY_PROJECT_ID: ${finalProjectId}`));
762
+ }
763
+
764
+ // ── Resolve execution paths ──
765
+ // tsx and playwright are HOISTED to ORIGINAL_CWD/node_modules when installed via npm.
766
+ // We run from ORIGINAL_CWD so relative paths for playwright work.
767
+ // For the test file and config (inside the arcality package), we use absolute paths.
768
+ //
769
+ // KEY: tsx loader uses file:// URL (pathToFileURL) so spaces encode as %20 — no quoting needed.
770
+
771
+ const testFile = path.join(PROJECT_ROOT, 'tests', '_helpers', 'agentic-runner.spec.ts');
772
+ const configFile = path.join(PROJECT_ROOT, 'playwright.config.js');
773
+
774
+ // Resolve playwright CLI relative to PROJECT_ROOT exactly as Node handles module resolution.
775
+ // This prevents the "did not expect test() to be called here" singleton duplication error.
776
+ let playwrightCli;
777
+ try {
778
+ // Find the core @playwright/test that the local project uses
779
+ const testRunnerPath = require.resolve('@playwright/test', { paths: [PROJECT_ROOT] });
780
+ // Target the cli.js file strictly inside its parallel playwright dependency
781
+ playwrightCli = path.join(testRunnerPath, '..', '..', '..', 'playwright', 'cli.js');
782
+ if (!fs.existsSync(playwrightCli)) {
783
+ throw new Error(`CLI not found exactly at: ${playwrightCli}`);
784
+ }
785
+ } catch (e) {
786
+ console.error(chalk.red('\n[FATAL] Playwright core not found! Make sure playwright is properly installed.'));
787
+ console.error(chalk.red(e.message));
788
+ process.exit(1);
789
+ }
790
+
791
+ try {
792
+ // No test file path is passed — playwright.config.js uses testMatch:'agentic-runner.spec.ts'
793
+ // This eliminates the Windows regex path issue permanently on any user's machine.
794
+ await run('node', [
795
+ '--no-warnings',
796
+ playwrightCli,
797
+ 'test',
798
+ '--headed',
799
+ '--config', 'playwright.config.js'
800
+ ], {
801
+ cwd: PROJECT_ROOT, // Run from the arcality package root
802
+ env: mergedEnv,
803
+ onStatus: (msg) => { if (!process.argv.includes('--debug')) s.message(msg); else console.log(chalk.gray(`>> Status: ${msg}`)); }
804
+ });
805
+ if (!process.argv.includes('--debug')) s.stop(chalk.green('✅ Mission completed successfully.'));
806
+ else console.log(chalk.green('✅ Mission completed successfully.'));
807
+
808
+ // ── End Mission ──
809
+ const { endMission } = await import('../src/arcalityClient.mjs');
810
+ await endMission(mission.mission_id, 'success');
811
+
812
+ // ── Ping Project ──
813
+ await pingProject(finalProjectId, process.env.ARCALITY_API_KEY);
814
+
815
+ // ── Save Mission YAML ──
816
+ const saveDecision = await select({
817
+ message: 'Do you want to save this mission to run it again later?',
818
+ options: [
819
+ { label: '✅ Yes, save as YAML', value: 'yes' },
820
+ { label: '❌ No, thanks', value: 'no' }
821
+ ]
822
+ });
823
+
824
+ if (saveDecision === 'yes') {
825
+ const name = await text({
826
+ message: 'Mission name (e.g., valid_login):',
827
+ placeholder: 'my_mission',
828
+ validate: v => v.length < 3 ? 'Name too short' : undefined
829
+ });
830
+
831
+ if (!isCancel(name)) {
832
+ const safeName = name.trim().replace(/[^a-z0-9]/gi, '_').toLowerCase();
833
+
834
+ // Save to missions dir (existing flow)
835
+ const missionsDir = process.env.MISSIONS_DIR;
836
+ const yamlPathMissions = path.join(missionsDir, `${safeName}.yaml`);
837
+
838
+ let yamlData = `name: "${name}"\nprompt: "${prompt}"\nproject: "${projectConfig?.project?.name || 'Default'}"\ncreated_at: "${new Date().toISOString()}"\n`;
839
+
840
+ const smartYamlPath = path.join(process.env.CONTEXT_DIR, 'last-mission-smart.yaml');
841
+ if (fs.existsSync(smartYamlPath)) {
842
+ yamlData = fs.readFileSync(smartYamlPath, 'utf8');
843
+ }
844
+
845
+ // Internal fallback backup for the agent
846
+ fs.writeFileSync(yamlPathMissions, yamlData);
847
+
848
+ // Primary save to user's specified directory from arcality.config
849
+ if (projectConfig) {
850
+ const yamlOutputDir = ensureYamlOutputDir(projectConfig, ORIGINAL_CWD);
851
+ const yamlPathOutput = path.join(yamlOutputDir, `${safeName}.yaml`);
852
+ fs.writeFileSync(yamlPathOutput, yamlData);
853
+ note(chalk.green(`✅ Mission saved at: ${yamlPathOutput}`), 'Arcality Persistence');
854
+ } else {
855
+ note(chalk.green(`✅ Mission saved at: ${yamlPathMissions}`), 'Arcality Persistence');
856
+ }
857
+ }
858
+ }
859
+ } catch (e) {
860
+ s.stop(chalk.red(`❌ Mission could not be completed.`));
861
+ console.error(chalk.red(`\nError Details: ${e.message}`));
862
+ if (e.stack && process.argv.includes('--debug')) {
863
+ console.error(chalk.gray(e.stack));
864
+ }
865
+
866
+ // End mission with failure
867
+ try {
868
+ const { endMission } = await import('../src/arcalityClient.mjs');
869
+ await endMission(mission.mission_id, 'failed');
870
+ } catch { }
871
+ } finally {
872
+ const sRep = spinner();
873
+ sRep.start('📊 Generating report...');
874
+ await run("node", [path.join(PROJECT_ROOT, "scripts/rebrand-report.mjs")]).catch(() => { });
875
+
876
+ const reportDir = process.env.REPORTS_DIR || 'tests-report';
877
+ const arcalityPath = path.resolve(reportDir, 'index.html');
878
+
879
+ if (globalReportProcess) {
880
+ try {
881
+ if (process.platform === "win32") {
882
+ spawn("taskkill", ["/pid", globalReportProcess.pid, "/f", "/t"], { stdio: 'ignore', windowsHide: true });
883
+ } else {
884
+ globalReportProcess.kill();
885
+ }
886
+ } catch (e) { /* ignore */ }
887
+ globalReportProcess = null;
888
+ }
889
+
890
+ if (fs.existsSync(arcalityPath)) {
891
+ console.log(chalk.blue(`🌐 Opening Arcality Report: ${arcalityPath}`));
892
+ if (process.platform === "win32") {
893
+ exec(`start "" "${arcalityPath}"`);
894
+ } else {
895
+ exec(`open "${arcalityPath}"`);
896
+ }
897
+ } else {
898
+ console.log(chalk.yellow(`⚠️ Report not generated (test may have crashed before completing).`));
899
+ }
900
+
901
+ await new Promise(resolve => setTimeout(resolve, 2000));
902
+ sRep.stop(chalk.cyan('Report process initialized.'));
903
+
904
+ // Ping project after report
905
+ await pingProject(
906
+ process.env.ARCALITY_PROJECT_ID,
907
+ process.env.ARCALITY_API_KEY
908
+ );
909
+
910
+ await text({
911
+ message: 'Press Enter to return to menu...',
912
+ placeholder: 'Enter'
913
+ });
914
+
915
+ if (skipMenu) {
916
+ outro(chalk.cyan('Mission finished! See you later.'));
917
+ return;
918
+ }
919
+
920
+ agentMode = false;
921
+ prompt = "";
922
+ firstRun = false;
923
+ }
924
+ }
925
+ firstRun = false;
926
+ }
927
+ }
928
+
929
+ main().catch(err => {
930
+ console.error(err);
931
+ process.exit(1);
932
+ });