@cnrai/pave 0.3.32 → 0.3.34

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 (83) hide show
  1. package/MARKETPLACE.md +406 -0
  2. package/README.md +218 -21
  3. package/build-binary.js +591 -0
  4. package/build-npm.js +537 -0
  5. package/build.js +230 -0
  6. package/check-binary.js +26 -0
  7. package/deploy.sh +95 -0
  8. package/index.js +5775 -0
  9. package/lib/agent-registry.js +1037 -0
  10. package/lib/args-parser.js +837 -0
  11. package/lib/blessed-widget-patched.js +93 -0
  12. package/lib/cli-markdown.js +590 -0
  13. package/lib/compaction.js +153 -0
  14. package/lib/duration.js +94 -0
  15. package/lib/hash.js +22 -0
  16. package/lib/marketplace.js +866 -0
  17. package/lib/memory-config.js +166 -0
  18. package/lib/skill-manager.js +891 -0
  19. package/lib/soul.js +31 -0
  20. package/lib/tool-output-formatter.js +180 -0
  21. package/package.json +35 -33
  22. package/start-pave.sh +149 -0
  23. package/status.js +271 -0
  24. package/test/abort-stream.test.js +445 -0
  25. package/test/agent-auto-compaction.test.js +552 -0
  26. package/test/agent-comm-abort.test.js +95 -0
  27. package/test/agent-comm.test.js +598 -0
  28. package/test/agent-inbox.test.js +576 -0
  29. package/test/agent-init.test.js +264 -0
  30. package/test/agent-interrupt.test.js +314 -0
  31. package/test/agent-lifecycle.test.js +520 -0
  32. package/test/agent-log-files.test.js +349 -0
  33. package/test/agent-mode.manual-test.js +392 -0
  34. package/test/agent-parsing.test.js +228 -0
  35. package/test/agent-post-stream-idle.test.js +762 -0
  36. package/test/agent-registry.test.js +359 -0
  37. package/test/agent-rm.test.js +442 -0
  38. package/test/agent-spawn.test.js +933 -0
  39. package/test/agent-status-api.test.js +624 -0
  40. package/test/agent-update.test.js +435 -0
  41. package/test/args-parser.test.js +391 -0
  42. package/test/auto-compaction-chat.manual-test.js +227 -0
  43. package/test/auto-compaction.test.js +941 -0
  44. package/test/build-config.test.js +120 -0
  45. package/test/build-npm.test.js +388 -0
  46. package/test/chat-command.test.js +137 -0
  47. package/test/chat-leading-lines.test.js +159 -0
  48. package/test/config-flag.test.js +272 -0
  49. package/test/cursor-drift.test.js +135 -0
  50. package/test/debug-require.js +23 -0
  51. package/test/dir-migration.test.js +323 -0
  52. package/test/duration.test.js +229 -0
  53. package/test/ghostty-term.test.js +202 -0
  54. package/test/http500-backoff.test.js +854 -0
  55. package/test/integration.test.js +86 -0
  56. package/test/memory-guard-env.test.js +220 -0
  57. package/test/pr233-fixes.test.js +259 -0
  58. package/test/run-agent-init.js +297 -0
  59. package/test/run-all.js +64 -0
  60. package/test/run-config-flag.js +159 -0
  61. package/test/run-cursor-drift.js +82 -0
  62. package/test/run-session-path.js +154 -0
  63. package/test/run-tests.js +643 -0
  64. package/test/sandbox-redirect.test.js +202 -0
  65. package/test/session-path.test.js +132 -0
  66. package/test/shebang-strip.test.js +241 -0
  67. package/test/soul-reinject.test.js +1027 -0
  68. package/test/soul-reread.test.js +281 -0
  69. package/test/tool-output-formatter.test.js +486 -0
  70. package/test/tool-output-gating.test.js +143 -0
  71. package/test/tool-states.test.js +167 -0
  72. package/test/tools-flag.test.js +65 -0
  73. package/test/tui-attach.test.js +1255 -0
  74. package/test/tui-compaction.test.js +354 -0
  75. package/test/tui-wrap.test.js +568 -0
  76. package/test-binary.js +52 -0
  77. package/test-binary2.js +36 -0
  78. package/LICENSE +0 -21
  79. package/pave.js +0 -2
  80. package/sandbox/SandboxRunner.js +0 -1
  81. package/sandbox/pave-run.js +0 -2
  82. package/sandbox/permission.js +0 -1
  83. package/sandbox/utils/yaml.js +0 -1
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Tests for sandbox shell redirection stripping (Issue #127).
3
+ *
4
+ * Verifies that shell redirections (2>&1, >/dev/null, etc.) are stripped
5
+ * from node command args before parsing, preventing them from being
6
+ * mistaken for script filenames.
7
+ *
8
+ * Uses checkmark/X format so run-all.js correctly counts results.
9
+ */
10
+
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ function assert(cond, msg) {
15
+ if (cond) {
16
+ passed++;
17
+ console.log('\u2705 ' + msg);
18
+ } else {
19
+ failed++;
20
+ console.log('\u274c ' + msg);
21
+ }
22
+ }
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ // ---- Load stripShellRedirections ----
28
+ // Try require() first (works in CI where deps are installed).
29
+ // Fall back to source extraction when deps like js-yaml are missing.
30
+ const sandboxPath = path.resolve(__dirname, '../../opencode-lite/src/sandbox/SandboxRunner.js');
31
+ let stripShellRedirections = null;
32
+ try {
33
+ const sandboxModule = require(sandboxPath);
34
+ if (sandboxModule && typeof sandboxModule.stripShellRedirections === 'function') {
35
+ stripShellRedirections = sandboxModule.stripShellRedirections;
36
+ }
37
+ } catch (_requireErr) {
38
+ // Module has dependencies (js-yaml etc.) that may not be available.
39
+ // Extract just the standalone function from source as a fallback.
40
+ try {
41
+ const src = fs.readFileSync(sandboxPath, 'utf8');
42
+ // Match the function including its full body (handles nested braces
43
+ // by finding the function start and scanning for balanced braces).
44
+ const startIdx = src.indexOf('function stripShellRedirections(str)');
45
+ if (startIdx !== -1) {
46
+ let depth = 0;
47
+ let endIdx = startIdx;
48
+ let foundOpen = false;
49
+ for (let ci = startIdx; ci < src.length; ci++) {
50
+ if (src[ci] === '{') { depth++; foundOpen = true; }
51
+ if (src[ci] === '}') { depth--; }
52
+ if (foundOpen && depth === 0) { endIdx = ci + 1; break; }
53
+ }
54
+ const fnSrc = src.substring(startIdx, endIdx);
55
+ eval(fnSrc); // defines stripShellRedirections in this scope
56
+ }
57
+ } catch (_extractErr) {
58
+ stripShellRedirections = null;
59
+ }
60
+ }
61
+
62
+ if (!stripShellRedirections) {
63
+ throw new Error('Unable to load stripShellRedirections from ' + sandboxPath);
64
+ }
65
+
66
+ // ---- 1. Unit tests for stripShellRedirections ----
67
+
68
+ // Basic redirections (no space between operator and target)
69
+ assert(stripShellRedirections('--version 2>&1') === '--version',
70
+ 'strip: --version 2>&1 -> --version');
71
+ assert(stripShellRedirections('--version') === '--version',
72
+ 'strip: --version (no redirect) unchanged');
73
+ assert(stripShellRedirections('script.js 2>&1') === 'script.js',
74
+ 'strip: script.js 2>&1 -> script.js');
75
+ assert(stripShellRedirections('script.js 2>/dev/null') === 'script.js',
76
+ 'strip: script.js 2>/dev/null -> script.js');
77
+ assert(stripShellRedirections('script.js >/dev/null') === 'script.js',
78
+ 'strip: script.js >/dev/null -> script.js');
79
+ assert(stripShellRedirections('script.js >output.txt') === 'script.js',
80
+ 'strip: script.js >output.txt -> script.js');
81
+ assert(stripShellRedirections('script.js >>output.txt') === 'script.js',
82
+ 'strip: script.js >>output.txt -> script.js');
83
+ assert(stripShellRedirections('script.js 2>>error.log') === 'script.js',
84
+ 'strip: script.js 2>>error.log -> script.js');
85
+ assert(stripShellRedirections('script.js <input.txt') === 'script.js',
86
+ 'strip: script.js <input.txt -> script.js');
87
+ assert(stripShellRedirections('script.js &>/dev/null') === 'script.js',
88
+ 'strip: script.js &>/dev/null -> script.js');
89
+ assert(stripShellRedirections('script.js &>>output.txt') === 'script.js',
90
+ 'strip: script.js &>>output.txt -> script.js');
91
+
92
+ // Whitespace-separated targets (Copilot review feedback)
93
+ assert(stripShellRedirections('script.js > out.txt') === 'script.js',
94
+ 'strip: script.js > out.txt -> script.js (space-separated)');
95
+ assert(stripShellRedirections('script.js 2> /dev/null') === 'script.js',
96
+ 'strip: script.js 2> /dev/null -> script.js (space-separated)');
97
+ assert(stripShellRedirections('script.js < input.txt') === 'script.js',
98
+ 'strip: script.js < input.txt -> script.js (space-separated)');
99
+ assert(stripShellRedirections('script.js >> out.txt') === 'script.js',
100
+ 'strip: script.js >> out.txt -> script.js (space-separated)');
101
+ assert(stripShellRedirections('script.js 2>> error.log') === 'script.js',
102
+ 'strip: script.js 2>> error.log -> script.js (space-separated)');
103
+ assert(stripShellRedirections('script.js &> /dev/null') === 'script.js',
104
+ 'strip: script.js &> /dev/null -> script.js (space-separated)');
105
+ assert(stripShellRedirections('script.js &>> out.txt') === 'script.js',
106
+ 'strip: script.js &>> out.txt -> script.js (space-separated)');
107
+
108
+ // Multiple redirections
109
+ assert(stripShellRedirections('script.js 2>&1 >/dev/null') === 'script.js',
110
+ 'strip: multiple redirections');
111
+ assert(stripShellRedirections('script.js >out.txt 2>&1') === 'script.js',
112
+ 'strip: >out.txt 2>&1');
113
+ assert(stripShellRedirections('script.js > out.txt 2>&1') === 'script.js',
114
+ 'strip: > out.txt 2>&1 (space-separated + fd redirect)');
115
+
116
+ // With script arguments
117
+ assert(stripShellRedirections('script.js --flag value 2>&1') === 'script.js --flag value',
118
+ 'strip: preserves script args');
119
+ assert(stripShellRedirections('-e "console.log(1)" 2>&1') === '-e "console.log(1)"',
120
+ 'strip: preserves -e inline code');
121
+
122
+ // Edge cases
123
+ assert(stripShellRedirections('2>&1') === '',
124
+ 'strip: only redirection -> empty');
125
+ assert(stripShellRedirections('') === '',
126
+ 'strip: empty string -> empty');
127
+ assert(stripShellRedirections('script.js') === 'script.js',
128
+ 'strip: no redirection -> unchanged');
129
+
130
+ // FD numbers
131
+ assert(stripShellRedirections('script.js 1>&2') === 'script.js',
132
+ 'strip: 1>&2');
133
+ assert(stripShellRedirections('script.js >&2') === 'script.js',
134
+ 'strip: >&2');
135
+
136
+ // Quoted redirections must be preserved (Copilot review round 4)
137
+ assert(stripShellRedirections('-e "console.log(\'2>&1\')" 2>&1') === '-e "console.log(\'2>&1\')"',
138
+ 'strip: preserves 2>&1 inside double quotes');
139
+ assert(stripShellRedirections("-e 'echo 2>/dev/null' 2>&1") === "-e 'echo 2>/dev/null'",
140
+ 'strip: preserves 2>/dev/null inside single quotes');
141
+ assert(stripShellRedirections('-e "a < b" >/dev/null') === '-e "a < b"',
142
+ 'strip: preserves < inside double quotes');
143
+ assert(stripShellRedirections("-e 'a > b' 2>&1") === "-e 'a > b'",
144
+ 'strip: preserves > inside single quotes');
145
+ assert(stripShellRedirections('script.js "arg with 2>&1" 2>/dev/null') === 'script.js "arg with 2>&1"',
146
+ 'strip: preserves 2>&1 in quoted script arg');
147
+
148
+ // Non-redirection uses of >/< in arguments must be preserved (Copilot review round 5)
149
+ assert(stripShellRedirections('-e "console.log(1>2)"') === '-e "console.log(1>2)"',
150
+ 'strip: preserves > in quoted expression');
151
+ assert(stripShellRedirections("-e 'x=a<b'") === "-e 'x=a<b'",
152
+ 'strip: preserves < in quoted expression');
153
+
154
+ // Backslash-escaped quotes inside double quotes (Copilot review round 6)
155
+ assert(stripShellRedirections('-e "console.log(\\"2>&1\\")" 2>&1') === '-e "console.log(\\"2>&1\\")"',
156
+ 'strip: preserves escaped quotes with redirection inside');
157
+
158
+ // Digit-prefixed args should not lose digits (Copilot review round 6)
159
+ assert(stripShellRedirections('script.js --port 3000 2>&1') === 'script.js --port 3000',
160
+ 'strip: preserves numeric arg followed by redirection');
161
+
162
+ // Bare & and && must not be treated as redirections (Copilot review round 7)
163
+ assert(stripShellRedirections('script.js && echo done') === 'script.js && echo done',
164
+ 'strip: preserves && operator');
165
+ assert(stripShellRedirections('script.js &') === 'script.js &',
166
+ 'strip: preserves background &');
167
+
168
+ // ---- 2. Integration flow tests ----
169
+ // Verify end-to-end that redirections don't pollute scriptPath detection.
170
+ function findScriptPath(fullArgs) {
171
+ const cleaned = stripShellRedirections(fullArgs);
172
+ const args = cleaned ? cleaned.split(/\s+/) : [];
173
+ let scriptPath = null;
174
+ for (let i = 0; i < args.length; i++) {
175
+ if (args[i].charAt(0) !== '-') {
176
+ scriptPath = args[i];
177
+ break;
178
+ }
179
+ }
180
+ return scriptPath;
181
+ }
182
+
183
+ assert(findScriptPath('--version 2>&1') === null,
184
+ 'flow: --version 2>&1 -> scriptPath is null');
185
+ assert(findScriptPath('script.js 2>&1') === 'script.js',
186
+ 'flow: script.js 2>&1 -> scriptPath is script.js');
187
+ assert(findScriptPath('script.js 2>/dev/null') === 'script.js',
188
+ 'flow: script.js 2>/dev/null -> scriptPath is script.js');
189
+ assert(findScriptPath('2>&1') === null,
190
+ 'flow: just 2>&1 -> scriptPath is null');
191
+ assert(findScriptPath('-e 2>&1') === null,
192
+ 'flow: -e 2>&1 -> scriptPath is null (-e is an option)');
193
+ assert(findScriptPath('--version') === null,
194
+ 'flow: --version -> scriptPath is null');
195
+ assert(findScriptPath('--version > out.txt') === null,
196
+ 'flow: --version > out.txt -> scriptPath is null');
197
+ assert(findScriptPath('--version 2> /dev/null') === null,
198
+ 'flow: --version 2> /dev/null -> scriptPath is null');
199
+
200
+ console.log('');
201
+ console.log(passed + ' passed, ' + failed + ' failed');
202
+ if (failed > 0) throw new Error(failed + ' test(s) failed');
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests for agent session file path (issues #108, #174)
3
+ *
4
+ * Verifies that both chat and agent commands use resolveSessionFile()
5
+ * to get the session file path, which resolves to .pave/session.json
6
+ * (migrating from .pave-session.json if needed).
7
+ */
8
+
9
+ const path = require('path');
10
+ const fs = require('fs');
11
+
12
+ // Helper: extract a named function body from source using brace matching.
13
+ function extractFunctionBody(src, funcName) {
14
+ let idx = src.indexOf('async function ' + funcName);
15
+ if (idx === -1) idx = src.indexOf('function ' + funcName);
16
+ if (idx === -1) throw new Error('function ' + funcName + ' not found');
17
+
18
+ const braceStart = src.indexOf('{', idx);
19
+ if (braceStart === -1) throw new Error('Opening brace not found for ' + funcName);
20
+
21
+ let braceCount = 0;
22
+ let endIndex = braceStart;
23
+ for (let i = braceStart; i < src.length; i++) {
24
+ if (src[i] === '{') braceCount++;
25
+ else if (src[i] === '}') {
26
+ braceCount--;
27
+ if (braceCount === 0) { endIndex = i + 1; break; }
28
+ }
29
+ }
30
+ return src.slice(braceStart, endIndex);
31
+ }
32
+
33
+ describe('Session file path (issues #108, #174)', () => {
34
+ const srcPath = path.join(__dirname, '..', 'index.js');
35
+ const src = fs.readFileSync(srcPath, 'utf8');
36
+ const agentBody = extractFunctionBody(src, 'handleAgentCommand');
37
+ const chatBody = extractFunctionBody(src, 'handleChatCommand');
38
+
39
+ // =========================================================================
40
+ // Test: resolveSessionFile helper exists
41
+ // =========================================================================
42
+
43
+ test('resolveSessionFile helper function exists', () => {
44
+ expect(src).toContain('function resolveSessionFile(');
45
+ });
46
+
47
+ // =========================================================================
48
+ // Test: SESSION_FILE in handleAgentCommand uses resolveSessionFile
49
+ // =========================================================================
50
+
51
+ test('agent SESSION_FILE uses resolveSessionFile', () => {
52
+ expect(agentBody).toContain('resolveSessionFile(configDir)');
53
+ expect(agentBody).not.toContain('path.join(process.cwd(), \'.pave-session.json\')');
54
+ });
55
+
56
+ // =========================================================================
57
+ // Test: SESSION_FILE in handleChatCommand uses resolveSessionFile
58
+ // =========================================================================
59
+
60
+ test('chat SESSION_FILE uses resolveSessionFile', () => {
61
+ expect(chatBody).toContain('resolveSessionFile(configDir)');
62
+ expect(chatBody).not.toContain('path.join(process.cwd(), \'.pave-session.json\')');
63
+ });
64
+
65
+ // =========================================================================
66
+ // Test: SESSION_FILE does not reference AGENT_LOG_DIR
67
+ // =========================================================================
68
+
69
+ test('agent SESSION_FILE does not reference AGENT_LOG_DIR', () => {
70
+ const codeOnly = agentBody.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
71
+ const assignRegex = /SESSION_FILE\s*=/g;
72
+ let m;
73
+ while ((m = assignRegex.exec(codeOnly)) !== null) {
74
+ const start = m.index;
75
+ let end = codeOnly.indexOf(';', start);
76
+ if (end === -1) end = codeOnly.length;
77
+ const stmt = codeOnly.slice(start, end);
78
+ expect(stmt).not.toContain('AGENT_LOG_DIR');
79
+ }
80
+ });
81
+
82
+ // =========================================================================
83
+ // Test: both chat and agent use the same resolveSessionFile pattern
84
+ // =========================================================================
85
+
86
+ test('chat and agent both use resolveSessionFile(configDir)', () => {
87
+ expect(chatBody).toContain('resolveSessionFile(configDir)');
88
+ expect(agentBody).toContain('resolveSessionFile(configDir)');
89
+ });
90
+
91
+ // =========================================================================
92
+ // Test: no CWD_SESSION_FILE variable in source
93
+ // =========================================================================
94
+
95
+ test('no CWD_SESSION_FILE variable in source code', () => {
96
+ expect(src.indexOf('CWD_SESSION_FILE')).toBe(-1);
97
+ });
98
+
99
+ // =========================================================================
100
+ // Test: no agent-session reference in source code
101
+ // =========================================================================
102
+
103
+ test('no agent-session reference in agent code', () => {
104
+ const codeOnly = agentBody.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
105
+ expect(codeOnly.indexOf('agent-session')).toBe(-1);
106
+ });
107
+
108
+ // =========================================================================
109
+ // Test: no dual writes in handleAgentCommand
110
+ // =========================================================================
111
+
112
+ test('no dual writes to SESSION_FILE and CWD_SESSION_FILE', () => {
113
+ const hasSessionWrite = agentBody.includes('writeFileSync(SESSION_FILE');
114
+ const hasCwdSessionWrite = agentBody.includes('writeFileSync(CWD_SESSION_FILE');
115
+
116
+ if (hasSessionWrite && hasCwdSessionWrite) {
117
+ throw new Error('Found dual write to SESSION_FILE and CWD_SESSION_FILE');
118
+ }
119
+ });
120
+
121
+ // =========================================================================
122
+ // Test: resolveSessionFile handles migration
123
+ // =========================================================================
124
+
125
+ test('resolveSessionFile migrates .pave-session.json', () => {
126
+ const resolveBody = extractFunctionBody(src, 'resolveSessionFile');
127
+ expect(resolveBody).toContain('.pave-session.json');
128
+ expect(resolveBody).toContain('session.json');
129
+ expect(resolveBody).toContain('copyFileSync');
130
+ expect(resolveBody).toContain('unlinkSync');
131
+ });
132
+ });
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Tests for sandbox shebang stripping (Issue #129).
3
+ *
4
+ * Verifies that shebang lines (#!/usr/bin/env node, etc.) are stripped
5
+ * from module content before eval() in the sandbox's __loadModule function.
6
+ *
7
+ * Uses checkmark/X format so run-all.js correctly counts results.
8
+ */
9
+
10
+ let passed = 0;
11
+ let failed = 0;
12
+
13
+ function assert(cond, msg) {
14
+ if (cond) {
15
+ passed++;
16
+ console.log('\u2705 ' + msg);
17
+ } else {
18
+ failed++;
19
+ console.log('\u274c ' + msg);
20
+ }
21
+ }
22
+
23
+ function assertEqual(actual, expected, msg) {
24
+ if (actual === expected) {
25
+ passed++;
26
+ console.log('\u2705 ' + msg);
27
+ } else {
28
+ failed++;
29
+ console.log('\u274c ' + msg);
30
+ console.log(' Expected: ' + JSON.stringify(expected));
31
+ console.log(' Actual: ' + JSON.stringify(actual));
32
+ }
33
+ }
34
+
35
+ const fs = require('fs');
36
+ const path = require('path');
37
+
38
+ // ---- Verify shebang stripping code exists in generated sandbox APIs ----
39
+
40
+ const sandboxPath = path.resolve(__dirname, '../../opencode-lite/src/sandbox/SandboxRunner.js');
41
+ const src = fs.readFileSync(sandboxPath, 'utf8');
42
+
43
+ // Find the __loadModule function in the template literal
44
+ const loadModuleIdx = src.indexOf('function __loadModule(resolvedPath)');
45
+ assert(loadModuleIdx !== -1, 'source: __loadModule function exists in SandboxRunner.js');
46
+
47
+ // Verify shebang stripping code is present after __loadModule
48
+ const afterLoadModule = src.substring(loadModuleIdx, loadModuleIdx + 2000);
49
+ assert(afterLoadModule.indexOf('#!') !== -1,
50
+ 'source: shebang check (#!) exists in __loadModule');
51
+ assert(afterLoadModule.indexOf('substring(0, 2)') !== -1,
52
+ 'source: uses substring(0, 2) to check first two chars');
53
+ assert(afterLoadModule.indexOf('indexOf') !== -1,
54
+ 'source: uses indexOf to find newline position');
55
+
56
+ // Verify the stripping happens BEFORE the eval wrapper
57
+ const evalIdx = afterLoadModule.indexOf('eval(wrapper)');
58
+ const shebangIdx = afterLoadModule.indexOf('#!');
59
+ assert(shebangIdx < evalIdx,
60
+ 'source: shebang stripping occurs before eval(wrapper)');
61
+
62
+ // ---- Unit tests for shebang stripping logic ----
63
+ // The logic in __loadModule is:
64
+ // if (content.substring(0, 2) === '#!') {
65
+ // var nlIdx = content.indexOf('\n');
66
+ // content = nlIdx !== -1 ? content.substring(nlIdx + 1) : '';
67
+ // }
68
+
69
+ function stripShebang(content) {
70
+ if (content.substring(0, 2) === '#!') {
71
+ const nlIdx = content.indexOf('\n');
72
+ content = nlIdx !== -1 ? content.substring(nlIdx + 1) : '';
73
+ }
74
+ return content;
75
+ }
76
+
77
+ // Basic shebang stripping
78
+ assertEqual(
79
+ stripShebang('#!/usr/bin/env node\nvar x = 1;'),
80
+ 'var x = 1;',
81
+ 'strip: #!/usr/bin/env node shebang is removed',
82
+ );
83
+
84
+ assertEqual(
85
+ stripShebang('#!/usr/bin/node\nvar x = 1;'),
86
+ 'var x = 1;',
87
+ 'strip: #!/usr/bin/node shebang is removed',
88
+ );
89
+
90
+ assertEqual(
91
+ stripShebang('#!/bin/sh\necho hello'),
92
+ 'echo hello',
93
+ 'strip: #!/bin/sh shebang is removed',
94
+ );
95
+
96
+ // Shebang with flags
97
+ assertEqual(
98
+ stripShebang('#!/usr/bin/env node --harmony\nvar x = 1;'),
99
+ 'var x = 1;',
100
+ 'strip: shebang with flags is removed',
101
+ );
102
+
103
+ // No shebang - content unchanged
104
+ assertEqual(
105
+ stripShebang('var x = 1;'),
106
+ 'var x = 1;',
107
+ 'strip: no shebang - content unchanged',
108
+ );
109
+
110
+ assertEqual(
111
+ stripShebang('// comment\nvar x = 1;'),
112
+ '// comment\nvar x = 1;',
113
+ 'strip: comment line - content unchanged',
114
+ );
115
+
116
+ assertEqual(
117
+ stripShebang(''),
118
+ '',
119
+ 'strip: empty string - content unchanged',
120
+ );
121
+
122
+ // Single character strings
123
+ assertEqual(
124
+ stripShebang('#'),
125
+ '#',
126
+ 'strip: single # - content unchanged (not a shebang)',
127
+ );
128
+
129
+ assertEqual(
130
+ stripShebang('!'),
131
+ '!',
132
+ 'strip: single ! - content unchanged',
133
+ );
134
+
135
+ // Hash but not shebang
136
+ assertEqual(
137
+ stripShebang('# This is a comment\nvar x = 1;'),
138
+ '# This is a comment\nvar x = 1;',
139
+ 'strip: # comment (not #!) - content unchanged',
140
+ );
141
+
142
+ // Multiline content after shebang
143
+ assertEqual(
144
+ stripShebang('#!/usr/bin/env node\nvar x = 1;\nvar y = 2;\nconsole.log(x + y);'),
145
+ 'var x = 1;\nvar y = 2;\nconsole.log(x + y);',
146
+ 'strip: multiline content after shebang preserved',
147
+ );
148
+
149
+ // Shebang-like content NOT at start
150
+ assertEqual(
151
+ stripShebang('var x = "#!/usr/bin/env node";\nvar y = 1;'),
152
+ 'var x = "#!/usr/bin/env node";\nvar y = 1;',
153
+ 'strip: #! not at start of file - content unchanged',
154
+ );
155
+
156
+ // Windows-style line endings (CRLF)
157
+ assertEqual(
158
+ stripShebang('#!/usr/bin/env node\r\nvar x = 1;'),
159
+ 'var x = 1;',
160
+ 'strip: CRLF line endings - shebang (including \\r) is fully removed',
161
+ );
162
+
163
+ // Shebang only (no content after)
164
+ assertEqual(
165
+ stripShebang('#!/usr/bin/env node\n'),
166
+ '',
167
+ 'strip: shebang only with trailing newline - returns empty',
168
+ );
169
+
170
+ // Shebang without newline (just the shebang, no terminator)
171
+ // With the guard, this correctly returns empty string since
172
+ // there's no actual code after the shebang.
173
+ const noNewline = stripShebang('#!/usr/bin/env node');
174
+ assertEqual(
175
+ noNewline,
176
+ '',
177
+ 'strip: shebang without newline - returns empty string',
178
+ );
179
+
180
+ // ---- Verify args-parser.js has a shebang (the original trigger) ----
181
+ const argsParserPath = path.resolve(__dirname, '../lib/args-parser.js');
182
+ if (fs.existsSync(argsParserPath)) {
183
+ const argsParserContent = fs.readFileSync(argsParserPath, 'utf8');
184
+ assert(argsParserContent.substring(0, 2) === '#!',
185
+ 'args-parser.js: starts with shebang (original trigger for issue #129)');
186
+ const strippedContent = stripShebang(argsParserContent);
187
+ assert(strippedContent.substring(0, 2) !== '#!',
188
+ 'args-parser.js: shebang removed after stripShebang');
189
+ assert(strippedContent.length > 0,
190
+ 'args-parser.js: content remains after shebang removal');
191
+ // Verify the remaining content starts with comment or code
192
+ assert(strippedContent.trimStart().indexOf('/**') === 0 || strippedContent.trimStart().indexOf('//') === 0 || strippedContent.trimStart().indexOf('var ') === 0 || strippedContent.trimStart().indexOf('const ') === 0,
193
+ 'args-parser.js: content after shebang starts with valid JS');
194
+ } else {
195
+ console.log('(skipped args-parser.js verification - file not found at ' + argsParserPath + ')');
196
+ }
197
+
198
+ // ---- Verify the wrapper would be valid JS after shebang stripping ----
199
+ // Simulates what __loadModule does: wrap content in a function and eval it
200
+ function simulateModuleLoad(content) {
201
+ // Apply shebang stripping (matches __loadModule implementation)
202
+ if (content.substring(0, 2) === '#!') {
203
+ const nlIdx = content.indexOf('\n');
204
+ content = nlIdx !== -1 ? content.substring(nlIdx + 1) : '';
205
+ }
206
+ // Create wrapper like __loadModule does
207
+ const wrapper = '(function(module, exports, require, __dirname, __filename) {' + content + '\n});';
208
+ // Try to parse (don't execute) - use Function constructor as a syntax check
209
+ try {
210
+ new Function(wrapper);
211
+ return { ok: true };
212
+ } catch (e) {
213
+ return { ok: false, error: e.message };
214
+ }
215
+ }
216
+
217
+ const result1 = simulateModuleLoad('#!/usr/bin/env node\nvar x = 1; module.exports = x;');
218
+ assert(result1.ok, 'wrapper: module with shebang parses after stripping');
219
+
220
+ const result2 = simulateModuleLoad('var x = 1; module.exports = x;');
221
+ assert(result2.ok, 'wrapper: module without shebang parses normally');
222
+
223
+ const result3 = simulateModuleLoad('#!/usr/bin/env node\n"use strict";\nvar x = 1;');
224
+ assert(result3.ok, 'wrapper: strict mode module with shebang parses after stripping');
225
+
226
+ // Shebang-only file without newline should now produce a valid (empty) wrapper
227
+ const result4 = simulateModuleLoad('#!/usr/bin/env node');
228
+ assert(result4.ok, 'wrapper: shebang-only file (no newline) produces valid empty wrapper');
229
+
230
+ // Without stripping, a shebang would cause SyntaxError
231
+ try {
232
+ const badWrapper = '(function(module, exports, require, __dirname, __filename) {#!/usr/bin/env node\nvar x = 1;\n});';
233
+ new Function(badWrapper);
234
+ assert(false, 'wrapper: unstripped shebang should cause SyntaxError');
235
+ } catch (e) {
236
+ assert(e instanceof SyntaxError, 'wrapper: unstripped shebang causes SyntaxError (confirms the bug)');
237
+ }
238
+
239
+ // ---- Summary ----
240
+ console.log('\n' + (passed + failed) + ' tests: ' + passed + ' passed, ' + failed + ' failed');
241
+ if (failed > 0) process.exit(1);