commandmate 0.3.1 → 0.3.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.
Files changed (110) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +17 -17
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/config.json +3 -3
  7. package/.next/cache/webpack/client-production/0.pack +0 -0
  8. package/.next/cache/webpack/client-production/1.pack +0 -0
  9. package/.next/cache/webpack/client-production/2.pack +0 -0
  10. package/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  12. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  13. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  14. package/.next/cache/webpack/server-production/0.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack +0 -0
  16. package/.next/next-server.js.nft.json +1 -1
  17. package/.next/prerender-manifest.json +1 -1
  18. package/.next/required-server-files.json +1 -1
  19. package/.next/routes-manifest.json +1 -1
  20. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/api/app/update-check/route.js +1 -1
  23. package/.next/server/app/api/external-apps/[id]/health/route.js +1 -1
  24. package/.next/server/app/api/external-apps/[id]/route.js +1 -1
  25. package/.next/server/app/api/external-apps/route.js +1 -1
  26. package/.next/server/app/api/hooks/claude-done/route.js +1 -1
  27. package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
  28. package/.next/server/app/api/repositories/clone/route.js +1 -1
  29. package/.next/server/app/api/repositories/excluded/route.js +7 -7
  30. package/.next/server/app/api/repositories/restore/route.js +3 -3
  31. package/.next/server/app/api/repositories/route.js +13 -11
  32. package/.next/server/app/api/repositories/scan/route.js +1 -1
  33. package/.next/server/app/api/repositories/sync/route.js +3 -3
  34. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  35. package/.next/server/app/api/worktrees/[id]/cli-tool/route.js +1 -1
  36. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  37. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -0
  38. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -0
  39. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +9 -0
  40. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -0
  41. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  44. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  45. package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
  46. package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js +1 -1
  47. package/.next/server/app/api/worktrees/[id]/memos/route.js +1 -1
  48. package/.next/server/app/api/worktrees/[id]/messages/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  51. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -0
  53. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/worktrees/[id]/schedules/route.js +4 -0
  55. package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -0
  56. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  58. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  59. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  60. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/tree/route.js +1 -1
  62. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  63. package/.next/server/app/api/worktrees/[id]/viewed/route.js +1 -1
  64. package/.next/server/app/api/worktrees/route.js +1 -1
  65. package/.next/server/app/login/page.js.nft.json +1 -1
  66. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  67. package/.next/server/app/page.js.nft.json +1 -1
  68. package/.next/server/app/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/proxy/[...path]/route.js +1 -1
  70. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  71. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  72. package/.next/server/app/worktrees/[id]/page.js +8 -3
  73. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  74. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  76. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app-paths-manifest.json +14 -10
  78. package/.next/server/chunks/2314.js +1 -0
  79. package/.next/server/chunks/3860.js +1 -1
  80. package/.next/server/chunks/6228.js +1 -0
  81. package/.next/server/chunks/7425.js +85 -30
  82. package/.next/server/chunks/7536.js +1 -1
  83. package/.next/server/functions-config-manifest.json +1 -1
  84. package/.next/server/middleware-manifest.json +5 -5
  85. package/.next/server/pages/500.html +1 -1
  86. package/.next/server/server-reference-manifest.json +1 -1
  87. package/.next/static/chunks/app/worktrees/[id]/page-0c889ab3f30d5af7.js +1 -0
  88. package/.next/static/css/bd6065b03ddb3efd.css +3 -0
  89. package/.next/trace +5 -5
  90. package/.next/types/app/api/worktrees/[id]/execution-logs/[logId]/route.ts +343 -0
  91. package/.next/types/app/api/worktrees/[id]/execution-logs/route.ts +343 -0
  92. package/.next/types/app/api/worktrees/[id]/schedules/[scheduleId]/route.ts +343 -0
  93. package/.next/types/app/api/worktrees/[id]/schedules/route.ts +343 -0
  94. package/dist/cli/utils/docs-reader.d.ts.map +1 -1
  95. package/dist/cli/utils/docs-reader.js +1 -0
  96. package/dist/server/server.js +5 -0
  97. package/dist/server/src/config/cmate-constants.js +79 -0
  98. package/dist/server/src/config/schedule-config.js +54 -0
  99. package/dist/server/src/lib/claude-executor.js +147 -0
  100. package/dist/server/src/lib/cmate-parser.js +240 -0
  101. package/dist/server/src/lib/db-instance.js +3 -0
  102. package/dist/server/src/lib/db-migrations.js +96 -2
  103. package/dist/server/src/lib/env-sanitizer.js +57 -0
  104. package/dist/server/src/lib/schedule-manager.js +397 -0
  105. package/dist/server/src/types/cmate.js +6 -0
  106. package/package.json +2 -1
  107. package/.next/static/chunks/app/worktrees/[id]/page-a556551ce5c69dec.js +0 -1
  108. package/.next/static/css/b9ea6a4fad17dc32.css +0 -3
  109. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → j8HFvzDZj7tHjAnhpXUno}/_buildManifest.js +0 -0
  110. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → j8HFvzDZj7tHjAnhpXUno}/_ssgManifest.js +0 -0
@@ -39,6 +39,7 @@ const worktrees_1 = require("./src/lib/worktrees");
39
39
  const db_instance_1 = require("./src/lib/db-instance");
40
40
  const response_poller_1 = require("./src/lib/response-poller");
41
41
  const auto_yes_manager_1 = require("./src/lib/auto-yes-manager");
42
+ const schedule_manager_1 = require("./src/lib/schedule-manager");
42
43
  const db_migrations_1 = require("./src/lib/db-migrations");
43
44
  const env_1 = require("./src/lib/env");
44
45
  const db_repository_1 = require("./src/lib/db-repository");
@@ -227,6 +228,8 @@ app.prepare().then(() => {
227
228
  console.log(`> WebSocket server ready`);
228
229
  // Initialize worktrees after server starts
229
230
  await initializeWorktrees();
231
+ // [S3-010] Initialize schedule manager AFTER worktrees are ready
232
+ (0, schedule_manager_1.initScheduleManager)();
230
233
  });
231
234
  // Graceful shutdown with timeout
232
235
  let isShuttingDown = false;
@@ -241,6 +244,8 @@ app.prepare().then(() => {
241
244
  (0, response_poller_1.stopAllPolling)();
242
245
  // Issue #138: Stop all auto-yes pollers
243
246
  (0, auto_yes_manager_1.stopAllAutoYesPolling)();
247
+ // Issue #294: Stop all scheduled executions (SIGKILL fire-and-forget)
248
+ (0, schedule_manager_1.stopAllSchedules)();
244
249
  // Close WebSocket connections immediately (don't wait)
245
250
  (0, ws_server_1.closeWebSocket)();
246
251
  // Force exit after 3 seconds if graceful shutdown fails
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md Shared Constants
4
+ * Issue #294: Constants shared between server-side parser and client-side validator
5
+ *
6
+ * This module has NO Node.js dependencies (no 'fs'), so it can be safely
7
+ * imported from both server and client code.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.MAX_SCHEDULE_ENTRIES = exports.MAX_CRON_EXPRESSION_LENGTH = exports.NAME_PATTERN = exports.CONTROL_CHAR_PATTERN = exports.CMATE_FILENAME = void 0;
11
+ exports.sanitizeContent = sanitizeContent;
12
+ exports.isValidCronExpression = isValidCronExpression;
13
+ // =============================================================================
14
+ // File
15
+ // =============================================================================
16
+ /** CMATE.md filename */
17
+ exports.CMATE_FILENAME = 'CMATE.md';
18
+ // =============================================================================
19
+ // Sanitization
20
+ // =============================================================================
21
+ /**
22
+ * Unicode control character regex for sanitization.
23
+ * Matches: C0 control chars (except \t \n \r), C1 control chars,
24
+ * zero-width characters, directional control characters.
25
+ *
26
+ * NOTE: No /g flag on the export — callers must use String.replace(pattern, '')
27
+ * with /g or String.replaceAll() to avoid lastIndex state issues.
28
+ *
29
+ * [S4-002] Strips potentially dangerous Unicode control characters
30
+ */
31
+ exports.CONTROL_CHAR_PATTERN =
32
+ // eslint-disable-next-line no-control-regex
33
+ /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F\u200B-\u200F\u2028-\u202F\uFEFF]/;
34
+ /**
35
+ * Remove Unicode control characters from a string.
36
+ * Preserves tabs (\t), newlines (\n), and carriage returns (\r).
37
+ *
38
+ * @param content - Raw string to sanitize
39
+ * @returns Sanitized string with control characters removed
40
+ */
41
+ function sanitizeContent(content) {
42
+ // Use RegExp constructor with /g to avoid lastIndex state on the shared pattern
43
+ return content.replace(new RegExp(exports.CONTROL_CHAR_PATTERN.source, 'g'), '');
44
+ }
45
+ // =============================================================================
46
+ // Name Validation
47
+ // =============================================================================
48
+ /**
49
+ * Name validation pattern.
50
+ * Allows: ASCII word chars, Japanese chars (CJK, Hiragana, Katakana, Symbols),
51
+ * spaces, and hyphens. Length: 1-100 characters.
52
+ *
53
+ * [S4-011] Prevents injection through name field
54
+ */
55
+ exports.NAME_PATTERN = /^[\w\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\s-]{1,100}$/;
56
+ // =============================================================================
57
+ // Limits
58
+ // =============================================================================
59
+ /** Maximum cron expression length */
60
+ exports.MAX_CRON_EXPRESSION_LENGTH = 100;
61
+ /** Maximum number of schedule entries per worktree */
62
+ exports.MAX_SCHEDULE_ENTRIES = 100;
63
+ // =============================================================================
64
+ // Cron Validation
65
+ // =============================================================================
66
+ /**
67
+ * Validate a cron expression.
68
+ * Checks length and basic format (5-6 fields separated by spaces).
69
+ *
70
+ * @param expression - Cron expression to validate
71
+ * @returns true if the expression appears valid
72
+ */
73
+ function isValidCronExpression(expression) {
74
+ if (expression.length > exports.MAX_CRON_EXPRESSION_LENGTH) {
75
+ return false;
76
+ }
77
+ const parts = expression.trim().split(/\s+/);
78
+ return parts.length >= 5 && parts.length <= 6;
79
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * Schedule Execution Configuration Constants
4
+ * Issue #294: Centralized constants for schedule-related API routes
5
+ *
6
+ * Eliminates duplication of validation constants and UUID validation
7
+ * across schedules/route.ts, schedules/[scheduleId]/route.ts,
8
+ * and execution-logs/[logId]/route.ts.
9
+ *
10
+ * [S4-014] UUID v4 format validation
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.UUID_V4_PATTERN = exports.DEFAULT_PERMISSIONS = exports.CODEX_SANDBOXES = exports.CLAUDE_PERMISSIONS = exports.MAX_SCHEDULE_CRON_LENGTH = exports.MAX_SCHEDULE_MESSAGE_LENGTH = exports.MAX_SCHEDULE_NAME_LENGTH = void 0;
14
+ exports.isValidUuidV4 = isValidUuidV4;
15
+ // =============================================================================
16
+ // Validation Constants
17
+ // =============================================================================
18
+ /** Maximum schedule name length */
19
+ exports.MAX_SCHEDULE_NAME_LENGTH = 100;
20
+ /** Maximum message length for schedule execution */
21
+ exports.MAX_SCHEDULE_MESSAGE_LENGTH = 10000;
22
+ /** Maximum cron expression length */
23
+ exports.MAX_SCHEDULE_CRON_LENGTH = 100;
24
+ // =============================================================================
25
+ // Permission Constants
26
+ // =============================================================================
27
+ /** Allowed permission values for claude CLI (--permission-mode) */
28
+ exports.CLAUDE_PERMISSIONS = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
29
+ /** Allowed sandbox values for codex CLI (--sandbox) */
30
+ exports.CODEX_SANDBOXES = ['read-only', 'workspace-write', 'danger-full-access'];
31
+ /** Default permission per CLI tool */
32
+ exports.DEFAULT_PERMISSIONS = {
33
+ claude: 'acceptEdits',
34
+ codex: 'workspace-write',
35
+ };
36
+ // =============================================================================
37
+ // UUID Validation
38
+ // =============================================================================
39
+ /**
40
+ * UUID v4 validation pattern.
41
+ * Matches standard UUID v4 format: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx
42
+ *
43
+ * [S4-014] Used to validate schedule IDs and execution log IDs
44
+ */
45
+ exports.UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
46
+ /**
47
+ * Validate that a string is a valid UUID v4 format.
48
+ *
49
+ * @param id - String to validate
50
+ * @returns true if the string matches UUID v4 format
51
+ */
52
+ function isValidUuidV4(id) {
53
+ return exports.UUID_V4_PATTERN.test(id);
54
+ }
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * Claude CLI Executor
4
+ * Issue #294: Executes claude -p commands for scheduled executions
5
+ *
6
+ * Security:
7
+ * - Uses execFile (not exec) to prevent shell injection
8
+ * - Sanitizes environment variables via env-sanitizer.ts
9
+ * - Limits output size to prevent memory exhaustion
10
+ * - Enforces execution timeout
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ALLOWED_CLI_TOOLS = exports.MAX_MESSAGE_LENGTH = exports.EXECUTION_TIMEOUT_MS = exports.MAX_STORED_OUTPUT_SIZE = exports.MAX_OUTPUT_SIZE = void 0;
14
+ exports.truncateOutput = truncateOutput;
15
+ exports.buildCliArgs = buildCliArgs;
16
+ exports.executeClaudeCommand = executeClaudeCommand;
17
+ exports.getActiveProcesses = getActiveProcesses;
18
+ const child_process_1 = require("child_process");
19
+ const env_sanitizer_1 = require("./env-sanitizer");
20
+ const cli_patterns_1 = require("./cli-patterns");
21
+ // =============================================================================
22
+ // Constants
23
+ // =============================================================================
24
+ /** Maximum output buffer size for execFile (1MB) */
25
+ exports.MAX_OUTPUT_SIZE = 1 * 1024 * 1024;
26
+ /** Maximum output size stored in DB (100KB) */
27
+ exports.MAX_STORED_OUTPUT_SIZE = 100 * 1024;
28
+ /** Execution timeout in milliseconds (5 minutes) */
29
+ exports.EXECUTION_TIMEOUT_MS = 5 * 60 * 1000;
30
+ /** Maximum message length sent to claude -p */
31
+ exports.MAX_MESSAGE_LENGTH = 10000;
32
+ /** Allowed CLI tool identifiers for scheduled execution */
33
+ exports.ALLOWED_CLI_TOOLS = new Set(['claude', 'codex']);
34
+ // =============================================================================
35
+ // Executor
36
+ // =============================================================================
37
+ /**
38
+ * Truncate output to MAX_STORED_OUTPUT_SIZE bytes.
39
+ * Appends a truncation notice if truncated.
40
+ *
41
+ * @param output - Raw output string
42
+ * @returns Truncated output string
43
+ */
44
+ function truncateOutput(output) {
45
+ if (Buffer.byteLength(output, 'utf-8') <= exports.MAX_STORED_OUTPUT_SIZE) {
46
+ return output;
47
+ }
48
+ // Truncate to MAX_STORED_OUTPUT_SIZE bytes
49
+ const buffer = Buffer.from(output, 'utf-8');
50
+ const truncated = buffer.subarray(0, exports.MAX_STORED_OUTPUT_SIZE).toString('utf-8');
51
+ return truncated + '\n\n--- Output truncated (exceeded 100KB limit) ---';
52
+ }
53
+ /**
54
+ * Build CLI arguments for non-interactive execution based on CLI tool type.
55
+ *
56
+ * - claude: -p <message> --output-format text --permission-mode <permission>
57
+ * - codex: exec <message> --sandbox <permission>
58
+ * - others: -p <message> (fallback)
59
+ *
60
+ * @param message - Prompt message
61
+ * @param cliToolId - CLI tool identifier
62
+ * @param permission - Permission mode (claude: --permission-mode, codex: --sandbox)
63
+ * @returns Array of CLI arguments
64
+ */
65
+ function buildCliArgs(message, cliToolId, permission) {
66
+ switch (cliToolId) {
67
+ case 'codex':
68
+ return ['exec', message, '--sandbox', permission ?? 'workspace-write'];
69
+ case 'claude':
70
+ default:
71
+ return ['-p', message, '--output-format', 'text', '--permission-mode', permission ?? 'acceptEdits'];
72
+ }
73
+ }
74
+ /**
75
+ * Execute a CLI command in a worktree directory.
76
+ *
77
+ * @param message - Prompt message to send
78
+ * @param cwd - Working directory (worktree path from DB)
79
+ * @param cliToolId - CLI tool to use (default: 'claude')
80
+ * @param permission - Permission mode (claude: --permission-mode, codex: --sandbox)
81
+ * @returns Execution result with output and status
82
+ */
83
+ async function executeClaudeCommand(message, cwd, cliToolId = 'claude', permission) {
84
+ // Validate cliToolId against whitelist [SEC-001]
85
+ if (!exports.ALLOWED_CLI_TOOLS.has(cliToolId)) {
86
+ return {
87
+ output: '',
88
+ exitCode: null,
89
+ status: 'failed',
90
+ error: `Invalid CLI tool: ${cliToolId}`,
91
+ };
92
+ }
93
+ // Validate message length
94
+ const truncatedMessage = message.length > exports.MAX_MESSAGE_LENGTH
95
+ ? message.substring(0, exports.MAX_MESSAGE_LENGTH)
96
+ : message;
97
+ const args = buildCliArgs(truncatedMessage, cliToolId, permission);
98
+ return new Promise((resolve) => {
99
+ const child = (0, child_process_1.execFile)(cliToolId, args, {
100
+ cwd,
101
+ env: (0, env_sanitizer_1.sanitizeEnvForChildProcess)(),
102
+ maxBuffer: exports.MAX_OUTPUT_SIZE,
103
+ timeout: exports.EXECUTION_TIMEOUT_MS,
104
+ }, (error, stdout, stderr) => {
105
+ if (error) {
106
+ const isTimeout = error.killed || error.code === 'ETIMEDOUT';
107
+ const rawOutput = (0, cli_patterns_1.stripAnsi)(stdout || stderr || error.message);
108
+ const output = truncateOutput(rawOutput);
109
+ resolve({
110
+ output,
111
+ exitCode: error.code ? parseInt(String(error.code), 10) || null : null,
112
+ status: isTimeout ? 'timeout' : 'failed',
113
+ error: error.message,
114
+ });
115
+ return;
116
+ }
117
+ const rawOutput = (0, cli_patterns_1.stripAnsi)(stdout || '');
118
+ const output = truncateOutput(rawOutput);
119
+ resolve({
120
+ output,
121
+ exitCode: 0,
122
+ status: 'completed',
123
+ });
124
+ });
125
+ // Close stdin immediately to prevent hanging on yes/no prompts
126
+ child.stdin?.end();
127
+ // Return the child process PID for tracking
128
+ if (child.pid) {
129
+ // Store PID in global active processes for cleanup on shutdown
130
+ const activeProcesses = getActiveProcesses();
131
+ activeProcesses.set(child.pid, child);
132
+ child.on('exit', () => {
133
+ activeProcesses.delete(child.pid);
134
+ });
135
+ }
136
+ });
137
+ }
138
+ /**
139
+ * Get the global active processes map.
140
+ * Uses globalThis for hot reload persistence.
141
+ */
142
+ function getActiveProcesses() {
143
+ if (!globalThis.__scheduleActiveProcesses) {
144
+ globalThis.__scheduleActiveProcesses = new Map();
145
+ }
146
+ return globalThis.__scheduleActiveProcesses;
147
+ }
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md Parser
4
+ * Issue #294: Schedule execution feature
5
+ *
6
+ * Parses CMATE.md files (Markdown table format) from worktree root directories.
7
+ * Provides a generic table parser and a specialized schedule section parser.
8
+ *
9
+ * Security:
10
+ * - Path traversal prevention (realpath + worktree directory validation)
11
+ * - Unicode control character sanitization
12
+ * - Name validation with strict pattern matching
13
+ * - Cron expression validation
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.MIN_CRON_INTERVAL = exports.CONTROL_CHAR_REGEX = exports.isValidCronExpression = exports.MAX_SCHEDULE_ENTRIES = exports.MAX_CRON_EXPRESSION_LENGTH = exports.NAME_PATTERN = exports.CMATE_FILENAME = void 0;
20
+ exports.sanitizeMessageContent = sanitizeMessageContent;
21
+ exports.validateCmatePath = validateCmatePath;
22
+ exports.parseCmateFile = parseCmateFile;
23
+ exports.parseSchedulesSection = parseSchedulesSection;
24
+ exports.readCmateFile = readCmateFile;
25
+ const fs_1 = require("fs");
26
+ const path_1 = __importDefault(require("path"));
27
+ const schedule_config_1 = require("../config/schedule-config");
28
+ const cmate_constants_1 = require("../config/cmate-constants");
29
+ Object.defineProperty(exports, "CMATE_FILENAME", { enumerable: true, get: function () { return cmate_constants_1.CMATE_FILENAME; } });
30
+ Object.defineProperty(exports, "NAME_PATTERN", { enumerable: true, get: function () { return cmate_constants_1.NAME_PATTERN; } });
31
+ Object.defineProperty(exports, "MAX_CRON_EXPRESSION_LENGTH", { enumerable: true, get: function () { return cmate_constants_1.MAX_CRON_EXPRESSION_LENGTH; } });
32
+ Object.defineProperty(exports, "MAX_SCHEDULE_ENTRIES", { enumerable: true, get: function () { return cmate_constants_1.MAX_SCHEDULE_ENTRIES; } });
33
+ Object.defineProperty(exports, "isValidCronExpression", { enumerable: true, get: function () { return cmate_constants_1.isValidCronExpression; } });
34
+ /**
35
+ * @deprecated Use CONTROL_CHAR_PATTERN from '../config/cmate-constants' instead.
36
+ * Kept for backward compatibility with existing tests.
37
+ */
38
+ exports.CONTROL_CHAR_REGEX = new RegExp(cmate_constants_1.CONTROL_CHAR_PATTERN.source, 'g');
39
+ /** Minimum cron interval pattern (every minute) */
40
+ exports.MIN_CRON_INTERVAL = '* * * * *';
41
+ // =============================================================================
42
+ // Sanitization
43
+ // =============================================================================
44
+ /**
45
+ * Remove Unicode control characters from a string.
46
+ * Preserves tabs (\t), newlines (\n), and carriage returns (\r).
47
+ *
48
+ * @param content - Raw string to sanitize
49
+ * @returns Sanitized string with control characters removed
50
+ */
51
+ function sanitizeMessageContent(content) {
52
+ return (0, cmate_constants_1.sanitizeContent)(content);
53
+ }
54
+ // =============================================================================
55
+ // Path Validation
56
+ // =============================================================================
57
+ /**
58
+ * Validate that a CMATE.md file path is within the expected worktree directory.
59
+ * Prevents path traversal attacks by resolving symlinks and verifying containment.
60
+ *
61
+ * @param filePath - Path to CMATE.md file
62
+ * @param worktreeDir - Expected worktree directory
63
+ * @returns true if path is valid and within worktree directory
64
+ * @throws Error if path traversal is detected
65
+ */
66
+ function validateCmatePath(filePath, worktreeDir) {
67
+ const realFilePath = (0, fs_1.realpathSync)(filePath);
68
+ const realWorktreeDir = (0, fs_1.realpathSync)(worktreeDir);
69
+ // Ensure the file is within the worktree directory
70
+ if (!realFilePath.startsWith(realWorktreeDir + path_1.default.sep) &&
71
+ realFilePath !== path_1.default.join(realWorktreeDir, cmate_constants_1.CMATE_FILENAME)) {
72
+ throw new Error(`Path traversal detected: ${filePath} is not within ${worktreeDir}`);
73
+ }
74
+ return true;
75
+ }
76
+ // =============================================================================
77
+ // Generic Markdown Table Parser
78
+ // =============================================================================
79
+ /**
80
+ * Parse a CMATE.md file into a generic structure.
81
+ * Returns a Map where keys are section names (from ## headers)
82
+ * and values are arrays of row data (each row is an array of cell values).
83
+ *
84
+ * [S1-010] Generic design: returns Map<string, string[][]>
85
+ *
86
+ * @param content - Raw CMATE.md file content
87
+ * @returns Map of section name to table rows
88
+ */
89
+ function parseCmateFile(content) {
90
+ const result = new Map();
91
+ const lines = content.split('\n');
92
+ let currentSection = null;
93
+ let headerParsed = false;
94
+ let separatorParsed = false;
95
+ for (const line of lines) {
96
+ const trimmed = line.trim();
97
+ // Detect section headers (## SectionName)
98
+ const headerMatch = trimmed.match(/^##\s+(.+)$/);
99
+ if (headerMatch) {
100
+ currentSection = headerMatch[1].trim();
101
+ headerParsed = false;
102
+ separatorParsed = false;
103
+ if (!result.has(currentSection)) {
104
+ result.set(currentSection, []);
105
+ }
106
+ continue;
107
+ }
108
+ // Skip empty lines
109
+ if (!trimmed) {
110
+ continue;
111
+ }
112
+ // Skip non-table lines
113
+ if (!trimmed.startsWith('|')) {
114
+ continue;
115
+ }
116
+ if (!currentSection) {
117
+ continue;
118
+ }
119
+ // Parse table row
120
+ if (!headerParsed) {
121
+ // First row is header - skip it
122
+ headerParsed = true;
123
+ continue;
124
+ }
125
+ if (!separatorParsed) {
126
+ // Second row is separator (|---|---|) - skip it
127
+ if (trimmed.match(/^\|[\s-:|]+\|$/)) {
128
+ separatorParsed = true;
129
+ continue;
130
+ }
131
+ // If it's not a separator, treat it as data
132
+ separatorParsed = true;
133
+ }
134
+ // Parse data row
135
+ const cells = trimmed
136
+ .split('|')
137
+ .slice(1, -1) // Remove leading and trailing empty strings from split
138
+ .map((cell) => cell.trim());
139
+ if (cells.length > 0) {
140
+ result.get(currentSection).push(cells);
141
+ }
142
+ }
143
+ return result;
144
+ }
145
+ // =============================================================================
146
+ // Schedule Section Parser
147
+ // =============================================================================
148
+ /**
149
+ * Parse the Schedules section of a CMATE.md file into typed ScheduleEntry objects.
150
+ *
151
+ * Expected table format:
152
+ * | Name | Cron | Message | CLI Tool | Enabled |
153
+ * |------|------|---------|----------|---------|
154
+ * | daily-review | 0 9 * * * | Review code changes | claude | true |
155
+ *
156
+ * Entries with invalid names, cron expressions, or missing required fields
157
+ * are silently skipped with a console.warn.
158
+ *
159
+ * @param rows - Raw table rows from parseCmateFile() for the Schedules section
160
+ * @returns Array of validated ScheduleEntry objects
161
+ */
162
+ function parseSchedulesSection(rows) {
163
+ const entries = [];
164
+ for (const row of rows) {
165
+ if (entries.length >= cmate_constants_1.MAX_SCHEDULE_ENTRIES) {
166
+ console.warn(`[cmate-parser] Maximum schedule entries (${cmate_constants_1.MAX_SCHEDULE_ENTRIES}) reached, skipping remaining`);
167
+ break;
168
+ }
169
+ // Minimum required columns: Name, Cron, Message
170
+ if (row.length < 3) {
171
+ console.warn('[cmate-parser] Skipping row with insufficient columns:', row);
172
+ continue;
173
+ }
174
+ const [name, cronExpression, message, cliToolId, enabledStr, permissionStr] = row;
175
+ // Validate name
176
+ const sanitizedName = sanitizeMessageContent(name);
177
+ if (!cmate_constants_1.NAME_PATTERN.test(sanitizedName)) {
178
+ console.warn(`[cmate-parser] Skipping entry with invalid name: "${sanitizedName}"`);
179
+ continue;
180
+ }
181
+ // Validate cron expression
182
+ if (!(0, cmate_constants_1.isValidCronExpression)(cronExpression)) {
183
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with invalid cron: "${cronExpression}"`);
184
+ continue;
185
+ }
186
+ // Sanitize message
187
+ const sanitizedMessage = sanitizeMessageContent(message);
188
+ if (!sanitizedMessage) {
189
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with empty message`);
190
+ continue;
191
+ }
192
+ // Parse enabled (default: true)
193
+ const enabled = enabledStr === undefined ||
194
+ enabledStr === '' ||
195
+ enabledStr.toLowerCase() === 'true';
196
+ // Parse permission with validation
197
+ const resolvedCliToolId = cliToolId?.trim() || 'claude';
198
+ const defaultPermission = schedule_config_1.DEFAULT_PERMISSIONS[resolvedCliToolId] ?? '';
199
+ let permission = permissionStr?.trim() || defaultPermission;
200
+ // Validate permission against allowed values
201
+ const allowedValues = resolvedCliToolId === 'codex' ? schedule_config_1.CODEX_SANDBOXES : schedule_config_1.CLAUDE_PERMISSIONS;
202
+ if (permission && !allowedValues.includes(permission)) {
203
+ console.warn(`[cmate-parser] Invalid permission "${permission}" for ${resolvedCliToolId} in entry "${sanitizedName}", using default "${defaultPermission}"`);
204
+ permission = defaultPermission;
205
+ }
206
+ entries.push({
207
+ name: sanitizedName,
208
+ cronExpression: cronExpression.trim(),
209
+ message: sanitizedMessage,
210
+ cliToolId: resolvedCliToolId,
211
+ enabled,
212
+ permission,
213
+ });
214
+ }
215
+ return entries;
216
+ }
217
+ /**
218
+ * Read and parse a CMATE.md file from a worktree directory.
219
+ *
220
+ * @param worktreeDir - Path to the worktree directory
221
+ * @returns Parsed CmateConfig, or null if the file doesn't exist
222
+ * @throws Error if path traversal is detected
223
+ */
224
+ function readCmateFile(worktreeDir) {
225
+ const filePath = path_1.default.join(worktreeDir, cmate_constants_1.CMATE_FILENAME);
226
+ try {
227
+ // Validate path before reading
228
+ validateCmatePath(filePath, worktreeDir);
229
+ const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
230
+ return parseCmateFile(content);
231
+ }
232
+ catch (error) {
233
+ if (error instanceof Error &&
234
+ 'code' in error &&
235
+ error.code === 'ENOENT') {
236
+ return null;
237
+ }
238
+ throw error;
239
+ }
240
+ }
@@ -45,6 +45,9 @@ function getDbInstance() {
45
45
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
46
46
  }
47
47
  dbInstance = new better_sqlite3_1.default(dbPath);
48
+ // Issue #294: Enable foreign key enforcement BEFORE migrations
49
+ // This ensures ON DELETE CASCADE works correctly for all tables
50
+ dbInstance.pragma('foreign_keys = ON');
48
51
  (0, db_migrations_1.runMigrations)(dbInstance);
49
52
  }
50
53
  return dbInstance;
@@ -19,7 +19,7 @@ const db_1 = require("./db");
19
19
  * Current schema version
20
20
  * Increment this when adding new migrations
21
21
  */
22
- exports.CURRENT_SCHEMA_VERSION = 16;
22
+ exports.CURRENT_SCHEMA_VERSION = 17;
23
23
  /**
24
24
  * Migration registry
25
25
  * All migrations should be added to this array in order
@@ -717,6 +717,100 @@ const migrations = [
717
717
  `);
718
718
  console.log('✓ Removed issue_no column from external_apps table');
719
719
  }
720
+ },
721
+ {
722
+ version: 17,
723
+ name: 'add-scheduled-executions-and-execution-logs',
724
+ up: (db) => {
725
+ // Issue #294: Schedule execution feature
726
+ // [S3-002] Clean up orphan records BEFORE creating new tables with FK constraints
727
+ // These records may exist if worktrees/repositories were deleted while FK was disabled
728
+ db.exec(`
729
+ DELETE FROM chat_messages WHERE worktree_id NOT IN (SELECT id FROM worktrees);
730
+ `);
731
+ db.exec(`
732
+ DELETE FROM session_states WHERE worktree_id NOT IN (SELECT id FROM worktrees);
733
+ `);
734
+ db.exec(`
735
+ DELETE FROM worktree_memos WHERE worktree_id NOT IN (SELECT id FROM worktrees);
736
+ `);
737
+ db.exec(`
738
+ UPDATE clone_jobs SET repository_id = NULL
739
+ WHERE repository_id IS NOT NULL AND repository_id NOT IN (SELECT id FROM repositories);
740
+ `);
741
+ // Create scheduled_executions table
742
+ db.exec(`
743
+ CREATE TABLE scheduled_executions (
744
+ id TEXT PRIMARY KEY,
745
+ worktree_id TEXT NOT NULL,
746
+ cli_tool_id TEXT DEFAULT 'claude',
747
+ name TEXT NOT NULL,
748
+ message TEXT NOT NULL,
749
+ cron_expression TEXT,
750
+ enabled INTEGER DEFAULT 1,
751
+ last_executed_at INTEGER,
752
+ next_execute_at INTEGER,
753
+ created_at INTEGER NOT NULL,
754
+ updated_at INTEGER NOT NULL,
755
+ UNIQUE(worktree_id, name),
756
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
757
+ );
758
+ `);
759
+ // Create index on worktree_id for scheduled_executions
760
+ db.exec(`
761
+ CREATE INDEX idx_scheduled_executions_worktree
762
+ ON scheduled_executions(worktree_id);
763
+ `);
764
+ // Create index on enabled for filtering active schedules
765
+ db.exec(`
766
+ CREATE INDEX idx_scheduled_executions_enabled
767
+ ON scheduled_executions(enabled);
768
+ `);
769
+ // Create execution_logs table
770
+ db.exec(`
771
+ CREATE TABLE execution_logs (
772
+ id TEXT PRIMARY KEY,
773
+ schedule_id TEXT NOT NULL,
774
+ worktree_id TEXT NOT NULL,
775
+ message TEXT NOT NULL,
776
+ result TEXT,
777
+ exit_code INTEGER,
778
+ status TEXT DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'timeout', 'cancelled')),
779
+ started_at INTEGER NOT NULL,
780
+ completed_at INTEGER,
781
+ created_at INTEGER NOT NULL,
782
+ FOREIGN KEY (schedule_id) REFERENCES scheduled_executions(id) ON DELETE CASCADE,
783
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
784
+ );
785
+ `);
786
+ // Create indexes for execution_logs
787
+ db.exec(`
788
+ CREATE INDEX idx_execution_logs_schedule
789
+ ON execution_logs(schedule_id);
790
+ `);
791
+ db.exec(`
792
+ CREATE INDEX idx_execution_logs_worktree
793
+ ON execution_logs(worktree_id);
794
+ `);
795
+ db.exec(`
796
+ CREATE INDEX idx_execution_logs_status
797
+ ON execution_logs(status);
798
+ `);
799
+ console.log('✓ Cleaned up orphan records');
800
+ console.log('✓ Created scheduled_executions table');
801
+ console.log('✓ Created execution_logs table');
802
+ console.log('✓ Created indexes for schedule tables');
803
+ },
804
+ down: (db) => {
805
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_status');
806
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_worktree');
807
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_schedule');
808
+ db.exec('DROP TABLE IF EXISTS execution_logs');
809
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_enabled');
810
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_worktree');
811
+ db.exec('DROP TABLE IF EXISTS scheduled_executions');
812
+ console.log('✓ Dropped scheduled_executions and execution_logs tables');
813
+ }
720
814
  }
721
815
  ];
722
816
  /**
@@ -904,7 +998,7 @@ function validateSchema(db) {
904
998
  ORDER BY name
905
999
  `).all();
906
1000
  const tableNames = tables.map(t => t.name);
907
- const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs'];
1001
+ const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs', 'scheduled_executions', 'execution_logs'];
908
1002
  const missingTables = requiredTables.filter(t => !tableNames.includes(t));
909
1003
  if (missingTables.length > 0) {
910
1004
  console.error('Missing required tables:', missingTables.join(', '));