prjct-cli 0.62.0 → 0.63.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.63.0] - 2026-02-05
4
+
5
+ ### Features
6
+
7
+ - add timeout management with configurable limits (PRJ-111) (#104)
8
+
9
+
10
+ ## [0.62.1] - 2026-02-05
11
+
12
+ ### Improved
13
+
14
+ - **Timeout management (PRJ-111)**: Operations now timeout gracefully with configurable limits instead of hanging indefinitely
15
+
16
+ ### Implementation Details
17
+
18
+ Added `TIMEOUTS` constants with `getTimeout()` helper function supporting environment variable overrides (`PRJCT_TIMEOUT_*`). Applied timeouts to: npm install (120s), git operations (10s), git clone (60s), and fetch API calls (30s via AbortController). Timeout errors now include helpful hints for increasing limits.
19
+
20
+ ### Learnings
21
+
22
+ - AbortController is the standard way to timeout fetch() calls - create controller, set timeout to call abort(), pass signal to fetch
23
+ - Environment variable pattern `PRJCT_TIMEOUT_*` allows user configurability without config files
24
+
25
+ ### Test Plan
26
+
27
+ #### For QA
28
+ 1. Set `PRJCT_TIMEOUT_GIT_OPERATION=100` (100ms) and run `prjct sync` - should timeout
29
+ 2. Unset env var, run `prjct sync` on a large repo - should complete within 10s
30
+ 3. Test npm install timeout with `PRJCT_TIMEOUT_NPM_INSTALL=1000` (1s) - should timeout with helpful message
31
+
32
+ #### For Users
33
+ - Operations now timeout gracefully instead of hanging indefinitely
34
+ - Set `PRJCT_TIMEOUT_*` env vars to customize timeouts (e.g., `export PRJCT_TIMEOUT_API_REQUEST=60000` for 60s)
35
+ - No breaking changes
36
+
37
+
3
38
  ## [0.62.0] - 2026-02-05
4
39
 
5
40
  ### Features
@@ -218,6 +218,52 @@ export const PLANNING_TOOLS = [
218
218
  'GetDateTime',
219
219
  ] as const
220
220
 
221
+ // =============================================================================
222
+ // Timeout Constants (PRJ-111)
223
+ // =============================================================================
224
+
225
+ /**
226
+ * Timeout values in milliseconds for various operations.
227
+ * Can be overridden via PRJCT_TIMEOUT_* environment variables.
228
+ */
229
+ export const TIMEOUTS = {
230
+ /** Tool availability checks (git --version, npm --version) */
231
+ TOOL_CHECK: 5_000,
232
+
233
+ /** Standard git operations (status, add, commit) */
234
+ GIT_OPERATION: 10_000,
235
+
236
+ /** Git clone with --depth 1 */
237
+ GIT_CLONE: 60_000,
238
+
239
+ /** HTTP fetch/API requests */
240
+ API_REQUEST: 30_000,
241
+
242
+ /** npm install -g (CLI installation) - 2 minutes */
243
+ NPM_INSTALL: 120_000,
244
+
245
+ /** User-defined workflow hooks */
246
+ WORKFLOW_HOOK: 60_000,
247
+ } as const
248
+
249
+ export type TimeoutKey = keyof typeof TIMEOUTS
250
+
251
+ /**
252
+ * Get timeout value with optional environment variable override.
253
+ * Environment variables: PRJCT_TIMEOUT_TOOL_CHECK, PRJCT_TIMEOUT_GIT_OPERATION, etc.
254
+ */
255
+ export function getTimeout(key: TimeoutKey): number {
256
+ const envVar = `PRJCT_TIMEOUT_${key}`
257
+ const envValue = process.env[envVar]
258
+ if (envValue) {
259
+ const parsed = Number.parseInt(envValue, 10)
260
+ if (!Number.isNaN(parsed) && parsed > 0) {
261
+ return parsed
262
+ }
263
+ }
264
+ return TIMEOUTS[key]
265
+ }
266
+
221
267
  // =============================================================================
222
268
  // Combined Exports
223
269
  // =============================================================================
@@ -245,3 +291,11 @@ export const PLAN = {
245
291
  DESTRUCTIVE_COMMANDS,
246
292
  TOOLS: PLANNING_TOOLS,
247
293
  } as const
294
+
295
+ /**
296
+ * Combined timeout exports for easy import.
297
+ */
298
+ export const TIMEOUT = {
299
+ VALUES: TIMEOUTS,
300
+ get: getTimeout,
301
+ } as const
@@ -21,6 +21,7 @@ import { execSync } from 'node:child_process'
21
21
  import fs from 'node:fs'
22
22
  import os from 'node:os'
23
23
  import path from 'node:path'
24
+ import { getTimeout } from '../constants'
24
25
  import { dependencyValidator } from '../services/dependency-validator'
25
26
  import { isNotFoundError } from '../types/fs'
26
27
  import type { AIProviderConfig, AIProviderName } from '../types/provider'
@@ -91,15 +92,28 @@ async function installAICLI(provider: AIProviderConfig): Promise<boolean> {
91
92
  try {
92
93
  console.log(`${YELLOW}📦 ${provider.displayName} not found. Installing...${NC}`)
93
94
  console.log('')
94
- execSync(`npm install -g ${packageName}`, { stdio: 'inherit' })
95
+ // PRJ-111: Add timeout to npm install (default: 2 minutes, configurable via PRJCT_TIMEOUT_NPM_INSTALL)
96
+ execSync(`npm install -g ${packageName}`, {
97
+ stdio: 'inherit',
98
+ timeout: getTimeout('NPM_INSTALL'),
99
+ })
95
100
  console.log('')
96
101
  console.log(`${GREEN}✓${NC} ${provider.displayName} installed successfully`)
97
102
  console.log('')
98
103
  return true
99
104
  } catch (error) {
100
- console.log(
101
- `${YELLOW}⚠️ Failed to install ${provider.displayName}: ${(error as Error).message}${NC}`
102
- )
105
+ const err = error as Error & { killed?: boolean; signal?: string }
106
+ const isTimeout = err.killed && err.signal === 'SIGTERM'
107
+
108
+ if (isTimeout) {
109
+ console.log(`${YELLOW}⚠️ Installation timed out for ${provider.displayName}${NC}`)
110
+ console.log('')
111
+ console.log(`${DIM}The npm install took too long. Try:${NC}`)
112
+ console.log(`${DIM} • Set PRJCT_TIMEOUT_NPM_INSTALL=300000 for 5 minutes${NC}`)
113
+ console.log(`${DIM} • Run manually: npm install -g ${packageName}${NC}`)
114
+ } else {
115
+ console.log(`${YELLOW}⚠️ Failed to install ${provider.displayName}: ${err.message}${NC}`)
116
+ }
103
117
  console.log('')
104
118
  console.log(`${DIM}Alternative installation methods:${NC}`)
105
119
  console.log(`${DIM} • npm: npm install -g ${packageName}${NC}`)
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { exec } from 'node:child_process'
14
14
  import { promisify } from 'node:util'
15
+ import { getTimeout } from '../constants'
15
16
  import { dependencyValidator } from './dependency-validator'
16
17
 
17
18
  const execAsync = promisify(exec)
@@ -99,6 +100,7 @@ export class GitAnalyzer {
99
100
  try {
100
101
  const { stdout } = await execAsync('git branch --show-current', {
101
102
  cwd: this.projectPath,
103
+ timeout: getTimeout('GIT_OPERATION'),
102
104
  })
103
105
  return stdout.trim() || 'main'
104
106
  } catch {
@@ -113,6 +115,7 @@ export class GitAnalyzer {
113
115
  try {
114
116
  const { stdout } = await execAsync('git rev-list --count HEAD', {
115
117
  cwd: this.projectPath,
118
+ timeout: getTimeout('GIT_OPERATION'),
116
119
  })
117
120
  return parseInt(stdout.trim(), 10) || 0
118
121
  } catch {
@@ -128,6 +131,7 @@ export class GitAnalyzer {
128
131
  try {
129
132
  const { stdout } = await execAsync('git shortlog -sn --all', {
130
133
  cwd: this.projectPath,
134
+ timeout: getTimeout('GIT_OPERATION'),
131
135
  })
132
136
  // Count non-empty lines instead of piping to wc -l
133
137
  const lines = stdout.trim().split('\n').filter(Boolean)
@@ -156,6 +160,7 @@ export class GitAnalyzer {
156
160
  try {
157
161
  const { stdout } = await execAsync('git status --porcelain', {
158
162
  cwd: this.projectPath,
163
+ timeout: getTimeout('GIT_OPERATION'),
159
164
  })
160
165
  const lines = stdout.trim().split('\n').filter(Boolean)
161
166
  result.hasChanges = lines.length > 0
@@ -188,7 +193,7 @@ export class GitAnalyzer {
188
193
  try {
189
194
  const { stdout } = await execAsync(
190
195
  `git log --oneline -${count} --pretty=format:"%h|%s|%ad" --date=short`,
191
- { cwd: this.projectPath }
196
+ { cwd: this.projectPath, timeout: getTimeout('GIT_OPERATION') }
192
197
  )
193
198
 
194
199
  return stdout
@@ -211,6 +216,7 @@ export class GitAnalyzer {
211
216
  try {
212
217
  const { stdout } = await execAsync('git log --oneline --since="1 week ago"', {
213
218
  cwd: this.projectPath,
219
+ timeout: getTimeout('GIT_OPERATION'),
214
220
  })
215
221
  // Count non-empty lines instead of piping to wc -l
216
222
  const lines = stdout.trim().split('\n').filter(Boolean)
@@ -227,6 +233,7 @@ export class GitAnalyzer {
227
233
  try {
228
234
  await execAsync('git rev-parse --is-inside-work-tree', {
229
235
  cwd: this.projectPath,
236
+ timeout: getTimeout('GIT_OPERATION'),
230
237
  })
231
238
  return true
232
239
  } catch {
@@ -243,6 +250,7 @@ export class GitAnalyzer {
243
250
  // Try to get from remote
244
251
  const { stdout } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
245
252
  cwd: this.projectPath,
253
+ timeout: getTimeout('GIT_OPERATION'),
246
254
  })
247
255
  // Use JS replace instead of piping to sed
248
256
  const branch = stdout.trim().replace(/^refs\/remotes\/origin\//, '')
@@ -255,6 +263,7 @@ export class GitAnalyzer {
255
263
  try {
256
264
  await execAsync('git show-ref --verify --quiet refs/heads/main', {
257
265
  cwd: this.projectPath,
266
+ timeout: getTimeout('GIT_OPERATION'),
258
267
  })
259
268
  return 'main'
260
269
  } catch {
@@ -20,6 +20,7 @@ import os from 'node:os'
20
20
  import path from 'node:path'
21
21
  import { promisify } from 'node:util'
22
22
  import { glob } from 'glob'
23
+ import { getTimeout } from '../constants'
23
24
  import { dependencyValidator } from './dependency-validator'
24
25
  import type { SkillLockEntry } from './skill-lock'
25
26
  import { skillLock } from './skill-lock'
@@ -252,13 +253,17 @@ async function installFromGitHub(source: ParsedSource): Promise<InstallResult> {
252
253
 
253
254
  try {
254
255
  // Clone with depth 1 for speed
256
+ // PRJ-111: Configurable timeout (default: 60s, override via PRJCT_TIMEOUT_GIT_CLONE)
255
257
  const cloneUrl = `https://github.com/${source.owner}/${source.repo}.git`
256
- await exec(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, { timeout: 60_000 })
258
+ await exec(`git clone --depth 1 ${cloneUrl} ${tmpDir}`, { timeout: getTimeout('GIT_CLONE') })
257
259
 
258
260
  // Get the commit SHA
259
261
  let sha: string | undefined
260
262
  try {
261
- const { stdout } = await exec('git rev-parse HEAD', { cwd: tmpDir, timeout: 5_000 })
263
+ const { stdout } = await exec('git rev-parse HEAD', {
264
+ cwd: tmpDir,
265
+ timeout: getTimeout('TOOL_CHECK'),
266
+ })
262
267
  sha = stdout.trim()
263
268
  } catch {
264
269
  // Non-critical
@@ -3,8 +3,11 @@
3
3
  *
4
4
  * Handles communication with the backend API for push/pull operations.
5
5
  * Uses native fetch API (available in Node 18+ and Bun).
6
+ *
7
+ * PRJ-111: Includes configurable timeout support via AbortController.
6
8
  */
7
9
 
10
+ import { getTimeout } from '../constants'
8
11
  import type { SyncEvent } from '../events'
9
12
  import type { SyncBatchResult, SyncClientError, SyncPullResult, SyncStatus } from '../types'
10
13
  import authConfig from './auth-config'
@@ -114,10 +117,15 @@ class SyncClient {
114
117
  * Test connection to the API
115
118
  */
116
119
  async testConnection(): Promise<boolean> {
120
+ // PRJ-111: Add timeout to connection test
121
+ const controller = new AbortController()
122
+ const timeoutId = setTimeout(() => controller.abort(), getTimeout('API_REQUEST'))
123
+
117
124
  try {
118
125
  const { apiUrl, apiKey } = await this.getAuthHeaders()
119
126
 
120
127
  if (!apiKey) {
128
+ clearTimeout(timeoutId)
121
129
  return false
122
130
  }
123
131
 
@@ -126,11 +134,14 @@ class SyncClient {
126
134
  headers: {
127
135
  'X-Api-Key': apiKey,
128
136
  },
137
+ signal: controller.signal,
129
138
  })
130
139
 
140
+ clearTimeout(timeoutId)
131
141
  return response.ok
132
142
  } catch (_error) {
133
- // Network error or other issue - expected
143
+ // Network error, timeout, or other issue - expected
144
+ clearTimeout(timeoutId)
134
145
  return false
135
146
  }
136
147
  }
@@ -156,8 +167,17 @@ class SyncClient {
156
167
  options: RequestInit,
157
168
  retryCount = 0
158
169
  ): Promise<Response> {
170
+ // PRJ-111: Add AbortController-based timeout (default: 30s, configurable via PRJCT_TIMEOUT_API_REQUEST)
171
+ const controller = new AbortController()
172
+ const timeoutId = setTimeout(() => controller.abort(), getTimeout('API_REQUEST'))
173
+
159
174
  try {
160
- const response = await fetch(url, options)
175
+ const response = await fetch(url, {
176
+ ...options,
177
+ signal: controller.signal,
178
+ })
179
+
180
+ clearTimeout(timeoutId)
161
181
 
162
182
  // Retry on server errors (5xx) but not client errors (4xx)
163
183
  if (response.status >= 500 && retryCount < this.retryConfig.maxRetries) {
@@ -171,6 +191,16 @@ class SyncClient {
171
191
 
172
192
  return response
173
193
  } catch (error) {
194
+ clearTimeout(timeoutId)
195
+
196
+ // Check if this is a timeout (AbortError)
197
+ if (error instanceof Error && error.name === 'AbortError') {
198
+ throw this.createError(
199
+ 'NETWORK_ERROR',
200
+ `Request timed out. Try increasing PRJCT_TIMEOUT_API_REQUEST (current: ${getTimeout('API_REQUEST')}ms)`
201
+ )
202
+ }
203
+
174
204
  // Retry on network errors
175
205
  if (retryCount < this.retryConfig.maxRetries) {
176
206
  const delay = Math.min(
@@ -11952,7 +11952,18 @@ var init_orchestrator_executor = __esm({
11952
11952
  });
11953
11953
 
11954
11954
  // core/constants/index.ts
11955
- var PLAN_STATUS, PLAN_REQUIRED_COMMANDS, DESTRUCTIVE_COMMANDS, PLANNING_TOOLS;
11955
+ function getTimeout(key) {
11956
+ const envVar = `PRJCT_TIMEOUT_${key}`;
11957
+ const envValue = process.env[envVar];
11958
+ if (envValue) {
11959
+ const parsed = Number.parseInt(envValue, 10);
11960
+ if (!Number.isNaN(parsed) && parsed > 0) {
11961
+ return parsed;
11962
+ }
11963
+ }
11964
+ return TIMEOUTS[key];
11965
+ }
11966
+ var PLAN_STATUS, PLAN_REQUIRED_COMMANDS, DESTRUCTIVE_COMMANDS, PLANNING_TOOLS, TIMEOUTS;
11956
11967
  var init_constants = __esm({
11957
11968
  "core/constants/index.ts"() {
11958
11969
  "use strict";
@@ -11997,6 +12008,21 @@ var init_constants = __esm({
11997
12008
  "GetDate",
11998
12009
  "GetDateTime"
11999
12010
  ];
12011
+ TIMEOUTS = {
12012
+ /** Tool availability checks (git --version, npm --version) */
12013
+ TOOL_CHECK: 5e3,
12014
+ /** Standard git operations (status, add, commit) */
12015
+ GIT_OPERATION: 1e4,
12016
+ /** Git clone with --depth 1 */
12017
+ GIT_CLONE: 6e4,
12018
+ /** HTTP fetch/API requests */
12019
+ API_REQUEST: 3e4,
12020
+ /** npm install -g (CLI installation) - 2 minutes */
12021
+ NPM_INSTALL: 12e4,
12022
+ /** User-defined workflow hooks */
12023
+ WORKFLOW_HOOK: 6e4
12024
+ };
12025
+ __name(getTimeout, "getTimeout");
12000
12026
  }
12001
12027
  });
12002
12028
 
@@ -15844,6 +15870,7 @@ var execAsync2;
15844
15870
  var init_git_analyzer = __esm({
15845
15871
  "core/services/git-analyzer.ts"() {
15846
15872
  "use strict";
15873
+ init_constants();
15847
15874
  init_dependency_validator();
15848
15875
  execAsync2 = promisify6(exec6);
15849
15876
  }
@@ -22747,15 +22774,26 @@ async function installAICLI(provider) {
22747
22774
  try {
22748
22775
  console.log(`${YELLOW4}\u{1F4E6} ${provider.displayName} not found. Installing...${NC}`);
22749
22776
  console.log("");
22750
- execSync7(`npm install -g ${packageName}`, { stdio: "inherit" });
22777
+ execSync7(`npm install -g ${packageName}`, {
22778
+ stdio: "inherit",
22779
+ timeout: getTimeout("NPM_INSTALL")
22780
+ });
22751
22781
  console.log("");
22752
22782
  console.log(`${GREEN4}\u2713${NC} ${provider.displayName} installed successfully`);
22753
22783
  console.log("");
22754
22784
  return true;
22755
22785
  } catch (error) {
22756
- console.log(
22757
- `${YELLOW4}\u26A0\uFE0F Failed to install ${provider.displayName}: ${error.message}${NC}`
22758
- );
22786
+ const err = error;
22787
+ const isTimeout = err.killed && err.signal === "SIGTERM";
22788
+ if (isTimeout) {
22789
+ console.log(`${YELLOW4}\u26A0\uFE0F Installation timed out for ${provider.displayName}${NC}`);
22790
+ console.log("");
22791
+ console.log(`${DIM4}The npm install took too long. Try:${NC}`);
22792
+ console.log(`${DIM4} \u2022 Set PRJCT_TIMEOUT_NPM_INSTALL=300000 for 5 minutes${NC}`);
22793
+ console.log(`${DIM4} \u2022 Run manually: npm install -g ${packageName}${NC}`);
22794
+ } else {
22795
+ console.log(`${YELLOW4}\u26A0\uFE0F Failed to install ${provider.displayName}: ${err.message}${NC}`);
22796
+ }
22759
22797
  console.log("");
22760
22798
  console.log(`${DIM4}Alternative installation methods:${NC}`);
22761
22799
  console.log(`${DIM4} \u2022 npm: npm install -g ${packageName}${NC}`);
@@ -23374,6 +23412,7 @@ var GREEN4, YELLOW4, DIM4, NC, setup_default, isDirectRun;
23374
23412
  var init_setup = __esm({
23375
23413
  "core/infrastructure/setup.ts"() {
23376
23414
  "use strict";
23415
+ init_constants();
23377
23416
  init_dependency_validator();
23378
23417
  init_fs();
23379
23418
  init_version();
@@ -26798,7 +26837,7 @@ var require_package = __commonJS({
26798
26837
  "package.json"(exports, module) {
26799
26838
  module.exports = {
26800
26839
  name: "prjct-cli",
26801
- version: "0.62.0",
26840
+ version: "0.63.0",
26802
26841
  description: "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
26803
26842
  main: "core/index.ts",
26804
26843
  bin: {
@@ -409,6 +409,34 @@ var import_node_fs3 = __toESM(require("node:fs"));
409
409
  var import_node_os4 = __toESM(require("node:os"));
410
410
  var import_node_path5 = __toESM(require("node:path"));
411
411
 
412
+ // core/constants/index.ts
413
+ var TIMEOUTS = {
414
+ /** Tool availability checks (git --version, npm --version) */
415
+ TOOL_CHECK: 5e3,
416
+ /** Standard git operations (status, add, commit) */
417
+ GIT_OPERATION: 1e4,
418
+ /** Git clone with --depth 1 */
419
+ GIT_CLONE: 6e4,
420
+ /** HTTP fetch/API requests */
421
+ API_REQUEST: 3e4,
422
+ /** npm install -g (CLI installation) - 2 minutes */
423
+ NPM_INSTALL: 12e4,
424
+ /** User-defined workflow hooks */
425
+ WORKFLOW_HOOK: 6e4
426
+ };
427
+ function getTimeout(key) {
428
+ const envVar = `PRJCT_TIMEOUT_${key}`;
429
+ const envValue = process.env[envVar];
430
+ if (envValue) {
431
+ const parsed = Number.parseInt(envValue, 10);
432
+ if (!Number.isNaN(parsed) && parsed > 0) {
433
+ return parsed;
434
+ }
435
+ }
436
+ return TIMEOUTS[key];
437
+ }
438
+ __name(getTimeout, "getTimeout");
439
+
412
440
  // core/services/dependency-validator.ts
413
441
  var import_node_child_process = require("node:child_process");
414
442
 
@@ -1382,15 +1410,26 @@ async function installAICLI(provider) {
1382
1410
  try {
1383
1411
  console.log(`${YELLOW}\u{1F4E6} ${provider.displayName} not found. Installing...${NC}`);
1384
1412
  console.log("");
1385
- (0, import_node_child_process3.execSync)(`npm install -g ${packageName}`, { stdio: "inherit" });
1413
+ (0, import_node_child_process3.execSync)(`npm install -g ${packageName}`, {
1414
+ stdio: "inherit",
1415
+ timeout: getTimeout("NPM_INSTALL")
1416
+ });
1386
1417
  console.log("");
1387
1418
  console.log(`${GREEN}\u2713${NC} ${provider.displayName} installed successfully`);
1388
1419
  console.log("");
1389
1420
  return true;
1390
1421
  } catch (error) {
1391
- console.log(
1392
- `${YELLOW}\u26A0\uFE0F Failed to install ${provider.displayName}: ${error.message}${NC}`
1393
- );
1422
+ const err = error;
1423
+ const isTimeout = err.killed && err.signal === "SIGTERM";
1424
+ if (isTimeout) {
1425
+ console.log(`${YELLOW}\u26A0\uFE0F Installation timed out for ${provider.displayName}${NC}`);
1426
+ console.log("");
1427
+ console.log(`${DIM}The npm install took too long. Try:${NC}`);
1428
+ console.log(`${DIM} \u2022 Set PRJCT_TIMEOUT_NPM_INSTALL=300000 for 5 minutes${NC}`);
1429
+ console.log(`${DIM} \u2022 Run manually: npm install -g ${packageName}${NC}`);
1430
+ } else {
1431
+ console.log(`${YELLOW}\u26A0\uFE0F Failed to install ${provider.displayName}: ${err.message}${NC}`);
1432
+ }
1394
1433
  console.log("");
1395
1434
  console.log(`${DIM}Alternative installation methods:${NC}`);
1396
1435
  console.log(`${DIM} \u2022 npm: npm install -g ${packageName}${NC}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.62.0",
3
+ "version": "0.63.0",
4
4
  "description": "Context layer for AI agents. Project context for Claude Code, Gemini CLI, and more.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {