vigthoria-cli 1.13.18 → 1.13.22

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.
Files changed (185) hide show
  1. package/completions/_vigthoria +49 -0
  2. package/completions/vigthoria.bash +6 -0
  3. package/completions/vigthoria.fish +41 -0
  4. package/dist/commands/auth.d.ts +1 -0
  5. package/dist/commands/auth.js +84 -135
  6. package/dist/commands/background.d.ts +0 -2
  7. package/dist/commands/background.js +26 -34
  8. package/dist/commands/bridge.js +45 -41
  9. package/dist/commands/cancel.d.ts +1 -0
  10. package/dist/commands/cancel.js +22 -16
  11. package/dist/commands/chat.d.ts +5 -42
  12. package/dist/commands/chat.js +462 -1122
  13. package/dist/commands/coding-registration.d.ts +4 -0
  14. package/dist/commands/coding-registration.js +24 -0
  15. package/dist/commands/config.js +32 -42
  16. package/dist/commands/conversation-registration.d.ts +4 -0
  17. package/dist/commands/conversation-registration.js +122 -0
  18. package/dist/commands/deploy.d.ts +8 -2
  19. package/dist/commands/deploy.js +92 -90
  20. package/dist/commands/device.d.ts +1 -2
  21. package/dist/commands/device.js +37 -21
  22. package/dist/commands/doctor-registration.d.ts +4 -0
  23. package/dist/commands/doctor-registration.js +200 -0
  24. package/dist/commands/edit.js +22 -33
  25. package/dist/commands/explain.js +7 -10
  26. package/dist/commands/fork.d.ts +1 -2
  27. package/dist/commands/fork.js +72 -22
  28. package/dist/commands/game.d.ts +1 -1
  29. package/dist/commands/game.js +17 -6
  30. package/dist/commands/generate.js +7 -9
  31. package/dist/commands/history.js +11 -7
  32. package/dist/commands/hub.d.ts +2 -2
  33. package/dist/commands/hub.js +34 -35
  34. package/dist/commands/legion.d.ts +0 -1
  35. package/dist/commands/legion.js +62 -79
  36. package/dist/commands/music.d.ts +1 -2
  37. package/dist/commands/music.js +62 -27
  38. package/dist/commands/platform-registration.d.ts +4 -0
  39. package/dist/commands/platform-registration.js +45 -0
  40. package/dist/commands/preview.d.ts +0 -1
  41. package/dist/commands/preview.js +82 -35
  42. package/dist/commands/product-run-registration.d.ts +4 -0
  43. package/dist/commands/product-run-registration.js +490 -0
  44. package/dist/commands/replay.d.ts +1 -2
  45. package/dist/commands/replay.js +15 -11
  46. package/dist/commands/repo.d.ts +14 -7
  47. package/dist/commands/repo.js +245 -196
  48. package/dist/commands/review.js +9 -12
  49. package/dist/commands/security.d.ts +1 -2
  50. package/dist/commands/security.js +12 -6
  51. package/dist/commands/update-registration.d.ts +3 -0
  52. package/dist/commands/update-registration.js +338 -0
  53. package/dist/commands/v4-registration.d.ts +4 -0
  54. package/dist/commands/v4-registration.js +24 -0
  55. package/dist/commands/v4.d.ts +1 -1
  56. package/dist/commands/v4.js +46 -8
  57. package/dist/commands/wallet.d.ts +1 -3
  58. package/dist/commands/wallet.js +18 -45
  59. package/dist/commands/workflow.d.ts +2 -0
  60. package/dist/commands/workflow.js +52 -39
  61. package/dist/index.js +122 -1498
  62. package/dist/utils/agent-stream-state.d.ts +27 -0
  63. package/dist/utils/agent-stream-state.js +93 -0
  64. package/dist/utils/agentRoute.d.ts +2 -1
  65. package/dist/utils/agentRoute.js +22 -9
  66. package/dist/utils/agentRunOutcome.d.ts +16 -0
  67. package/dist/utils/agentRunOutcome.js +37 -2
  68. package/dist/utils/api-client-factory.d.ts +8 -0
  69. package/dist/utils/api-client-factory.js +8 -0
  70. package/dist/utils/api.d.ts +45 -269
  71. package/dist/utils/api.js +664 -3210
  72. package/dist/utils/auth-session.d.ts +41 -0
  73. package/dist/utils/auth-session.js +224 -0
  74. package/dist/utils/brain-hub-client.d.ts +0 -4
  75. package/dist/utils/brain-hub-client.js +6 -3
  76. package/dist/utils/bridge-client.js +2 -0
  77. package/dist/utils/capability-health-service.d.ts +48 -0
  78. package/dist/utils/capability-health-service.js +64 -0
  79. package/dist/utils/chat-prompt-policy.d.ts +18 -0
  80. package/dist/utils/chat-prompt-policy.js +61 -0
  81. package/dist/utils/chat-tool-call-parser.d.ts +11 -0
  82. package/dist/utils/chat-tool-call-parser.js +71 -0
  83. package/dist/utils/cli-state.d.ts +22 -4
  84. package/dist/utils/cli-state.js +105 -47
  85. package/dist/utils/code-operations-service.d.ts +104 -0
  86. package/dist/utils/code-operations-service.js +837 -0
  87. package/dist/utils/codebase-indexer.d.ts +1 -0
  88. package/dist/utils/codebase-indexer.js +28 -8
  89. package/dist/utils/command-contract.d.ts +61 -0
  90. package/dist/utils/command-contract.js +149 -0
  91. package/dist/utils/command-policy.d.ts +10 -0
  92. package/dist/utils/command-policy.js +54 -0
  93. package/dist/utils/config.d.ts +13 -5
  94. package/dist/utils/config.js +227 -99
  95. package/dist/utils/context-ranker.js +11 -2
  96. package/dist/utils/context-state.d.ts +39 -0
  97. package/dist/utils/context-state.js +52 -0
  98. package/dist/utils/deckEvents.d.ts +0 -5
  99. package/dist/utils/deckEvents.js +3 -2
  100. package/dist/utils/desktop-bridge-client.js +2 -0
  101. package/dist/utils/direct-output-policy.d.ts +3 -0
  102. package/dist/utils/direct-output-policy.js +76 -0
  103. package/dist/utils/durable-json.d.ts +57 -0
  104. package/dist/utils/durable-json.js +319 -0
  105. package/dist/utils/fastAgentRouter.d.ts +6 -7
  106. package/dist/utils/fastAgentRouter.js +21 -30
  107. package/dist/utils/files.js +21 -13
  108. package/dist/utils/frontend-preview-service.d.ts +76 -0
  109. package/dist/utils/frontend-preview-service.js +482 -0
  110. package/dist/utils/goaEvents.d.ts +0 -4
  111. package/dist/utils/goaEvents.js +5 -2
  112. package/dist/utils/logger.d.ts +2 -1
  113. package/dist/utils/logger.js +27 -20
  114. package/dist/utils/mcp-context-client.d.ts +24 -0
  115. package/dist/utils/mcp-context-client.js +58 -0
  116. package/dist/utils/model-governance.d.ts +18 -0
  117. package/dist/utils/model-governance.js +75 -0
  118. package/dist/utils/model-transport-service.d.ts +44 -0
  119. package/dist/utils/model-transport-service.js +111 -0
  120. package/dist/utils/mutation-journal.d.ts +42 -0
  121. package/dist/utils/mutation-journal.js +387 -0
  122. package/dist/utils/network-policy.d.ts +24 -0
  123. package/dist/utils/network-policy.js +202 -0
  124. package/dist/utils/operator-client.d.ts +45 -0
  125. package/dist/utils/operator-client.js +159 -0
  126. package/dist/utils/post-write-validator.js +7 -9
  127. package/dist/utils/preview-screenshot-adapter.d.ts +13 -0
  128. package/dist/utils/preview-screenshot-adapter.js +36 -0
  129. package/dist/utils/process-policy.d.ts +23 -0
  130. package/dist/utils/process-policy.js +284 -0
  131. package/dist/utils/project-memory.d.ts +13 -3
  132. package/dist/utils/project-memory.js +70 -17
  133. package/dist/utils/prompt-workspace-resolver.d.ts +14 -0
  134. package/dist/utils/prompt-workspace-resolver.js +64 -0
  135. package/dist/utils/release-install.d.ts +11 -0
  136. package/dist/utils/release-install.js +155 -0
  137. package/dist/utils/release-policy.d.ts +64 -0
  138. package/dist/utils/release-policy.js +293 -0
  139. package/dist/utils/requestIntent.js +22 -3
  140. package/dist/utils/runtime-capability.d.ts +8 -0
  141. package/dist/utils/runtime-capability.js +16 -0
  142. package/dist/utils/secret-policy.d.ts +20 -0
  143. package/dist/utils/secret-policy.js +160 -0
  144. package/dist/utils/session.d.ts +34 -2
  145. package/dist/utils/session.js +354 -110
  146. package/dist/utils/tool-approval-service.d.ts +17 -0
  147. package/dist/utils/tool-approval-service.js +78 -0
  148. package/dist/utils/tool-capability-providers.d.ts +57 -0
  149. package/dist/utils/tool-capability-providers.js +57 -0
  150. package/dist/utils/tools.d.ts +2 -23
  151. package/dist/utils/tools.js +216 -508
  152. package/dist/utils/update-policy.d.ts +18 -0
  153. package/dist/utils/update-policy.js +137 -0
  154. package/dist/utils/v3-agent-client.d.ts +40 -0
  155. package/dist/utils/v3-agent-client.js +165 -0
  156. package/dist/utils/v3-workspace-service.d.ts +28 -0
  157. package/dist/utils/v3-workspace-service.js +279 -0
  158. package/dist/utils/vigflow-client.d.ts +95 -0
  159. package/dist/utils/vigflow-client.js +105 -0
  160. package/dist/utils/workspace-boundary.d.ts +45 -0
  161. package/dist/utils/workspace-boundary.js +377 -0
  162. package/dist/utils/workspace-brain-service.d.ts +2 -0
  163. package/dist/utils/workspace-brain-service.js +11 -1
  164. package/dist/utils/workspace-cache.js +37 -15
  165. package/dist/utils/workspace-stream.js +35 -10
  166. package/install.ps1 +86 -143
  167. package/install.sh +76 -81
  168. package/package.json +44 -9
  169. package/release-policy.json +43 -0
  170. package/scripts/release/LOCAL_MACHINE_USER_VERIFICATION.md +1 -1
  171. package/scripts/release/generate-completions.mjs +24 -0
  172. package/scripts/release/install-release.mjs +10 -0
  173. package/scripts/release/publish-cli-release.mjs +116 -0
  174. package/scripts/release/publish-cli-release.sh +2 -70
  175. package/scripts/release/resolve-release-manifest.mjs +26 -0
  176. package/scripts/release/test-balanced-model-live.sh +42 -0
  177. package/scripts/release/validate-live-service-gates.sh +90 -0
  178. package/scripts/release/validate-no-go-gates.sh +135 -172
  179. package/scripts/release/verify-runtime-consistency.mjs +4 -1
  180. package/dist/commands/index.d.ts +0 -12
  181. package/dist/commands/index.js +0 -194
  182. package/dist/commands/update.d.ts +0 -9
  183. package/dist/commands/update.js +0 -301
  184. package/dist/utils/templateInstantPath.d.ts +0 -18
  185. package/dist/utils/templateInstantPath.js +0 -228
@@ -16,11 +16,14 @@ import chalk from 'chalk';
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { createRequire } from 'node:module';
19
+ import { guardedFetch } from '../utils/network-policy.js';
19
20
  import { createSpinner, CH } from '../utils/logger.js';
21
+ import { createOperationId } from '../utils/mutation-journal.js';
22
+ import { containsHighConfidenceSecret, isSensitivePath, safeChildProcessEnv } from '../utils/secret-policy.js';
23
+ import { CliCommandError, commandFailure, formatSuccessJson } from '../utils/command-contract.js';
24
+ import { assertSafeDestinationPath, assertSafeRelativePath, inspectZipArchive, mergeValidatedWorkspace, resolveWorkspacePath, validateExtractedWorkspace, } from '../utils/workspace-boundary.js';
20
25
  const require = createRequire(import.meta.url);
21
26
  import inquirer from 'inquirer';
22
- import * as archiverModule from 'archiver';
23
- import { createWriteStream } from 'fs';
24
27
  const MAX_REPOSITORY_CONTENT_BYTES = 100 * 1024 * 1024;
25
28
  const MAX_REPOSITORY_REQUEST_BYTES = 120 * 1024 * 1024;
26
29
  class RepositoryApiError extends Error {
@@ -33,6 +36,24 @@ class RepositoryApiError extends Error {
33
36
  this.payload = payload || {};
34
37
  }
35
38
  }
39
+ export class RepositoryMutationAmbiguousError extends Error {
40
+ operationId;
41
+ mutationOutcome = 'unknown';
42
+ partialMutation = 'unknown';
43
+ constructor(operationId, cause) {
44
+ super(`Repository mutation outcome is unknown for operation ${operationId}; no alternate backend retry was attempted.`);
45
+ this.name = 'RepositoryMutationAmbiguousError';
46
+ this.operationId = operationId;
47
+ if (cause instanceof Error)
48
+ this.cause = cause;
49
+ }
50
+ }
51
+ export function canFallbackRepositoryRequest(method, status) {
52
+ const normalized = method.toUpperCase();
53
+ if (normalized === 'GET' || normalized === 'HEAD')
54
+ return true;
55
+ return status !== undefined && [401, 403, 404, 413].includes(status);
56
+ }
36
57
  function formatBytes(bytes) {
37
58
  return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
38
59
  }
@@ -49,24 +70,35 @@ async function readResponsePayload(response) {
49
70
  }
50
71
  export class RepoCommand {
51
72
  config;
52
- logger;
53
73
  apiBase;
54
74
  communityBase;
55
75
  communityToken;
56
- constructor(config, logger) {
76
+ constructor(config, _logger) {
57
77
  this.config = config;
58
- this.logger = logger;
59
78
  this.apiBase = this.config.get('apiUrl') || 'https://coder.vigthoria.io';
60
79
  this.communityBase = process.env.VIGTHORIA_COMMUNITY_API_URL || 'https://community.vigthoria.io';
61
80
  this.communityToken = process.env.VIGTHORIA_COMMUNITY_TOKEN || null;
62
81
  }
63
- getAuthHeaders() {
64
- const token = this.communityToken || this.config.get('authToken');
82
+ getAuthHeaders(audience) {
83
+ const token = audience === 'coder'
84
+ ? process.env.VIGTHORIA_TOKEN || process.env.VIGTHORIA_AUTH_TOKEN || this.config.get('authToken')
85
+ : this.communityToken;
65
86
  return {
66
- 'Authorization': `Bearer ${token}`,
67
- 'Content-Type': 'application/json'
87
+ ...(token ? { 'Authorization': `Bearer ${token}` } : {}),
88
+ 'Content-Type': 'application/json',
68
89
  };
69
90
  }
91
+ getRepositoryAudienceForUrl(rawUrl) {
92
+ const hostname = new URL(rawUrl).hostname.toLowerCase();
93
+ if (hostname === 'coder.vigthoria.io')
94
+ return 'coder';
95
+ if (hostname === 'community.vigthoria.io')
96
+ return 'community';
97
+ throw new Error('Repository download URL is outside the Coder and Community trust boundary');
98
+ }
99
+ getAuthHeadersForUrl(rawUrl) {
100
+ return this.getAuthHeaders(this.getRepositoryAudienceForUrl(rawUrl));
101
+ }
70
102
  async ensureCommunityAuth() {
71
103
  if (this.communityToken) {
72
104
  return;
@@ -76,58 +108,62 @@ export class RepoCommand {
76
108
  if (!email || !password) {
77
109
  return;
78
110
  }
79
- const response = await fetch(`${this.communityBase}/api/auth/login`, {
111
+ const response = await guardedFetch(`${this.communityBase}/api/auth/login`, {
80
112
  method: 'POST',
81
113
  headers: { 'Content-Type': 'application/json' },
82
114
  body: JSON.stringify({ email, password })
83
- });
115
+ }, { audience: 'community' });
84
116
  const data = await response.json();
85
117
  if (!response.ok || !data.token) {
86
118
  throw new Error(data.error || data.message || 'Community login failed');
87
119
  }
88
120
  this.communityToken = data.token;
89
121
  }
90
- async repoFetch(apiPath, init = {}) {
122
+ async repoFetch(apiPath, init = {}, options = {}) {
91
123
  const normalizedApiPath = apiPath.startsWith('/api/repo') ? apiPath : `/api/repo${apiPath.startsWith('/') ? apiPath : `/${apiPath}`}`;
92
124
  const proxyPath = `${this.apiBase}/api/community-repo${normalizedApiPath.replace('/api/repo', '')}`;
93
125
  const directPath = `${this.communityBase}${normalizedApiPath}`;
94
- const attempt = async (url, allowCommunityAuthRetry) => {
95
- const response = await fetch(url, {
126
+ const method = String(init.method || 'GET').toUpperCase();
127
+ const readOnly = options.readOnly === true || method === 'GET' || method === 'HEAD';
128
+ const mutationId = readOnly ? undefined : createOperationId(options.operationId);
129
+ const attempt = async (url, audience) => {
130
+ if (audience === 'community' && !this.communityToken)
131
+ await this.ensureCommunityAuth();
132
+ const response = await guardedFetch(url, {
96
133
  ...init,
97
134
  headers: {
98
- ...this.getAuthHeaders(),
135
+ ...this.getAuthHeaders(audience),
136
+ ...(mutationId ? {
137
+ 'Idempotency-Key': mutationId,
138
+ 'X-Vigthoria-Operation-Id': mutationId,
139
+ } : {}),
99
140
  ...(init.headers || {})
100
141
  }
101
- });
102
- // Both 401 (missing/expired credentials) and 403 (a valid Coder
103
- // token that Community cannot accept) must activate the explicit
104
- // Community credential fallback. Previously a stale/incompatible
105
- // Coder token made configured Community credentials unreachable.
106
- if ([401, 403].includes(response.status) && allowCommunityAuthRetry && !this.communityToken) {
107
- await this.ensureCommunityAuth();
108
- if (this.communityToken) {
109
- return attempt(url, false);
110
- }
111
- }
142
+ }, { audience });
112
143
  return response;
113
144
  };
114
145
  try {
115
- const proxyResponse = await attempt(proxyPath, true);
146
+ const proxyResponse = await attempt(proxyPath, 'coder');
116
147
  // A large repository payload can exceed the Coder API's general
117
148
  // 50 MiB parser while remaining valid under Community's 100 MiB
118
149
  // repository contract. Retry that one status directly.
119
- const shouldTryDirectCommunity = [401, 403, 404, 413].includes(proxyResponse.status);
150
+ const shouldTryDirectCommunity = readOnly || canFallbackRepositoryRequest(method, proxyResponse.status);
120
151
  if (proxyResponse.ok || (proxyResponse.status < 500 && !shouldTryDirectCommunity)) {
121
152
  return proxyResponse;
122
153
  }
123
154
  if (shouldTryDirectCommunity) {
124
155
  await proxyResponse.body?.cancel();
125
156
  }
157
+ else {
158
+ return proxyResponse;
159
+ }
126
160
  }
127
- catch {
128
- // fall through to direct community endpoint
161
+ catch (error) {
162
+ if (!readOnly) {
163
+ throw new RepositoryMutationAmbiguousError(mutationId, error);
164
+ }
129
165
  }
130
- return attempt(directPath, true);
166
+ return attempt(directPath, 'community');
131
167
  }
132
168
  collectProjectFiles(projectPath) {
133
169
  const files = [];
@@ -140,15 +176,26 @@ export class RepoCommand {
140
176
  }
141
177
  const fullPath = path.join(dir, entry.name);
142
178
  const relativePath = path.relative(projectPath, fullPath);
179
+ if (isSensitivePath(relativePath)) {
180
+ continue;
181
+ }
182
+ if (entry.isSymbolicLink()) {
183
+ throw new Error(`Repository push refused symbolic link or junction: ${relativePath}`);
184
+ }
143
185
  if (entry.isDirectory()) {
186
+ resolveWorkspacePath(projectPath, relativePath);
144
187
  walk(fullPath);
145
188
  continue;
146
189
  }
147
- if (!entry.isFile() || ignoreSuffixes.some((suffix) => entry.name.endsWith(suffix))) {
190
+ if (!entry.isFile()) {
191
+ throw new Error(`Repository push refused special or unreadable entry: ${relativePath}`);
192
+ }
193
+ if (ignoreSuffixes.some((suffix) => entry.name.endsWith(suffix))) {
148
194
  continue;
149
195
  }
150
196
  try {
151
- const raw = fs.readFileSync(fullPath);
197
+ const containedPath = resolveWorkspacePath(projectPath, relativePath);
198
+ const raw = fs.readFileSync(containedPath);
152
199
  let content;
153
200
  let encoding;
154
201
  try {
@@ -159,9 +206,14 @@ export class RepoCommand {
159
206
  if (looksBinary)
160
207
  throw new Error('binary content');
161
208
  content = new TextDecoder('utf-8', { fatal: true }).decode(raw);
209
+ if (containsHighConfidenceSecret(content)) {
210
+ throw new Error(`Repository push refused ${relativePath}: detected secret material`);
211
+ }
162
212
  encoding = 'utf8';
163
213
  }
164
- catch {
214
+ catch (error) {
215
+ if (error instanceof Error && error.message.startsWith('Repository push refused'))
216
+ throw error;
165
217
  content = raw.toString('base64');
166
218
  encoding = 'base64';
167
219
  }
@@ -172,8 +224,8 @@ export class RepoCommand {
172
224
  size: raw.length,
173
225
  });
174
226
  }
175
- catch {
176
- // Skip unreadable files. Binary files are transported as base64.
227
+ catch (error) {
228
+ throw new Error(`Repository push could not safely read ${relativePath}: ${error.message}`);
177
229
  }
178
230
  }
179
231
  };
@@ -250,13 +302,14 @@ export class RepoCommand {
250
302
  console.log(chalk.cyan(` Retry fixed content: ${review.retryCommand || `vigthoria repo push --retry ${review.reviewId}`}\n`));
251
303
  }
252
304
  isAuthenticated() {
253
- const token = this.config.get('authToken');
305
+ const token = process.env.VIGTHORIA_TOKEN || process.env.VIGTHORIA_AUTH_TOKEN || this.config.get('authToken');
254
306
  return !!token;
255
307
  }
256
308
  requireAuth() {
257
309
  if (!this.isAuthenticated()) {
258
- console.log(chalk.red('\n❌ Authentication required. Run: vigthoria login\n'));
259
- process.exit(1);
310
+ throw new CliCommandError('Authentication required. Run: vigthoria login', {
311
+ code: 'AUTH_REQUIRED', category: 'authentication',
312
+ });
260
313
  }
261
314
  }
262
315
  /**
@@ -321,44 +374,6 @@ export class RepoCommand {
321
374
  }
322
375
  return { name, description, techStack: [...new Set(techStack)] };
323
376
  }
324
- /**
325
- * Create a zip archive of a project
326
- */
327
- async createProjectArchive(projectPath) {
328
- const tempDir = path.join(require('os').tmpdir(), 'vigthoria-cli');
329
- if (!fs.existsSync(tempDir)) {
330
- fs.mkdirSync(tempDir, { recursive: true });
331
- }
332
- const archivePath = path.join(tempDir, `project-${Date.now()}.zip`);
333
- const output = createWriteStream(archivePath);
334
- // Archiver 8 is ESM-only and exposes format-specific constructors
335
- // instead of the legacy default factory.
336
- const archive = new archiverModule.ZipArchive({ zlib: { level: 9 } });
337
- return new Promise((resolve, reject) => {
338
- output.on('close', () => resolve(archivePath));
339
- archive.on('error', (err) => reject(err));
340
- archive.pipe(output);
341
- // Ignore patterns
342
- const ignorePatterns = [
343
- 'node_modules/**',
344
- '.git/**',
345
- 'dist/**',
346
- 'build/**',
347
- '__pycache__/**',
348
- '.venv/**',
349
- 'venv/**',
350
- '*.log',
351
- '.env',
352
- '.DS_Store'
353
- ];
354
- archive.glob('**/*', {
355
- cwd: projectPath,
356
- ignore: ignorePatterns,
357
- dot: true
358
- });
359
- archive.finalize();
360
- });
361
- }
362
377
  /**
363
378
  * Push a project to Vigthoria Repository
364
379
  */
@@ -366,8 +381,9 @@ export class RepoCommand {
366
381
  this.requireAuth();
367
382
  const projectPath = options.path ? path.resolve(options.path) : process.cwd();
368
383
  if (!fs.existsSync(projectPath)) {
369
- console.log(chalk.red(`\n❌ Path does not exist: ${projectPath}\n`));
370
- return;
384
+ throw new CliCommandError(`Path does not exist: ${projectPath}`, {
385
+ code: 'PROJECT_PATH_NOT_FOUND', category: 'configuration',
386
+ });
371
387
  }
372
388
  const spinner = createSpinner('Analyzing project...').start();
373
389
  try {
@@ -428,7 +444,9 @@ export class RepoCommand {
428
444
  if (contentBytes > MAX_REPOSITORY_CONTENT_BYTES) {
429
445
  throw new Error(`Repository content is ${formatBytes(contentBytes)} and exceeds the ${formatBytes(MAX_REPOSITORY_CONTENT_BYTES)} limit`);
430
446
  }
447
+ const operationId = createOperationId();
431
448
  const pushPayload = JSON.stringify({
449
+ operationId,
432
450
  projectName: answers.name,
433
451
  description: answers.description,
434
452
  techStack: projectInfo.techStack.join(', '),
@@ -447,7 +465,7 @@ export class RepoCommand {
447
465
  const registerResponse = await this.repoFetch('/api/repo/push', {
448
466
  method: 'POST',
449
467
  body: pushPayload
450
- });
468
+ }, { operationId });
451
469
  if (!registerResponse.ok) {
452
470
  const error = await readResponsePayload(registerResponse);
453
471
  throw new RepositoryApiError(registerResponse.status, error, 'Failed to push project');
@@ -467,7 +485,6 @@ export class RepoCommand {
467
485
  }
468
486
  catch (error) {
469
487
  spinner.stop();
470
- this.logger.error('Push failed');
471
488
  if (error instanceof RepositoryApiError && error.payload?.code === 'REPOSITORY_SECURITY_REVIEW') {
472
489
  this.printSecurityReview({
473
490
  reviewId: error.payload.reviewId,
@@ -478,8 +495,7 @@ export class RepoCommand {
478
495
  retryCommand: error.payload.retryCommand,
479
496
  });
480
497
  }
481
- const errMsg = this.formatRepoError(error);
482
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
498
+ throw commandFailure(error, { code: 'REPOSITORY_PUSH_FAILED', message: this.formatRepoError(error) });
483
499
  }
484
500
  }
485
501
  /**
@@ -499,7 +515,7 @@ export class RepoCommand {
499
515
  }
500
516
  spinner.stop();
501
517
  if (options.json) {
502
- console.log(JSON.stringify(reviewId ? payload.review : payload.reviews, null, 2));
518
+ console.log(formatSuccessJson('repo review', reviewId ? payload.review : payload.reviews));
503
519
  return;
504
520
  }
505
521
  if (reviewId) {
@@ -519,7 +535,103 @@ export class RepoCommand {
519
535
  }
520
536
  catch (error) {
521
537
  spinner.stop();
522
- console.log(chalk.red(`\n❌ Error: ${this.formatRepoError(error)}\n`));
538
+ throw commandFailure(error, { code: 'REPOSITORY_REVIEW_FAILED', message: this.formatRepoError(error) });
539
+ }
540
+ }
541
+ async materializeProject(data, repo, requestedName, options, publicDownload = false) {
542
+ const remoteProjectName = String(data.project?.project_name || data.projectName || repo.name || repo.project_name || requestedName);
543
+ const outputPath = options.output
544
+ ? assertSafeDestinationPath(path.resolve(options.output))
545
+ : resolveWorkspacePath(process.cwd(), assertSafeRelativePath(remoteProjectName), { allowMissing: true });
546
+ if (fs.existsSync(outputPath) && !options.force) {
547
+ const { overwrite } = await inquirer.prompt([{
548
+ type: 'confirm',
549
+ name: 'overwrite',
550
+ message: `Directory ${outputPath} exists. Overwrite?`,
551
+ default: false
552
+ }]);
553
+ if (!overwrite) {
554
+ throw new CliCommandError('Repository materialization cancelled by user', {
555
+ code: 'REPOSITORY_PULL_CANCELLED', category: 'cancelled',
556
+ });
557
+ }
558
+ }
559
+ const downloadSpinner = createSpinner('Downloading project files...').start();
560
+ try {
561
+ if (fs.existsSync(outputPath))
562
+ validateExtractedWorkspace(outputPath);
563
+ if (data.downloadUrl) {
564
+ const archiveResponse = await guardedFetch(data.downloadUrl, {
565
+ headers: publicDownload
566
+ ? { 'Content-Type': 'application/json' }
567
+ : this.getAuthHeadersForUrl(data.downloadUrl)
568
+ }, { audience: this.getRepositoryAudienceForUrl(data.downloadUrl) });
569
+ if (!archiveResponse.ok)
570
+ throw new Error('Failed to download project archive');
571
+ const archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
572
+ inspectZipArchive(archiveBuffer);
573
+ const os = require('os');
574
+ const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-pull-'));
575
+ const tempArchive = path.join(stagingRoot, 'archive.zip');
576
+ const extractedPath = path.join(stagingRoot, 'extracted');
577
+ try {
578
+ fs.mkdirSync(extractedPath, { mode: 0o700 });
579
+ fs.writeFileSync(tempArchive, archiveBuffer, { mode: 0o600 });
580
+ if (os.platform() === 'win32') {
581
+ const { execFileSync } = await import('child_process');
582
+ execFileSync('powershell', [
583
+ '-NoProfile', '-NonInteractive', '-Command',
584
+ 'Expand-Archive -LiteralPath $env:VIGTHORIA_ARCHIVE_PATH -DestinationPath $env:VIGTHORIA_ARCHIVE_DESTINATION -Force'
585
+ ], {
586
+ stdio: 'ignore',
587
+ timeout: 120_000,
588
+ windowsHide: true,
589
+ env: safeChildProcessEnv(process.env, {
590
+ VIGTHORIA_ARCHIVE_PATH: tempArchive,
591
+ VIGTHORIA_ARCHIVE_DESTINATION: extractedPath,
592
+ }),
593
+ });
594
+ }
595
+ else {
596
+ const { execFileSync } = await import('child_process');
597
+ execFileSync('unzip', ['-o', tempArchive, '-d', extractedPath], { stdio: 'ignore', timeout: 120_000, env: safeChildProcessEnv() });
598
+ }
599
+ validateExtractedWorkspace(extractedPath);
600
+ mergeValidatedWorkspace(extractedPath, outputPath);
601
+ }
602
+ finally {
603
+ fs.rmSync(stagingRoot, { recursive: true, force: true });
604
+ }
605
+ }
606
+ else if (data.files) {
607
+ const os = require('os');
608
+ const stagingRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'vigthoria-pull-inline-'));
609
+ try {
610
+ for (const file of data.files) {
611
+ const filePath = resolveWorkspacePath(stagingRoot, file.path, { allowMissing: true });
612
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
613
+ fs.writeFileSync(filePath, file.content, { mode: 0o600 });
614
+ }
615
+ validateExtractedWorkspace(stagingRoot);
616
+ mergeValidatedWorkspace(stagingRoot, outputPath);
617
+ }
618
+ finally {
619
+ fs.rmSync(stagingRoot, { recursive: true, force: true });
620
+ }
621
+ }
622
+ else {
623
+ throw new Error('Repository response contained neither an archive nor inline files');
624
+ }
625
+ downloadSpinner.succeed(chalk.green('Project materialized successfully!'));
626
+ console.log(chalk.cyan('\n📁 Project extracted to:'));
627
+ console.log(chalk.white(` ${outputPath}`));
628
+ console.log(chalk.gray(`\n Tech Stack: ${data.project?.tech_stack || repo.tech_stack || 'Unknown'}`));
629
+ console.log(chalk.gray(` Description: ${data.project?.description || data.description || repo.description || '(none)'}`));
630
+ console.log();
631
+ }
632
+ catch (error) {
633
+ downloadSpinner.stop();
634
+ throw error;
523
635
  }
524
636
  }
525
637
  /**
@@ -533,81 +645,18 @@ export class RepoCommand {
533
645
  const response = await this.repoFetch('/api/repo/pull', {
534
646
  method: 'POST',
535
647
  body: JSON.stringify({ projectId: repo.id })
536
- });
648
+ }, { readOnly: true });
537
649
  if (!response.ok) {
538
650
  const error = await readResponsePayload(response);
539
651
  throw new Error(error.error || error.message || 'Project not found');
540
652
  }
541
653
  const data = await response.json();
542
654
  spinner.succeed(`Found project: ${chalk.cyan(data.project?.project_name || data.projectName || repo.name || repo.project_name || projectName)}`);
543
- const outputPath = options.output
544
- ? path.resolve(options.output)
545
- : path.join(process.cwd(), data.project?.project_name || data.projectName || repo.name || repo.project_name || projectName);
546
- if (fs.existsSync(outputPath) && !options.force) {
547
- const { overwrite } = await inquirer.prompt([{
548
- type: 'confirm',
549
- name: 'overwrite',
550
- message: `Directory ${outputPath} exists. Overwrite?`,
551
- default: false
552
- }]);
553
- if (!overwrite) {
554
- console.log(chalk.yellow(`\n${CH.warnEmoji} Pull cancelled.\n`));
555
- return;
556
- }
557
- }
558
- const downloadSpinner = createSpinner('Downloading project files...').start();
559
- // Create output directory
560
- fs.mkdirSync(outputPath, { recursive: true });
561
- // If we have a download URL, fetch and extract
562
- if (data.downloadUrl) {
563
- const archiveResponse = await fetch(data.downloadUrl, {
564
- headers: this.getAuthHeaders()
565
- });
566
- if (!archiveResponse.ok) {
567
- throw new Error('Failed to download project archive');
568
- }
569
- // Save and extract the archive
570
- const tempArchive = path.join(require('os').tmpdir(), `vigthoria-pull-${Date.now()}.zip`);
571
- const archiveBuffer = Buffer.from(await archiveResponse.arrayBuffer());
572
- fs.writeFileSync(tempArchive, archiveBuffer);
573
- // Extract archive - cross-platform
574
- const os = require('os');
575
- const platform = os.platform();
576
- if (platform === 'win32') {
577
- // Use PowerShell's Expand-Archive — args as array to prevent injection
578
- const { execFileSync } = await import('child_process');
579
- execFileSync('powershell', [
580
- '-NoProfile', '-NonInteractive', '-Command',
581
- `Expand-Archive -Path '${tempArchive.replace(/'/g, "''")}' -DestinationPath '${outputPath.replace(/'/g, "''")}' -Force`
582
- ], { stdio: 'ignore', windowsHide: true });
583
- }
584
- else {
585
- // Use unzip on Unix-like systems — args as array to prevent injection
586
- const { execFileSync } = await import('child_process');
587
- execFileSync('unzip', ['-o', tempArchive, '-d', outputPath], { stdio: 'ignore' });
588
- }
589
- fs.unlinkSync(tempArchive);
590
- }
591
- else if (data.files) {
592
- // If we have files inline (small projects)
593
- for (const file of data.files) {
594
- const filePath = path.join(outputPath, file.path);
595
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
596
- fs.writeFileSync(filePath, file.content);
597
- }
598
- }
599
- downloadSpinner.succeed(chalk.green('Project pulled successfully!'));
600
- console.log(chalk.cyan('\n📁 Project extracted to:'));
601
- console.log(chalk.white(` ${outputPath}`));
602
- console.log(chalk.gray(`\n Tech Stack: ${data.project?.tech_stack || repo.tech_stack || 'Unknown'}`));
603
- console.log(chalk.gray(` Description: ${data.project?.description || data.description || repo.description || '(none)'}`));
604
- console.log();
655
+ await this.materializeProject(data, repo, projectName, options);
605
656
  }
606
657
  catch (error) {
607
658
  spinner.stop();
608
- this.logger.error('Pull failed');
609
- const errMsg = this.formatRepoError(error);
610
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
659
+ throw commandFailure(error, { code: 'REPOSITORY_PULL_FAILED', message: this.formatRepoError(error) });
611
660
  }
612
661
  }
613
662
  /**
@@ -704,9 +753,7 @@ export class RepoCommand {
704
753
  }
705
754
  catch (error) {
706
755
  spinner.stop();
707
- this.logger.error('List failed');
708
- const errMsg = this.formatRepoError(error);
709
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
756
+ throw commandFailure(error, { code: 'REPOSITORY_LIST_FAILED', message: this.formatRepoError(error) });
710
757
  }
711
758
  }
712
759
  /**
@@ -718,10 +765,10 @@ export class RepoCommand {
718
765
  const projectInfo = this.detectProjectInfo(projectPath);
719
766
  const spinner = createSpinner('Checking sync status...').start();
720
767
  try {
721
- const response = await fetch(`${this.apiBase}/api/repo/status/${encodeURIComponent(projectInfo.name)}`, {
768
+ const response = await guardedFetch(`${this.apiBase}/api/repo/status/${encodeURIComponent(projectInfo.name)}`, {
722
769
  method: 'GET',
723
- headers: this.getAuthHeaders()
724
- });
770
+ headers: this.getAuthHeaders('coder')
771
+ }, { audience: 'coder' });
725
772
  if (!response.ok) {
726
773
  spinner.succeed('Project not tracked in Vigthoria Community Repository');
727
774
  console.log(chalk.yellow('\n📦 Current project is not synced with Vigthoria Community Repository.\n'));
@@ -755,9 +802,7 @@ export class RepoCommand {
755
802
  }
756
803
  catch (error) {
757
804
  spinner.stop();
758
- this.logger.error('Status check failed');
759
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
760
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
805
+ throw commandFailure(error, { code: 'REPOSITORY_STATUS_FAILED' });
761
806
  }
762
807
  }
763
808
  /**
@@ -767,14 +812,15 @@ export class RepoCommand {
767
812
  this.requireAuth();
768
813
  const spinner = createSpinner('Generating share link...').start();
769
814
  try {
770
- const response = await fetch(`${this.apiBase}/api/repo/share`, {
815
+ const operationId = createOperationId();
816
+ const response = await this.repoFetch('/api/repo/share', {
771
817
  method: 'POST',
772
- headers: this.getAuthHeaders(),
773
818
  body: JSON.stringify({
819
+ operationId,
774
820
  projectName,
775
821
  expiresIn: options.expires || '7d'
776
822
  })
777
- });
823
+ }, { operationId });
778
824
  if (!response.ok) {
779
825
  const error = await response.json();
780
826
  throw new Error(error.error || 'Failed to generate share link');
@@ -788,32 +834,33 @@ export class RepoCommand {
788
834
  }
789
835
  catch (error) {
790
836
  spinner.stop();
791
- this.logger.error('Share failed');
792
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
793
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
837
+ throw commandFailure(error, { code: 'REPOSITORY_SHARE_FAILED' });
794
838
  }
795
839
  }
796
840
  /**
797
841
  * Delete a project from Vigthoria Repository
798
842
  */
799
- async delete(projectName) {
843
+ async delete(projectName, options = {}) {
800
844
  this.requireAuth();
801
- const { confirm } = await inquirer.prompt([{
802
- type: 'confirm',
803
- name: 'confirm',
804
- message: chalk.red(`Are you sure you want to delete "${projectName}" from Vigthoria Community Repository?`),
805
- default: false
806
- }]);
845
+ const confirm = options.yes === true
846
+ ? true
847
+ : (await inquirer.prompt([{
848
+ type: 'confirm',
849
+ name: 'confirm',
850
+ message: chalk.red(`Are you sure you want to delete "${projectName}" from Vigthoria Community Repository?`),
851
+ default: false
852
+ }])).confirm;
807
853
  if (!confirm) {
808
854
  console.log(chalk.yellow(`\n${CH.warnEmoji} Delete cancelled.\n`));
809
855
  return;
810
856
  }
811
857
  const spinner = createSpinner('Deleting project...').start();
812
858
  try {
813
- const response = await fetch(`${this.apiBase}/api/repo/projects/${encodeURIComponent(projectName)}`, {
859
+ const operationId = createOperationId();
860
+ const response = await this.repoFetch(`/api/repo/projects/${encodeURIComponent(projectName)}`, {
814
861
  method: 'DELETE',
815
- headers: this.getAuthHeaders()
816
- });
862
+ headers: { 'X-Vigthoria-Operation-Id': operationId },
863
+ }, { operationId });
817
864
  if (!response.ok) {
818
865
  const error = await response.json();
819
866
  throw new Error(error.error || 'Failed to delete project');
@@ -823,9 +870,7 @@ export class RepoCommand {
823
870
  }
824
871
  catch (error) {
825
872
  spinner.stop();
826
- this.logger.error('Delete failed');
827
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
828
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
873
+ throw commandFailure(error, { code: 'REPOSITORY_DELETE_FAILED' });
829
874
  }
830
875
  }
831
876
  /**
@@ -844,16 +889,22 @@ export class RepoCommand {
844
889
  const resolvedName = projectName || this.detectProjectInfo(process.cwd()).name;
845
890
  const spinner = createSpinner(`Opening "${resolvedName}" in ${label}...`).start();
846
891
  try {
847
- const response = await fetch(`${this.apiBase}/api/engine-import`, {
892
+ const operationId = createOperationId();
893
+ const response = await guardedFetch(`${this.apiBase}/api/engine-import`, {
848
894
  method: 'POST',
849
- headers: this.getAuthHeaders(),
895
+ headers: {
896
+ ...this.getAuthHeaders('coder'),
897
+ 'Idempotency-Key': operationId,
898
+ 'X-Vigthoria-Operation-Id': operationId,
899
+ },
850
900
  body: JSON.stringify({
901
+ operationId,
851
902
  engine,
852
903
  projectName: resolvedName,
853
904
  shopId: options.shopId || 'default',
854
905
  source: 'repo'
855
906
  })
856
- });
907
+ }, { audience: 'coder' });
857
908
  if (!response.ok) {
858
909
  const err = await readResponsePayload(response);
859
910
  throw new Error(err.error || `Engine import failed (${response.status})`);
@@ -873,11 +924,11 @@ export class RepoCommand {
873
924
  }
874
925
  const safeUrl = parsedUrl.href;
875
926
  if (platform === 'win32')
876
- execFileSync('cmd', ['/c', 'start', '', safeUrl], { stdio: 'ignore', windowsHide: true });
927
+ execFileSync('rundll32.exe', ['url.dll,FileProtocolHandler', safeUrl], { stdio: 'ignore', timeout: 10_000, windowsHide: true, env: safeChildProcessEnv() });
877
928
  else if (platform === 'darwin')
878
- execFileSync('open', [safeUrl], { stdio: 'ignore' });
929
+ execFileSync('open', [safeUrl], { stdio: 'ignore', timeout: 10_000, env: safeChildProcessEnv() });
879
930
  else
880
- execFileSync('xdg-open', [safeUrl], { stdio: 'ignore' });
931
+ execFileSync('xdg-open', [safeUrl], { stdio: 'ignore', timeout: 10_000, env: safeChildProcessEnv() });
881
932
  }
882
933
  catch { /* browser open is optional */ }
883
934
  }
@@ -887,9 +938,7 @@ export class RepoCommand {
887
938
  }
888
939
  catch (error) {
889
940
  spinner.stop();
890
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
891
- console.log(chalk.red(`\n❌ ${label} import failed: ${errMsg}\n`));
892
- console.log(chalk.gray('Tip: Make sure your project is pushed first with `vigthoria repo push`\n'));
941
+ throw commandFailure(error, { code: 'REPOSITORY_ENGINE_IMPORT_FAILED' });
893
942
  }
894
943
  }
895
944
  /**
@@ -901,24 +950,24 @@ export class RepoCommand {
901
950
  const projectId = projectIdMatch ? projectIdMatch[1] : projectUrl;
902
951
  const spinner = createSpinner(`Cloning project...`).start();
903
952
  try {
904
- const response = await fetch(`${this.apiBase}/api/repo/clone/${projectId}`, {
953
+ const response = await guardedFetch(`${this.apiBase}/api/repo/clone/${projectId}`, {
905
954
  method: 'GET',
906
- headers: this.isAuthenticated() ? this.getAuthHeaders() : { 'Content-Type': 'application/json' }
907
- });
955
+ headers: this.isAuthenticated() ? this.getAuthHeaders('coder') : { 'Content-Type': 'application/json' }
956
+ }, { audience: 'coder' });
908
957
  if (!response.ok) {
909
958
  const error = await response.json();
910
959
  throw new Error(error.error || 'Project not found or not public');
911
960
  }
912
961
  const data = await response.json();
913
962
  spinner.succeed(`Found project: ${chalk.cyan(data.project.project_name)}`);
914
- // Use pull logic for the rest
915
- await this.pull(data.project.project_name, options);
963
+ // Public clones materialize the clone response directly. They must
964
+ // not route through authenticated `pull`, and public archive
965
+ // downloads must not inherit a user credential.
966
+ await this.materializeProject(data, data.project, data.project.project_name, options, true);
916
967
  }
917
968
  catch (error) {
918
969
  spinner.stop();
919
- this.logger.error('Clone failed');
920
- const errMsg = error instanceof Error ? error.message : 'Unknown error';
921
- console.log(chalk.red(`\n❌ Error: ${errMsg}\n`));
970
+ throw commandFailure(error, { code: 'REPOSITORY_CLONE_FAILED' });
922
971
  }
923
972
  }
924
973
  }