@vectorasystems/cli 0.1.3 → 0.2.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/bin/vectora.js CHANGED
@@ -94,6 +94,14 @@ projects
94
94
  await m.remove(id);
95
95
  });
96
96
 
97
+ projects
98
+ .command('archive <id>')
99
+ .description('Archive a project')
100
+ .action(async (id) => {
101
+ const m = await import('../src/commands/projects.js');
102
+ await m.archive(id);
103
+ });
104
+
97
105
  // ── Chat ──────────────────────────────────────────────────────────────────────
98
106
  program
99
107
  .command('chat')
@@ -202,6 +210,103 @@ config
202
210
  await m.reset();
203
211
  });
204
212
 
213
+ // ── Validate ─────────────────────────────────────────────────────────────────
214
+ program
215
+ .command('validate')
216
+ .description('Run 4-agent validation pipeline on the idea')
217
+ .option('-p, --project <id>', 'Project ID (defaults to active)')
218
+ .option('-f, --format <fmt>', 'Output format: table or json')
219
+ .action(async (opts) => {
220
+ const m = await import('../src/commands/validate.js');
221
+ await m.validate(opts);
222
+ });
223
+
224
+ // ── Lock ─────────────────────────────────────────────────────────────────────
225
+ program
226
+ .command('lock')
227
+ .description('Lock the idea scope (requires 100% readiness)')
228
+ .option('-p, --project <id>', 'Project ID (defaults to active)')
229
+ .action(async (opts) => {
230
+ const m = await import('../src/commands/lock.js');
231
+ await m.lock(opts);
232
+ });
233
+
234
+ // ── Feedback ─────────────────────────────────────────────────────────────────
235
+ program
236
+ .command('feedback')
237
+ .description('Submit feedback on a completed phase')
238
+ .option('-p, --project <id>', 'Project ID (defaults to active)')
239
+ .requiredOption('--phase <phaseId>', 'Phase ID to give feedback on')
240
+ .requiredOption('--message <text>', 'Feedback message')
241
+ .option('--rating <n>', 'Rating 1-5')
242
+ .action(async (opts) => {
243
+ const m = await import('../src/commands/feedback.js');
244
+ await m.feedback(opts);
245
+ });
246
+
247
+ // ── Handoff ──────────────────────────────────────────────────────────────────
248
+ const handoff = program.command('handoff').description('View or trigger handoff bundles');
249
+
250
+ handoff
251
+ .command('show')
252
+ .description('Show current handoff bundle')
253
+ .option('-p, --project <id>', 'Project ID (defaults to active)')
254
+ .action(async (opts) => {
255
+ const m = await import('../src/commands/handoff.js');
256
+ await m.show(opts);
257
+ });
258
+
259
+ handoff
260
+ .command('trigger')
261
+ .description('Generate a new handoff bundle')
262
+ .option('-p, --project <id>', 'Project ID (defaults to active)')
263
+ .option('-t, --target <target>', 'Target: claudecode, codex, generic', 'generic')
264
+ .action(async (opts) => {
265
+ const m = await import('../src/commands/handoff.js');
266
+ await m.trigger(opts);
267
+ });
268
+
269
+ // ── Settings ─────────────────────────────────────────────────────────────────
270
+ const settings = program.command('settings').description('Manage API keys and preferences');
271
+
272
+ const settingsApiKeys = settings.command('api-keys').description('View or update API keys');
273
+
274
+ settingsApiKeys
275
+ .command('show')
276
+ .description('Show configured API keys (masked)')
277
+ .action(async () => {
278
+ const m = await import('../src/commands/settings.js');
279
+ await m.apiKeys();
280
+ });
281
+
282
+ settingsApiKeys
283
+ .command('set')
284
+ .description('Set an API key for a provider')
285
+ .requiredOption('--provider <name>', 'Provider: anthropic, openai, google')
286
+ .requiredOption('--key <value>', 'API key (empty string to remove)')
287
+ .action(async (opts) => {
288
+ const m = await import('../src/commands/settings.js');
289
+ await m.apiKeysSet(opts);
290
+ });
291
+
292
+ const settingsKeyMode = settings.command('key-mode').description('View or set key mode');
293
+
294
+ settingsKeyMode
295
+ .command('show')
296
+ .description('Show current key mode preference')
297
+ .action(async () => {
298
+ const m = await import('../src/commands/settings.js');
299
+ await m.keyMode();
300
+ });
301
+
302
+ settingsKeyMode
303
+ .command('set <mode>')
304
+ .description('Set key mode: own or server')
305
+ .action(async (mode) => {
306
+ const m = await import('../src/commands/settings.js');
307
+ await m.keyModeSet(mode);
308
+ });
309
+
205
310
  // ── TUI ───────────────────────────────────────────────────────────────────────
206
311
  program
207
312
  .command('ui')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectorasystems/cli",
3
- "version": "0.1.3",
3
+ "version": "0.2.1",
4
4
  "description": "Vectora CLI — AI-powered project orchestration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -101,10 +101,22 @@ export async function download(id, opts) {
101
101
  }
102
102
 
103
103
  // Get presigned URL + suggested filename from API
104
- const { url, filename } = await getArtifactDownloadUrl(id);
104
+ const { url, filename, inlineBase64 } = await getArtifactDownloadUrl(id);
105
105
 
106
106
  const outPath = resolve(opts.out ?? filename);
107
107
 
108
+ // Local-storage fallback: API returns the file bytes inline when no
109
+ // presigned URL backend is configured.
110
+ if (inlineBase64) {
111
+ const buffer = Buffer.from(inlineBase64, 'base64');
112
+ await writeFile(outPath, buffer);
113
+ success(`Saved to ${chalk.bold(outPath)} ${chalk.dim(`(${(buffer.length / 1024).toFixed(1)} KB)`)}`);
114
+ return;
115
+ }
116
+ if (!url) {
117
+ throw new Error('Artifact download URL was not provided by the API');
118
+ }
119
+
108
120
  // Fetch directly from R2 via presigned URL
109
121
  const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
110
122
  if (!res.ok) {
@@ -0,0 +1,54 @@
1
+ // @vectora/cli — feedback command: submit feedback on a phase
2
+ import chalk from 'chalk';
3
+ import { submitFeedback } from '../lib/api-client.js';
4
+ import { getConfigValue } from '../lib/config-store.js';
5
+ import { handleError } from '../lib/errors.js';
6
+ import { success, warn } from '../lib/output.js';
7
+
8
+ const VALID_RATINGS = [1, 2, 3, 4, 5];
9
+
10
+ /**
11
+ * vectora feedback --phase <phaseId> --message <text> [--rating <1-5>] [--project <id>]
12
+ */
13
+ export async function feedback(opts) {
14
+ try {
15
+ const projectId = opts.project ?? getConfigValue('defaultProject');
16
+ if (!projectId) {
17
+ warn('No project selected. Use --project <id> or: vectora projects select <id>');
18
+ process.exitCode = 1;
19
+ return;
20
+ }
21
+
22
+ if (!opts.phase) {
23
+ console.error(chalk.red('--phase <phaseId> is required'));
24
+ process.exitCode = 1;
25
+ return;
26
+ }
27
+
28
+ if (!opts.message) {
29
+ console.error(chalk.red('--message <text> is required'));
30
+ process.exitCode = 1;
31
+ return;
32
+ }
33
+
34
+ const body = {
35
+ phaseId: opts.phase,
36
+ feedbackText: opts.message,
37
+ };
38
+
39
+ if (opts.rating !== undefined) {
40
+ const rating = Number(opts.rating);
41
+ if (!VALID_RATINGS.includes(rating)) {
42
+ console.error(chalk.red('--rating must be 1-5'));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ body.rating = rating;
47
+ }
48
+
49
+ const result = await submitFeedback(projectId, body);
50
+ success(`Feedback submitted (artifact: ${result.artifact?.id ?? 'saved'})`);
51
+ } catch (err) {
52
+ handleError(err);
53
+ }
54
+ }
@@ -0,0 +1,84 @@
1
+ // @vectora/cli — handoff command: view or trigger handoff bundles
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { getHandoff, triggerHandoff } from '../lib/api-client.js';
5
+ import { getConfigValue } from '../lib/config-store.js';
6
+ import { handleError } from '../lib/errors.js';
7
+ import { success, warn, info, renderTime } from '../lib/output.js';
8
+
9
+ const VALID_TARGETS = ['claudecode', 'codex', 'generic'];
10
+
11
+ /**
12
+ * vectora handoff show [--project <id>]
13
+ */
14
+ export async function show(opts) {
15
+ try {
16
+ const projectId = opts.project ?? getConfigValue('defaultProject');
17
+ if (!projectId) {
18
+ warn('No project selected. Use --project <id> or: vectora projects select <id>');
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+
23
+ const result = await getHandoff(projectId);
24
+ const bundle = result.bundle;
25
+
26
+ if (!bundle) {
27
+ warn('No handoff bundle found. Run: vectora handoff trigger');
28
+ return;
29
+ }
30
+
31
+ console.log();
32
+ console.log(chalk.cyan.bold(' Handoff Bundle'));
33
+ console.log(chalk.dim(' ─────────────────────────────'));
34
+ console.log(` ${chalk.dim('ID:')} ${bundle.id}`);
35
+ console.log(` ${chalk.dim('Version:')} v${bundle.version}`);
36
+ console.log(` ${chalk.dim('Target:')} ${bundle.target}`);
37
+ console.log(` ${chalk.dim('Created:')} ${renderTime(bundle.createdAt)}`);
38
+ if (bundle.publicUrl) {
39
+ console.log(` ${chalk.dim('URL:')} ${chalk.cyan(bundle.publicUrl)}`);
40
+ }
41
+ console.log();
42
+ } catch (err) {
43
+ handleError(err);
44
+ }
45
+ }
46
+
47
+ /**
48
+ * vectora handoff trigger [--target <target>] [--project <id>]
49
+ */
50
+ export async function trigger(opts) {
51
+ try {
52
+ const projectId = opts.project ?? getConfigValue('defaultProject');
53
+ if (!projectId) {
54
+ warn('No project selected. Use --project <id> or: vectora projects select <id>');
55
+ process.exitCode = 1;
56
+ return;
57
+ }
58
+
59
+ const target = opts.target ?? 'generic';
60
+ if (!VALID_TARGETS.includes(target)) {
61
+ console.error(chalk.red(`Invalid target: ${target}. Valid: ${VALID_TARGETS.join(', ')}`));
62
+ process.exitCode = 1;
63
+ return;
64
+ }
65
+
66
+ const spinner = ora({ text: `Triggering handoff (target: ${target})...`, color: 'cyan' }).start();
67
+
68
+ let result;
69
+ try {
70
+ result = await triggerHandoff(projectId, target);
71
+ } catch (err) {
72
+ spinner.fail('Failed to trigger handoff');
73
+ throw err;
74
+ }
75
+
76
+ spinner.succeed('Handoff triggered');
77
+ if (result.jobId) {
78
+ info(`Job ID: ${result.jobId}`);
79
+ info('Check progress with: vectora status');
80
+ }
81
+ } catch (err) {
82
+ handleError(err);
83
+ }
84
+ }
@@ -0,0 +1,74 @@
1
+ // @vectora/cli — lock command: lock idea scope
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { runPhase, getProject } from '../lib/api-client.js';
5
+ import { getConfig, getConfigValue } from '../lib/config-store.js';
6
+ import { requireToken } from '../lib/auth-store.js';
7
+ import { streamPhaseProgress } from '../lib/sse-client.js';
8
+ import { handleError } from '../lib/errors.js';
9
+ import { warn, success, info } from '../lib/output.js';
10
+
11
+ /**
12
+ * vectora lock [--project <id>]
13
+ */
14
+ export async function lock(opts) {
15
+ try {
16
+ const projectId = opts.project ?? getConfigValue('defaultProject');
17
+ if (!projectId) {
18
+ warn('No project selected. Use --project <id> or: vectora projects select <id>');
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+
23
+ const token = await requireToken();
24
+ const { apiUrl } = getConfig();
25
+
26
+ const spinner = ora({ text: 'Locking idea scope...', color: 'cyan' }).start();
27
+
28
+ let result;
29
+ try {
30
+ result = await runPhase(projectId, 'lock-idea');
31
+ } catch (err) {
32
+ spinner.fail('Failed to start lock-idea');
33
+ throw err;
34
+ }
35
+
36
+ const { jobId } = result;
37
+ spinner.text = `lock-idea queued (job: ${jobId?.slice(0, 8) ?? '?'})`;
38
+
39
+ // Stream SSE progress
40
+ try {
41
+ for await (const { event, data } of streamPhaseProgress(apiUrl, jobId, token)) {
42
+ switch (event) {
43
+ case 'job:status':
44
+ spinner.text = `lock-idea: ${data.status ?? 'processing'}`;
45
+ break;
46
+ case 'job:progress':
47
+ spinner.text = `lock-idea: ${data.step ?? data.message ?? ''}`;
48
+ break;
49
+ case 'job:completed':
50
+ spinner.succeed('Idea scope locked');
51
+ info('Scope is now frozen. Run validation next: vectora validate');
52
+ return;
53
+ case 'job:failed':
54
+ spinner.fail('lock-idea failed');
55
+ console.error(chalk.red(data.error ?? 'Unknown error'));
56
+ process.exitCode = 1;
57
+ return;
58
+ case 'job:timeout':
59
+ spinner.warn('lock-idea timed out');
60
+ info(`Check status with: vectora status --project ${projectId}`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ }
65
+ spinner.warn('lock-idea — SSE stream ended');
66
+ } catch (err) {
67
+ spinner.fail('lock-idea — lost connection');
68
+ info(`Phase may still be running. Check: vectora status --project ${projectId}`);
69
+ throw err;
70
+ }
71
+ } catch (err) {
72
+ handleError(err);
73
+ }
74
+ }
@@ -185,6 +185,24 @@ export async function select(id) {
185
185
  }
186
186
  }
187
187
 
188
+ /**
189
+ * vectora projects archive <id>
190
+ */
191
+ export async function archive(id) {
192
+ try {
193
+ if (!id) {
194
+ console.error(chalk.red('Error — project ID is required'));
195
+ process.exitCode = 1;
196
+ return;
197
+ }
198
+
199
+ const project = await patchProject(id, { status: 'archived' });
200
+ success(`Archived project ${chalk.bold(project.name)} ${chalk.dim(`(${project.id})`)}`);
201
+ } catch (err) {
202
+ handleError(err);
203
+ }
204
+ }
205
+
188
206
  /**
189
207
  * vectora projects delete <id>
190
208
  */
@@ -0,0 +1,94 @@
1
+ // @vectora/cli — settings command: manage API keys and key mode
2
+ import chalk from 'chalk';
3
+ import { getApiKeys, updateApiKeys, getKeyMode, updateKeyMode } from '../lib/api-client.js';
4
+ import { handleError } from '../lib/errors.js';
5
+ import { success, warn } from '../lib/output.js';
6
+
7
+ const VALID_PROVIDERS = ['anthropic', 'openai', 'google'];
8
+
9
+ /**
10
+ * vectora settings api-keys — show configured API keys (masked)
11
+ */
12
+ export async function apiKeys() {
13
+ try {
14
+ const keys = await getApiKeys();
15
+ console.log();
16
+ console.log(chalk.cyan.bold(' API Keys'));
17
+ console.log(chalk.dim(' ─────────────────────────────'));
18
+ for (const provider of VALID_PROVIDERS) {
19
+ const value = keys[provider];
20
+ const display = value ? chalk.green(value) : chalk.dim('not set');
21
+ console.log(` ${provider.padEnd(12)} ${display}`);
22
+ }
23
+ console.log();
24
+ } catch (err) {
25
+ handleError(err);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * vectora settings api-keys set --provider <name> --key <value>
31
+ */
32
+ export async function apiKeysSet(opts) {
33
+ try {
34
+ if (!opts.provider) {
35
+ console.error(chalk.red('--provider is required (anthropic, openai, google)'));
36
+ process.exitCode = 1;
37
+ return;
38
+ }
39
+ if (!VALID_PROVIDERS.includes(opts.provider)) {
40
+ console.error(chalk.red(`Invalid provider. Valid: ${VALID_PROVIDERS.join(', ')}`));
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ if (!opts.key && opts.key !== '') {
45
+ console.error(chalk.red('--key is required (use empty string to delete)'));
46
+ process.exitCode = 1;
47
+ return;
48
+ }
49
+
50
+ await updateApiKeys({ [opts.provider]: opts.key });
51
+ if (opts.key === '') {
52
+ success(`Removed ${opts.provider} API key`);
53
+ } else {
54
+ success(`Updated ${opts.provider} API key`);
55
+ }
56
+ } catch (err) {
57
+ handleError(err);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * vectora settings key-mode — show current key mode
63
+ */
64
+ export async function keyMode() {
65
+ try {
66
+ const result = await getKeyMode();
67
+ console.log();
68
+ console.log(chalk.cyan.bold(' Key Mode'));
69
+ console.log(chalk.dim(' ─────────────────────────────'));
70
+ console.log(` ${chalk.dim('Prefer own keys:')} ${result.preferOwnKeys ? chalk.green('yes') : chalk.dim('no')}`);
71
+ console.log(` ${chalk.dim('Has own keys:')} ${result.hasOwnKeys ? chalk.green('yes') : chalk.dim('no')}`);
72
+ console.log();
73
+ } catch (err) {
74
+ handleError(err);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * vectora settings key-mode set <own|server>
80
+ */
81
+ export async function keyModeSet(mode) {
82
+ try {
83
+ if (mode !== 'own' && mode !== 'server') {
84
+ console.error(chalk.red('Mode must be "own" or "server"'));
85
+ process.exitCode = 1;
86
+ return;
87
+ }
88
+ const preferOwnKeys = mode === 'own';
89
+ await updateKeyMode(preferOwnKeys);
90
+ success(`Key mode set to: ${mode}`);
91
+ } catch (err) {
92
+ handleError(err);
93
+ }
94
+ }
@@ -0,0 +1,84 @@
1
+ // @vectora/cli — validate command: run 4-agent validation pipeline
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { runValidation } from '../lib/api-client.js';
5
+ import { getConfigValue } from '../lib/config-store.js';
6
+ import { handleError } from '../lib/errors.js';
7
+ import { renderTable, warn, success, info } from '../lib/output.js';
8
+
9
+ /**
10
+ * vectora validate [--project <id>] [--format <fmt>]
11
+ */
12
+ export async function validate(opts) {
13
+ try {
14
+ const projectId = opts.project ?? getConfigValue('defaultProject');
15
+ if (!projectId) {
16
+ warn('No project selected. Use --project <id> or: vectora projects select <id>');
17
+ process.exitCode = 1;
18
+ return;
19
+ }
20
+
21
+ const spinner = ora({ text: 'Running validation pipeline (this may take a minute)...', color: 'cyan' }).start();
22
+
23
+ let result;
24
+ try {
25
+ result = await runValidation(projectId);
26
+ } catch (err) {
27
+ spinner.fail('Validation failed');
28
+ throw err;
29
+ }
30
+
31
+ spinner.succeed('Validation complete');
32
+ console.log();
33
+
34
+ // Summary
35
+ const recColor = result.recommendation === 'go' ? chalk.green :
36
+ result.recommendation === 'no-go' ? chalk.red : chalk.yellow;
37
+ console.log(` ${chalk.cyan.bold('Score:')} ${result.score}/100`);
38
+ console.log(` ${chalk.cyan.bold('Verdict:')} ${recColor(result.recommendation.toUpperCase())}`);
39
+ console.log();
40
+
41
+ if (opts.format === 'json') {
42
+ console.log(JSON.stringify(result, null, 2));
43
+ return;
44
+ }
45
+
46
+ // Report table
47
+ const report = result.report;
48
+ if (report) {
49
+ if (report.viabilityScore) {
50
+ console.log(chalk.cyan.bold(' Viability'));
51
+ console.log(` ${report.viabilityScore.reasoning}`);
52
+ console.log();
53
+ }
54
+
55
+ if (report.stressTest?.criticalAssumptions?.length) {
56
+ console.log(chalk.cyan.bold(' Critical Assumptions'));
57
+ for (const a of report.stressTest.criticalAssumptions) {
58
+ console.log(` ${chalk.dim('•')} ${a}`);
59
+ }
60
+ console.log();
61
+ }
62
+
63
+ if (report.viabilityScore?.topRisks?.length) {
64
+ console.log(chalk.cyan.bold(' Top Risks'));
65
+ for (const r of report.viabilityScore.topRisks) {
66
+ console.log(` ${chalk.dim('•')} ${r}`);
67
+ }
68
+ console.log();
69
+ }
70
+ }
71
+
72
+ if (result.warnings?.length) {
73
+ for (const w of result.warnings) {
74
+ warn(w);
75
+ }
76
+ }
77
+
78
+ if (result.artifactId) {
79
+ info(`Artifact saved: ${result.artifactId}`);
80
+ }
81
+ } catch (err) {
82
+ handleError(err);
83
+ }
84
+ }
@@ -125,8 +125,30 @@ export async function getArtifactDownloadUrl(artifactId) {
125
125
  return authedFetch(`/v1/artifacts/${artifactId}/download`);
126
126
  }
127
127
 
128
+ // ─── Validation ─────────────────────────────────────────────────────────────
129
+
130
+ export async function runValidation(projectId) {
131
+ return authedFetch(`/v1/projects/${projectId}/validate`, {
132
+ method: 'POST',
133
+ body: {},
134
+ });
135
+ }
136
+
137
+ // ─── Feedback ───────────────────────────────────────────────────────────────
138
+
139
+ export async function submitFeedback(projectId, body) {
140
+ return authedFetch(`/v1/projects/${projectId}/feedback`, {
141
+ method: 'POST',
142
+ body,
143
+ });
144
+ }
145
+
128
146
  // ─── Handoff ─────────────────────────────────────────────────────────────────
129
147
 
148
+ export async function getHandoff(projectId) {
149
+ return authedFetch(`/v1/projects/${projectId}/handoff`);
150
+ }
151
+
130
152
  export async function triggerHandoff(projectId, target) {
131
153
  return authedFetch(`/v1/projects/${projectId}/handoff`, {
132
154
  method: 'POST',
@@ -159,6 +181,22 @@ export async function getApiKeys() {
159
181
  return res.keys;
160
182
  }
161
183
 
184
+ export async function updateApiKeys(keys) {
185
+ const res = await authedFetch('/v1/settings/api-keys', { method: 'PUT', body: keys });
186
+ return res.keys;
187
+ }
188
+
189
+ export async function getKeyMode() {
190
+ return authedFetch('/v1/settings/key-mode');
191
+ }
192
+
193
+ export async function updateKeyMode(preferOwnKeys) {
194
+ return authedFetch('/v1/settings/key-mode', {
195
+ method: 'PATCH',
196
+ body: { preferOwnKeys },
197
+ });
198
+ }
199
+
162
200
  // ─── Auth (unauthenticated or special) ───────────────────────────────────────
163
201
 
164
202
  export async function getMe(token) {
@@ -1,7 +1,7 @@
1
1
  // @vectora/cli — constants
2
2
  // Inlined from @vectora/engine to avoid dependency in API-connected mode.
3
3
 
4
- export const VERSION = '0.1.3';
4
+ export const VERSION = '0.2.0';
5
5
 
6
6
  export const BRAND = {
7
7
  name: 'VECTORA',
@@ -28,8 +28,11 @@ export const TEMPER_PHASES = [
28
28
  'plan-platform',
29
29
  ];
30
30
 
31
+ /** Pre-pipeline phases (idea → validation → lock). */
32
+ export const PRE_PIPELINE_PHASES = ['lock-idea', 'run-validation'];
33
+
31
34
  /** All valid phases for `vectora run`. */
32
- export const VALID_PHASES = [...FORGE_PHASES, ...TEMPER_PHASES];
35
+ export const VALID_PHASES = [...PRE_PIPELINE_PHASES, ...FORGE_PHASES, ...TEMPER_PHASES];
33
36
 
34
37
  /** Directories to skip during workspace scanning. */
35
38
  export const WORKSPACE_IGNORED_DIRS = new Set([
@@ -43,11 +43,23 @@ export function ArtifactBrowser({ projectId, onBack }) {
43
43
  setSavedPath(null);
44
44
  setError(null);
45
45
  try {
46
- const { url, filename } = await getArtifactDownloadUrl(item.value);
46
+ const { url, filename, inlineBase64 } = await getArtifactDownloadUrl(item.value);
47
+ const outPath = resolve(filename);
48
+
49
+ // Local-storage fallback: the API can return inline bytes when no
50
+ // presigned URL backend is available.
51
+ if (inlineBase64) {
52
+ const buffer = Buffer.from(inlineBase64, 'base64');
53
+ await writeFile(outPath, buffer);
54
+ setSavedPath(outPath);
55
+ return;
56
+ }
57
+
58
+ if (!url) throw new Error('Artifact download URL was not provided by the API');
59
+
47
60
  const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
48
61
  if (!res.ok) throw new Error(`Download failed: HTTP ${res.status}`);
49
62
  const buffer = Buffer.from(await res.arrayBuffer());
50
- const outPath = resolve(filename);
51
63
  await writeFile(outPath, buffer);
52
64
  setSavedPath(outPath);
53
65
  } catch (err) {