@jojonax/codex-copilot 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,10 +12,10 @@ PRD → Tasks → CodeX Dev → PR → AI Review → Fix → Merge → Next Task
12
12
 
13
13
  ```bash
14
14
  # Install globally
15
- npm install -g codex-copilot
15
+ npm install -g @jojonax/codex-copilot
16
16
 
17
17
  # Or run directly without installing
18
- npx codex-copilot
18
+ npx @jojonax/codex-copilot
19
19
 
20
20
  # In your project directory:
21
21
  codex-copilot init # Detect PRD, generate task queue
@@ -122,6 +122,13 @@ CodeX develops feature
122
122
  - [ ] GitHub Action for fully server-side automation
123
123
  - [ ] Support for monorepo / multi-package projects
124
124
 
125
+ ## Contributing
126
+
127
+ 1. Fork the repo
128
+ 2. Create a feature branch (`git checkout -b feat/awesome`)
129
+ 3. Commit your changes (`git commit -m 'feat: add awesome feature'`)
130
+ 4. Push and open a PR
131
+
125
132
  ## License
126
133
 
127
134
  MIT © Jonas Qin
package/bin/cli.js CHANGED
@@ -10,8 +10,7 @@
10
10
  * codex-copilot reset # 重置状态(重新开始)
11
11
  */
12
12
 
13
- import { fileURLToPath } from 'url';
14
- import { dirname, resolve } from 'path';
13
+ import { resolve } from 'path';
15
14
  import { existsSync } from 'fs';
16
15
 
17
16
  import { init } from '../src/commands/init.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "PRD-driven automated development orchestrator for CodeX / Cursor",
5
5
  "bin": {
6
6
  "codex-copilot": "./bin/cli.js"
@@ -7,9 +7,8 @@
7
7
  * 4. 创建 .codex-copilot/ 目录结构
8
8
  */
9
9
 
10
- import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from 'fs';
11
- import { resolve, dirname } from 'path';
12
- import { fileURLToPath } from 'url';
10
+ import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'fs';
11
+ import { resolve } from 'path';
13
12
  import { execSync } from 'child_process';
14
13
  import { detectPRD, readPRD } from '../utils/detect-prd.js';
15
14
  import { log } from '../utils/logger.js';
@@ -17,8 +16,6 @@ import { ask, confirm, select, closePrompt } from '../utils/prompt.js';
17
16
  import { git } from '../utils/git.js';
18
17
  import { github } from '../utils/github.js';
19
18
 
20
- const __dirname = dirname(fileURLToPath(import.meta.url));
21
-
22
19
  export async function init(projectDir) {
23
20
  log.title('📋 初始化 Codex-Copilot');
24
21
  log.blank();
@@ -66,6 +63,12 @@ export async function init(projectDir) {
66
63
  log.warn('未自动检测到 PRD 文档');
67
64
  const manualPath = await ask('请输入 PRD 文件路径(相对或绝对路径):');
68
65
  prdPath = resolve(projectDir, manualPath);
66
+ // 防止路径穿越:确保 PRD 路径在项目目录内或是绝对路径
67
+ if (!prdPath.startsWith(resolve(projectDir)) && !manualPath.startsWith('/')) {
68
+ log.error('PRD 路径不在项目目录内');
69
+ closePrompt();
70
+ process.exit(1);
71
+ }
69
72
  if (!existsSync(prdPath)) {
70
73
  log.error(`文件不存在: ${prdPath}`);
71
74
  closePrompt();
@@ -187,8 +190,7 @@ export async function init(projectDir) {
187
190
  if (autoparse) {
188
191
  log.step('调用 CodeX CLI 拆解 PRD...');
189
192
  try {
190
- const { execSync } = await import('child_process');
191
- execSync(`codex -q "${parsePrompt.slice(0, 2000)}"`, {
193
+ execSync(`codex -q --file .codex-copilot/parse-prd-prompt.md`, {
192
194
  cwd: projectDir,
193
195
  stdio: 'inherit',
194
196
  });
@@ -204,7 +206,8 @@ export async function init(projectDir) {
204
206
 
205
207
  function checkCodexCLI() {
206
208
  try {
207
- execSync('which codex', { stdio: 'pipe' });
209
+ const cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
210
+ execSync(cmd, { stdio: 'pipe' });
208
211
  return true;
209
212
  } catch {
210
213
  return false;
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
8
  import { resolve } from 'path';
9
+ import { execSync } from 'child_process';
9
10
  import { log, progressBar } from '../utils/logger.js';
10
11
  import { git } from '../utils/git.js';
11
12
  import { github } from '../utils/github.js';
@@ -24,10 +25,27 @@ export async function run(projectDir) {
24
25
  const statePath = resolve(projectDir, '.codex-copilot/state.json');
25
26
  const configPath = resolve(projectDir, '.codex-copilot/config.json');
26
27
 
27
- const tasks = readJSON(tasksPath);
28
- let state = readJSON(statePath);
28
+ let tasks;
29
+ let state;
30
+ try {
31
+ tasks = readJSON(tasksPath);
32
+ state = readJSON(statePath);
33
+ } catch (err) {
34
+ log.error(`读取任务/状态文件失败: ${err.message}`);
35
+ log.warn('文件可能已损坏,请运行 codex-copilot reset');
36
+ closePrompt();
37
+ process.exit(1);
38
+ }
29
39
  const config = existsSync(configPath) ? readJSON(configPath) : {};
30
40
 
41
+ // 校验 tasks.json 结构
42
+ if (!tasks.tasks || !Array.isArray(tasks.tasks) || !tasks.total) {
43
+ log.error('tasks.json 格式无效: 缺少 tasks 数组或 total 字段');
44
+ log.warn('请重新运行 codex-copilot init 并让 CodeX 重新生成 tasks.json');
45
+ closePrompt();
46
+ process.exit(1);
47
+ }
48
+
31
49
  const baseBranch = config.base_branch || 'main';
32
50
  const maxReviewRounds = config.max_review_rounds || 2;
33
51
  const pollInterval = config.review_poll_interval || 60;
@@ -78,7 +96,7 @@ export async function run(projectDir) {
78
96
  });
79
97
 
80
98
  // ===== 阶段 4: 合并 =====
81
- await mergePhase(projectDir, task, prInfo);
99
+ await mergePhase(projectDir, task, prInfo, baseBranch);
82
100
 
83
101
  // 更新任务状态
84
102
  task.status = 'completed';
@@ -113,22 +131,14 @@ async function developPhase(projectDir, task, baseBranch) {
113
131
  const devPrompt = buildDevPrompt(task);
114
132
 
115
133
  // 检查是否有 CodeX CLI
116
- let codexAvailable = false;
117
- try {
118
- const { execSync } = await import('child_process');
119
- execSync('which codex', { stdio: 'pipe' });
120
- codexAvailable = true;
121
- } catch {}
134
+ const codexAvailable = isCodexAvailable();
122
135
 
123
136
  if (codexAvailable) {
124
137
  log.info('检测到 CodeX CLI,自动执行...');
125
138
  const autoRun = await confirm('自动调用 CodeX 开发?');
126
139
  if (autoRun) {
127
140
  try {
128
- const { execSync } = await import('child_process');
129
- // 将 prompt 写入临时文件
130
141
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
131
- const { writeFileSync } = await import('fs');
132
142
  writeFileSync(promptPath, devPrompt);
133
143
  execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
134
144
  cwd: projectDir,
@@ -154,17 +164,12 @@ async function developPhase(projectDir, task, baseBranch) {
154
164
 
155
165
  // 同时将 Prompt 保存到文件,方便复制
156
166
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
157
- const { writeFileSync: ws } = await import('fs');
158
- ws(promptPath, devPrompt);
167
+ writeFileSync(promptPath, devPrompt);
159
168
  log.dim(`Prompt 已保存到 .codex-copilot/_current_prompt.md (可直接复制文件内容)`);
160
169
  log.blank();
161
170
 
162
- // 尝试复制到剪贴板
163
- try {
164
- const { execSync } = await import('child_process');
165
- execSync(`echo '${devPrompt.replace(/'/g, "\\'")}' | pbcopy`, { stdio: 'pipe' });
166
- log.info('📋 已复制到剪贴板!直接到 CodeX 中粘贴即可');
167
- } catch {}
171
+ // 尝试复制到剪贴板(通过 stdin 传入,避免 shell 注入)
172
+ copyToClipboard(devPrompt);
168
173
 
169
174
  await ask('CodeX 开发完成后按 Enter 继续...');
170
175
  }
@@ -201,14 +206,20 @@ async function prPhase(projectDir, task, baseBranch) {
201
206
  // PR 可能已存在
202
207
  log.warn(`创建 PR 异常: ${err.message}`);
203
208
  const prNumber = await ask('请输入已存在的 PR 编号:');
204
- return { number: parseInt(prNumber), url: '' };
209
+ const parsed = parseInt(prNumber, 10);
210
+ if (isNaN(parsed) || parsed <= 0) {
211
+ log.error('无效的 PR 编号');
212
+ throw new Error('无效的 PR 编号');
213
+ }
214
+ return { number: parsed, url: '' };
205
215
  }
206
216
  }
207
217
 
208
218
  // ──────────────────────────────────────────────
209
219
  // 阶段 3: Review 循环
210
220
  // ──────────────────────────────────────────────
211
- async function reviewLoop(projectDir, task, prInfo, { maxRounds, pollInterval, waitTimeout }) {
221
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }) {
222
+ let maxRounds = _maxRounds;
212
223
  log.step('阶段 3/4: 等待 Review');
213
224
 
214
225
  for (let round = 1; round <= maxRounds; round++) {
@@ -287,7 +298,7 @@ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
287
298
  const hasNewReview = currentReviews.length > startReviewCount;
288
299
  const hasBotComment = currentComments.some(c =>
289
300
  (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) &&
290
- new Date(c.created_at) > new Date(Date.now() - elapsed * 1000)
301
+ new Date(c.created_at).getTime() > (Date.now() - elapsed * 1000)
291
302
  );
292
303
 
293
304
  if (hasNewReview || hasBotComment) {
@@ -320,22 +331,15 @@ ${feedback}
320
331
 
321
332
  // 保存到文件并提示用户
322
333
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
323
- const { writeFileSync } = await import('fs');
324
334
  writeFileSync(promptPath, fixPrompt);
325
335
 
326
336
  // 尝试 CodeX CLI
327
- let codexAvailable = false;
328
- try {
329
- const { execSync } = await import('child_process');
330
- execSync('which codex', { stdio: 'pipe' });
331
- codexAvailable = true;
332
- } catch {}
337
+ const codexAvailable = isCodexAvailable();
333
338
 
334
339
  if (codexAvailable) {
335
340
  const autoFix = await confirm('自动调用 CodeX 修复?');
336
341
  if (autoFix) {
337
342
  try {
338
- const { execSync } = await import('child_process');
339
343
  execSync(`codex -q --file .codex-copilot/_current_prompt.md`, {
340
344
  cwd: projectDir,
341
345
  stdio: 'inherit',
@@ -353,11 +357,7 @@ ${feedback}
353
357
  log.dim(`Review 修复 Prompt 已保存到 .codex-copilot/_current_prompt.md`);
354
358
  log.dim('请将文件内容粘贴到 CodeX 执行');
355
359
 
356
- try {
357
- const { execSync } = await import('child_process');
358
- execSync(`cat .codex-copilot/_current_prompt.md | pbcopy`, { cwd: projectDir, stdio: 'pipe' });
359
- log.info('📋 已复制到剪贴板');
360
- } catch {}
360
+ copyToClipboard(fixPrompt);
361
361
 
362
362
  await ask('CodeX 修复完成后按 Enter 继续...');
363
363
  }
@@ -365,7 +365,7 @@ ${feedback}
365
365
  // ──────────────────────────────────────────────
366
366
  // 阶段 4: 合并
367
367
  // ──────────────────────────────────────────────
368
- async function mergePhase(projectDir, task, prInfo) {
368
+ async function mergePhase(projectDir, task, prInfo, baseBranch) {
369
369
  log.step('阶段 4/4: 合并 PR');
370
370
 
371
371
  const doMerge = await confirm(`合并 PR #${prInfo.number}?`);
@@ -384,9 +384,7 @@ async function mergePhase(projectDir, task, prInfo) {
384
384
  }
385
385
 
386
386
  // 切回主分支
387
- const configPath = resolve(projectDir, '.codex-copilot/config.json');
388
- const config = JSON.parse(readFileSync(configPath, 'utf-8'));
389
- git.checkoutMain(projectDir, config.base_branch || 'main');
387
+ git.checkoutMain(projectDir, baseBranch);
390
388
  }
391
389
 
392
390
  function buildDevPrompt(task) {
@@ -411,3 +409,20 @@ ${task.acceptance.map(a => `- ${a}`).join('\n')}
411
409
  function sleep(ms) {
412
410
  return new Promise(resolve => setTimeout(resolve, ms));
413
411
  }
412
+
413
+ function isCodexAvailable() {
414
+ try {
415
+ const cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
416
+ execSync(cmd, { stdio: 'pipe' });
417
+ return true;
418
+ } catch {
419
+ return false;
420
+ }
421
+ }
422
+
423
+ function copyToClipboard(text) {
424
+ try {
425
+ execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
426
+ log.info('📋 已复制到剪贴板');
427
+ } catch {}
428
+ }
@@ -10,8 +10,15 @@ export async function status(projectDir) {
10
10
  const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
11
11
  const statePath = resolve(projectDir, '.codex-copilot/state.json');
12
12
 
13
- const tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
14
- const state = JSON.parse(readFileSync(statePath, 'utf-8'));
13
+ let tasks, state;
14
+ try {
15
+ tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
16
+ state = JSON.parse(readFileSync(statePath, 'utf-8'));
17
+ } catch (err) {
18
+ log.error(`读取文件失败: ${err.message}`);
19
+ log.warn('文件可能已损坏,请运行 codex-copilot reset');
20
+ return;
21
+ }
15
22
 
16
23
  log.title(`📊 项目: ${tasks.project}`);
17
24
  log.blank();
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { readdirSync, readFileSync, statSync } from 'fs';
7
- import { resolve, basename, extname } from 'path';
7
+ import { resolve, extname, relative } from 'path';
8
8
  import { log } from './logger.js';
9
9
 
10
10
  // PRD 文件名匹配模式(优先级由高到低)
@@ -89,7 +89,7 @@ function scanDir(dir, projectDir, candidates, depth) {
89
89
  if (score > 0) {
90
90
  candidates.push({
91
91
  path: fullPath,
92
- relativePath: fullPath.replace(projectDir + '/', ''),
92
+ relativePath: relative(projectDir, fullPath),
93
93
  name: entry.name,
94
94
  score,
95
95
  });
package/src/utils/git.js CHANGED
@@ -17,6 +17,15 @@ function execSafe(cmd, cwd) {
17
17
  }
18
18
  }
19
19
 
20
+ // 校验分支名,防止 shell 注入
21
+ function validateBranch(name) {
22
+ if (!name || typeof name !== 'string') throw new Error('分支名不能为空');
23
+ if (/[;&|`$(){}\[\]!\\<>"'\s]/.test(name)) {
24
+ throw new Error(`分支名包含不安全字符: ${name}`);
25
+ }
26
+ return name;
27
+ }
28
+
20
29
  /**
21
30
  * 检查 git 仓库是否干净
22
31
  */
@@ -47,6 +56,8 @@ export function getRepoInfo(cwd) {
47
56
  * 切换到目标分支(如不存在则创建)
48
57
  */
49
58
  export function checkoutBranch(cwd, branch, baseBranch = 'main') {
59
+ validateBranch(branch);
60
+ validateBranch(baseBranch);
50
61
  const current = currentBranch(cwd);
51
62
  if (current === branch) return;
52
63
 
@@ -71,14 +82,19 @@ export function commitAll(cwd, message) {
71
82
  log.dim('没有变更需要提交');
72
83
  return false;
73
84
  }
74
- exec(`git commit -m "${message}"`, cwd);
85
+ exec(`git commit -m ${shellEscape(message)}`, cwd);
75
86
  return true;
76
87
  }
77
88
 
89
+ function shellEscape(str) {
90
+ return `'${str.replace(/'/g, "'\\''")}'`
91
+ }
92
+
78
93
  /**
79
94
  * 推送分支
80
95
  */
81
96
  export function pushBranch(cwd, branch) {
97
+ validateBranch(branch);
82
98
  exec(`git push origin ${branch} --force-with-lease`, cwd);
83
99
  }
84
100
 
@@ -86,6 +102,7 @@ export function pushBranch(cwd, branch) {
86
102
  * 切回主分支
87
103
  */
88
104
  export function checkoutMain(cwd, baseBranch = 'main') {
105
+ validateBranch(baseBranch);
89
106
  execSafe(`git checkout ${baseBranch}`, cwd);
90
107
  }
91
108
 
@@ -11,7 +11,16 @@ function gh(cmd, cwd) {
11
11
 
12
12
  function ghJSON(cmd, cwd) {
13
13
  const output = gh(cmd, cwd);
14
- return output ? JSON.parse(output) : null;
14
+ if (!output) return null;
15
+ try {
16
+ return JSON.parse(output);
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ function shellEscape(str) {
23
+ return `'${str.replace(/'/g, "'\\''")}'`
15
24
  }
16
25
 
17
26
  /**
@@ -31,7 +40,7 @@ export function checkGhAuth() {
31
40
  */
32
41
  export function createPR(cwd, { title, body, base = 'main', head }) {
33
42
  const output = gh(
34
- `pr create --title "${title}" --body "${body}" --base ${base} --head ${head}`,
43
+ `pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${base} --head ${head}`,
35
44
  cwd
36
45
  );
37
46
  // 从输出中提取 PR URL 和编号
@@ -52,7 +61,8 @@ export function createPR(cwd, { title, body, base = 'main', head }) {
52
61
  */
53
62
  export function getReviews(cwd, prNumber) {
54
63
  try {
55
- return ghJSON(`api repos/{owner}/{repo}/pulls/${prNumber}/reviews`, cwd) || [];
64
+ const num = validatePRNumber(prNumber);
65
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/reviews`, cwd) || [];
56
66
  } catch {
57
67
  return [];
58
68
  }
@@ -63,7 +73,8 @@ export function getReviews(cwd, prNumber) {
63
73
  */
64
74
  export function getReviewComments(cwd, prNumber) {
65
75
  try {
66
- return ghJSON(`api repos/{owner}/{repo}/pulls/${prNumber}/comments`, cwd) || [];
76
+ const num = validatePRNumber(prNumber);
77
+ return ghJSON(`api repos/{owner}/{repo}/pulls/${num}/comments`, cwd) || [];
67
78
  } catch {
68
79
  return [];
69
80
  }
@@ -74,7 +85,8 @@ export function getReviewComments(cwd, prNumber) {
74
85
  */
75
86
  export function getIssueComments(cwd, prNumber) {
76
87
  try {
77
- return ghJSON(`api repos/{owner}/{repo}/issues/${prNumber}/comments`, cwd) || [];
88
+ const num = validatePRNumber(prNumber);
89
+ return ghJSON(`api repos/{owner}/{repo}/issues/${num}/comments`, cwd) || [];
78
90
  } catch {
79
91
  return [];
80
92
  }
@@ -99,7 +111,18 @@ export function getLatestReviewState(cwd, prNumber) {
99
111
  * 合并 PR
100
112
  */
101
113
  export function mergePR(cwd, prNumber, method = 'squash') {
102
- gh(`pr merge ${prNumber} --${method} --delete-branch`, cwd);
114
+ const num = validatePRNumber(prNumber);
115
+ const validMethods = ['squash', 'merge', 'rebase'];
116
+ if (!validMethods.includes(method)) {
117
+ throw new Error(`无效的合并方式: ${method}`);
118
+ }
119
+ gh(`pr merge ${num} --${method} --delete-branch`, cwd);
120
+ }
121
+
122
+ function validatePRNumber(prNumber) {
123
+ const num = parseInt(prNumber, 10);
124
+ if (isNaN(num) || num <= 0) throw new Error(`无效的 PR 编号: ${prNumber}`);
125
+ return num;
103
126
  }
104
127
 
105
128
  /**
@@ -28,6 +28,11 @@ export const log = {
28
28
  */
29
29
  export function progressBar(current, total, label = '') {
30
30
  const width = 30;
31
+ if (total <= 0) {
32
+ const bar = '░'.repeat(width);
33
+ console.log(` ${COLORS.cyan}[${bar}]${COLORS.reset} 0% ${label}`);
34
+ return;
35
+ }
31
36
  const filled = Math.round((current / total) * width);
32
37
  const bar = '█'.repeat(filled) + '░'.repeat(width - filled);
33
38
  const pct = Math.round((current / total) * 100);