skalpel 2.0.23 → 3.0.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.
Files changed (43) hide show
  1. package/INSTALL.md +103 -0
  2. package/LICENSE +201 -21
  3. package/README.md +12 -174
  4. package/design-tokens.json +51 -0
  5. package/npm-bin/colors.js +125 -0
  6. package/npm-bin/skalpel.js +200 -0
  7. package/npm-bin/skalpeld.js +20 -0
  8. package/package.json +50 -68
  9. package/postinstall/index.js +294 -0
  10. package/postinstall/launchd/com.skalpel.skalpeld.plist.tmpl +41 -0
  11. package/postinstall/lib/detect-prior.js +51 -0
  12. package/postinstall/lib/env-inject.js +121 -0
  13. package/postinstall/lib/launch.js +28 -0
  14. package/postinstall/lib/log.js +31 -0
  15. package/postinstall/lib/paths.js +186 -0
  16. package/postinstall/lib/rc-edit.js +167 -0
  17. package/postinstall/lib/rc-edit.test.js +196 -0
  18. package/postinstall/lib/service-register.js +293 -0
  19. package/postinstall/lib/sign-in.js +98 -0
  20. package/postinstall/lib/template.js +36 -0
  21. package/postinstall/snippets/bash.sh.tmpl +12 -0
  22. package/postinstall/snippets/fish.fish.tmpl +11 -0
  23. package/postinstall/snippets/powershell.ps1.tmpl +12 -0
  24. package/postinstall/snippets/zsh.sh.tmpl +13 -0
  25. package/postinstall/systemd/skalpeld.service.tmpl +33 -0
  26. package/postinstall/windows/Task.xml.tmpl +42 -0
  27. package/postinstall/windows/register-task.ps1.tmpl +45 -0
  28. package/dist/cli/index.js +0 -2899
  29. package/dist/cli/index.js.map +0 -1
  30. package/dist/cli/proxy-runner.js +0 -1649
  31. package/dist/cli/proxy-runner.js.map +0 -1
  32. package/dist/index.cjs +0 -2333
  33. package/dist/index.cjs.map +0 -1
  34. package/dist/index.d.cts +0 -165
  35. package/dist/index.d.ts +0 -165
  36. package/dist/index.js +0 -2287
  37. package/dist/index.js.map +0 -1
  38. package/dist/proxy/index.cjs +0 -1782
  39. package/dist/proxy/index.cjs.map +0 -1
  40. package/dist/proxy/index.d.cts +0 -39
  41. package/dist/proxy/index.d.ts +0 -39
  42. package/dist/proxy/index.js +0 -1748
  43. package/dist/proxy/index.js.map +0 -1
@@ -0,0 +1,186 @@
1
+ // Per-OS path resolution for the postinstall wizard.
2
+ //
3
+ // SPEC.md §9.1 — config directory paths, service-registration
4
+ // destinations, expected rc-files per shell. Centralising the
5
+ // table here keeps every step honest about what it touches.
6
+
7
+ 'use strict';
8
+
9
+ const path = require('path');
10
+ const os = require('os');
11
+
12
+ // B41: validate that paths supplied via env or os.homedir() are
13
+ // absolute and free of `..` components — defense-in-depth against
14
+ // a malicious / corrupted env that would have us write into a
15
+ // parent directory or into the project tree.
16
+ function validatePath(p, name) {
17
+ if (p === undefined || p === null || p === '') {
18
+ return; // optional values handled by caller
19
+ }
20
+ if (!path.isAbsolute(p)) {
21
+ throw new Error(`${name} must be an absolute path; got ${JSON.stringify(p)}`);
22
+ }
23
+ const parts = String(p).split(path.sep);
24
+ if (parts.includes('..')) {
25
+ throw new Error(`${name} must not contain ".." components; got ${JSON.stringify(p)}`);
26
+ }
27
+ }
28
+
29
+ function home() {
30
+ const h = os.homedir();
31
+ validatePath(h, 'os.homedir()');
32
+ return h;
33
+ }
34
+
35
+ function configDir() {
36
+ const h = home();
37
+ switch (process.platform) {
38
+ case 'darwin':
39
+ return path.join(h, 'Library', 'Application Support', 'skalpel');
40
+ case 'win32': {
41
+ const appdata = process.env.APPDATA;
42
+ validatePath(appdata, 'APPDATA');
43
+ return path.join(appdata || path.join(h, 'AppData', 'Roaming'), 'skalpel');
44
+ }
45
+ default: {
46
+ const xdg = process.env.XDG_CONFIG_HOME;
47
+ validatePath(xdg, 'XDG_CONFIG_HOME');
48
+ return path.join(xdg || path.join(h, '.config'), 'skalpel');
49
+ }
50
+ }
51
+ }
52
+
53
+ function authFile() {
54
+ return path.join(configDir(), 'auth.json');
55
+ }
56
+
57
+ function configToml() {
58
+ return path.join(configDir(), 'config.toml');
59
+ }
60
+
61
+ function lockFile() {
62
+ return path.join(configDir(), 'skalpeld.lock');
63
+ }
64
+
65
+ function logsDir() {
66
+ return path.join(configDir(), 'logs');
67
+ }
68
+
69
+ // Per-OS service registration destination.
70
+ function servicePath() {
71
+ const h = home();
72
+ switch (process.platform) {
73
+ case 'darwin':
74
+ return path.join(h, 'Library', 'LaunchAgents', 'ai.skalpel.daemon.plist');
75
+ case 'win32':
76
+ return path.join(configDir(), 'Task.xml');
77
+ default: {
78
+ const xdg = process.env.XDG_CONFIG_HOME;
79
+ validatePath(xdg, 'XDG_CONFIG_HOME');
80
+ return path.join(xdg || path.join(h, '.config'), 'systemd', 'user', 'skalpel-daemon.service');
81
+ }
82
+ }
83
+ }
84
+
85
+ // B32: detect when postinstall is running inside `npx skalpel`.
86
+ function isNpxInvocation() {
87
+ if (process.env.npm_lifecycle_event && /\/_npx\//.test(process.execPath)) {
88
+ return true;
89
+ }
90
+ if (process.env.npm_execpath && /\/_npx\//.test(process.env.npm_execpath)) {
91
+ return true;
92
+ }
93
+ if (/\/_npx\//.test(process.argv[1] || '')) {
94
+ return true;
95
+ }
96
+ return false;
97
+ }
98
+
99
+ // Where the platform binary lives once optionalDependencies installed it.
100
+ function binPath(name) {
101
+ if (isNpxInvocation()) {
102
+ throw new Error(
103
+ 'Skalpel does not support `npx skalpel` — please run `npm i -g @skalpelai/skalpel` instead.'
104
+ );
105
+ }
106
+ const h = home();
107
+ const npmPrefix = process.env.npm_config_prefix;
108
+ validatePath(npmPrefix, 'npm_config_prefix');
109
+ if (process.platform === 'win32') {
110
+ if (npmPrefix) {
111
+ return path.join(npmPrefix, `${name}.exe`);
112
+ }
113
+ return path.join(h, 'AppData', 'Local', 'skalpel', `${name}.exe`);
114
+ }
115
+ if (npmPrefix) {
116
+ return path.join(npmPrefix, 'bin', name);
117
+ }
118
+ if (process.platform === 'darwin') {
119
+ return path.join('/usr/local/bin', name);
120
+ }
121
+ return path.join(h, '.local', 'bin', name);
122
+ }
123
+
124
+ // rc-files per shell. The wizard touches zero or more of these,
125
+ // depending on what exists on disk.
126
+ //
127
+ // B22: ~/.profile is sourced by sh / bash-login / ksh / dash and is
128
+ // the only POSIX-portable per-user rc file — it is always emitted.
129
+ // B23: ~/.bash_profile is bash-login-only; it is conventionally
130
+ // edited on macOS (where Terminal.app launches login shells by
131
+ // default) but rarely on Linux. We emit it only on darwin.
132
+ function rcFiles() {
133
+ const h = home();
134
+ if (process.platform === 'win32') {
135
+ const docs = process.env.USERPROFILE
136
+ ? path.join(process.env.USERPROFILE, 'Documents')
137
+ : path.join(h, 'Documents');
138
+ return [
139
+ { shell: 'powershell', path: path.join(docs, 'PowerShell', 'Microsoft.PowerShell_profile.ps1') },
140
+ { shell: 'powershell-legacy', path: path.join(docs, 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1') },
141
+ ];
142
+ }
143
+ // audit-2026-05-09 §6 LOW: fish config respects $XDG_CONFIG_HOME per
144
+ // the fish spec; previously we hardcoded $HOME/.config/fish.
145
+ const xdgConfig = process.env.XDG_CONFIG_HOME;
146
+ validatePath(xdgConfig, 'XDG_CONFIG_HOME');
147
+ const fishConfigDir = xdgConfig
148
+ ? path.join(xdgConfig, 'fish')
149
+ : path.join(h, '.config', 'fish');
150
+ const list = [
151
+ { shell: 'zsh', path: path.join(h, '.zshrc') },
152
+ { shell: 'bash', path: path.join(h, '.bashrc') },
153
+ { shell: 'profile', path: path.join(h, '.profile') }, // B22
154
+ { shell: 'fish', path: path.join(fishConfigDir, 'config.fish') },
155
+ ];
156
+ if (process.platform === 'darwin') {
157
+ list.push({ shell: 'bash-profile', path: path.join(h, '.bash_profile') }); // B23
158
+ }
159
+ return list;
160
+ }
161
+
162
+ function templateValues() {
163
+ const h = home();
164
+ return {
165
+ USER: os.userInfo().username,
166
+ HOME: h,
167
+ BIN: path.dirname(binPath('skalpeld')),
168
+ CONFIG_DIR: configDir(),
169
+ SKALPELD_BIN: binPath('skalpeld'),
170
+ SKALPEL_BIN: binPath('skalpel'),
171
+ };
172
+ }
173
+
174
+ module.exports = {
175
+ configDir,
176
+ authFile,
177
+ configToml,
178
+ lockFile,
179
+ logsDir,
180
+ servicePath,
181
+ binPath,
182
+ rcFiles,
183
+ templateValues,
184
+ isNpxInvocation,
185
+ validatePath,
186
+ };
@@ -0,0 +1,167 @@
1
+ // rc-file injection: the fenced "managed block" that holds the env
2
+ // vars Skalpel needs the user's coding agents to see.
3
+ //
4
+ // SPEC.md §9.5 — the block is identified by its begin/end fences. The
5
+ // injector finds the existing block (if any) and replaces it; otherwise
6
+ // appends. The injector NEVER edits outside its own fences; user-added
7
+ // export lines are preserved.
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+
14
+ // Decision D260 / B30 — vendor-prefixed fence format:
15
+ // # >>> @skalpelai/skalpel begin (managed)
16
+ // # <<< @skalpelai/skalpel end (managed)
17
+ const FENCE_BEGIN_POSIX = '# >>> @skalpelai/skalpel begin (managed)';
18
+ const FENCE_END_POSIX = '# <<< @skalpelai/skalpel end (managed)';
19
+ const FENCE_BEGIN_PS = '# >>> @skalpelai/skalpel begin (managed) >>>';
20
+ const FENCE_END_PS = '# <<< @skalpelai/skalpel end (managed) <<<';
21
+
22
+ function envBlockValues(port) {
23
+ const p = String(port || 7878);
24
+ const root = `http://127.0.0.1:${p}`;
25
+ return {
26
+ ANTHROPIC_API_URL: root,
27
+ ANTHROPIC_BASE_URL: root,
28
+ OPENAI_BASE_URL: `${root}/v1`,
29
+ OPENAI_API_BASE: `${root}/v1`,
30
+ SKALPEL_PROXY_URL: root,
31
+ };
32
+ }
33
+
34
+ function fenceFor(shell) {
35
+ if (shell === 'powershell' || shell === 'powershell-legacy') {
36
+ return { begin: FENCE_BEGIN_PS, end: FENCE_END_PS };
37
+ }
38
+ return { begin: FENCE_BEGIN_POSIX, end: FENCE_END_POSIX };
39
+ }
40
+
41
+ // B19: shell-escape value v inside a double-quoted POSIX export.
42
+ function shellEscapePosixDQ(v) {
43
+ return String(v).replace(/[\\"$`]/g, '\\$&');
44
+ }
45
+
46
+ // PowerShell single-quoted strings are fully literal except `'` doubled.
47
+ function shellEscapePsSQ(v) {
48
+ return String(v).replace(/'/g, "''");
49
+ }
50
+
51
+ function bodyFor(shell, env) {
52
+ const note =
53
+ 'This block is managed by skalpel install. Do not edit by hand;\n' +
54
+ 're-run `skalpel install` or `npx skalpel` to update.';
55
+ switch (shell) {
56
+ case 'fish':
57
+ return [
58
+ '# Managed by skalpel install; safe to delete.',
59
+ ...Object.entries(env).map(
60
+ ([k, v]) => `set -gx ${k} "${shellEscapePosixDQ(v)}"`
61
+ ),
62
+ ].join('\n');
63
+ case 'powershell':
64
+ case 'powershell-legacy':
65
+ return [
66
+ ...note.split('\n').map((l) => `# ${l}`),
67
+ ...Object.entries(env).map(
68
+ ([k, v]) => `$env:${k} = '${shellEscapePsSQ(v)}'`
69
+ ),
70
+ ].join('\n');
71
+ default:
72
+ return [
73
+ ...note.split('\n').map((l) => `# ${l}`),
74
+ ...Object.entries(env).map(
75
+ ([k, v]) => `export ${k}="${shellEscapePosixDQ(v)}"`
76
+ ),
77
+ ].join('\n');
78
+ }
79
+ }
80
+
81
+ function buildBlock(shell, env) {
82
+ const { begin, end } = fenceFor(shell);
83
+ return `${begin}\n${bodyFor(shell, env)}\n${end}`;
84
+ }
85
+
86
+ function escapeRegex(s) {
87
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
88
+ }
89
+
90
+ // B17: tighten trailing-newline match. (\n|$) instead of \n? makes
91
+ // the intent explicit: match exactly one newline OR end-of-input.
92
+ function blockRegex(shell) {
93
+ const { begin, end } = fenceFor(shell);
94
+ return new RegExp(
95
+ `(^|\\n)${escapeRegex(begin)}[\\s\\S]*?${escapeRegex(end)}(\\n|$)`,
96
+ 'g'
97
+ );
98
+ }
99
+
100
+ function rewrite(existing, shell, env) {
101
+ const block = buildBlock(shell, env);
102
+ const re = blockRegex(shell);
103
+ if (re.test(existing)) {
104
+ re.lastIndex = 0;
105
+ return existing.replace(re, (m, pre) => `${pre}${block}\n`);
106
+ }
107
+ const sep = existing.length === 0 || existing.endsWith('\n') ? '' : '\n';
108
+ return `${existing}${sep}${existing.length ? '\n' : ''}${block}\n`;
109
+ }
110
+
111
+ function applyBlock({ shell, file, env, dryRun }) {
112
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
113
+ const next = rewrite(current, shell, env);
114
+ if (dryRun) {
115
+ return { changed: current !== next, dryRun: true };
116
+ }
117
+ if (current === next) {
118
+ return { changed: false };
119
+ }
120
+ fs.mkdirSync(path.dirname(file), { recursive: true });
121
+ fs.writeFileSync(file, next);
122
+ return { changed: true };
123
+ }
124
+
125
+ function removeBlock({ shell, file, dryRun }) {
126
+ if (!fs.existsSync(file)) {
127
+ return { changed: false, missing: true };
128
+ }
129
+ const current = fs.readFileSync(file, 'utf8');
130
+ const re = blockRegex(shell);
131
+ const next = current.replace(re, '');
132
+ if (current === next) {
133
+ return { changed: false };
134
+ }
135
+ if (dryRun) {
136
+ return { changed: true, dryRun: true };
137
+ }
138
+ fs.writeFileSync(file, next);
139
+ return { changed: true };
140
+ }
141
+
142
+ // xmlEscape: B47 — used by Task.xml renderer.
143
+ function xmlEscape(v) {
144
+ return String(v)
145
+ .replace(/&/g, '&amp;')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;')
148
+ .replace(/"/g, '&quot;')
149
+ .replace(/'/g, '&apos;');
150
+ }
151
+
152
+ module.exports = {
153
+ envBlockValues,
154
+ buildBlock,
155
+ rewrite,
156
+ applyBlock,
157
+ removeBlock,
158
+ fenceFor,
159
+ bodyFor,
160
+ shellEscapePosixDQ,
161
+ shellEscapePsSQ,
162
+ xmlEscape,
163
+ FENCE_BEGIN_POSIX,
164
+ FENCE_END_POSIX,
165
+ FENCE_BEGIN_PS,
166
+ FENCE_END_PS,
167
+ };
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+ // rc-edit tests.
3
+ //
4
+ // Mandatory test (per w3c-packaging promise): TestRcEdit_Idempotent_Fenced
5
+ // — runs applyBlock twice against a temp rc-file, asserts only one
6
+ // managed block exists, content matches expected.
7
+ //
8
+ // Additional tests cover all four shells (zsh, bash, fish, PowerShell),
9
+ // removeBlock idempotence, user-line preservation, and re-run with a
10
+ // different port (block replaced, single instance).
11
+ //
12
+ // Stdlib only: assert + fs + os + path. No mocha / no jest.
13
+
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const os = require('os');
18
+ const path = require('path');
19
+ const assert = require('assert');
20
+
21
+ const rc = require('./rc-edit');
22
+
23
+ let pass = 0;
24
+ let fail = 0;
25
+
26
+ function tmpFile(prefix) {
27
+ const name = `${prefix}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
28
+ return path.join(os.tmpdir(), name);
29
+ }
30
+
31
+ function test(name, fn) {
32
+ try {
33
+ fn();
34
+ process.stdout.write(` PASS ${name}\n`);
35
+ pass += 1;
36
+ } catch (err) {
37
+ process.stderr.write(` FAIL ${name}\n ${err.message}\n`);
38
+ fail += 1;
39
+ }
40
+ }
41
+
42
+ function countMatches(s, needle) {
43
+ let c = 0;
44
+ let i = 0;
45
+ while ((i = s.indexOf(needle, i)) !== -1) {
46
+ c += 1;
47
+ i += needle.length;
48
+ }
49
+ return c;
50
+ }
51
+
52
+ function run() {
53
+ process.stdout.write('rc-edit tests:\n');
54
+
55
+ test('TestRcEdit_Idempotent_Fenced', () => {
56
+ const file = tmpFile('rcedit-bash');
57
+ fs.writeFileSync(file, '# user content\nexport FOO=bar\n');
58
+ try {
59
+ const env = rc.envBlockValues(7878);
60
+ const r1 = rc.applyBlock({ shell: 'bash', file, env });
61
+ assert.strictEqual(r1.changed, true, 'first run should change file');
62
+ const r2 = rc.applyBlock({ shell: 'bash', file, env });
63
+ assert.strictEqual(r2.changed, false, 'second run with same env is a no-op');
64
+ const after = fs.readFileSync(file, 'utf8');
65
+ assert.strictEqual(
66
+ countMatches(after, rc.FENCE_BEGIN_POSIX),
67
+ 1,
68
+ 'managed block begin marker must appear exactly once'
69
+ );
70
+ assert.strictEqual(
71
+ countMatches(after, rc.FENCE_END_POSIX),
72
+ 1,
73
+ 'managed block end marker must appear exactly once'
74
+ );
75
+ assert.ok(after.includes('export ANTHROPIC_API_URL="http://127.0.0.1:7878"'));
76
+ assert.ok(after.includes('export OPENAI_BASE_URL="http://127.0.0.1:7878/v1"'));
77
+ assert.ok(after.includes('# user content'), 'user content preserved');
78
+ assert.ok(after.includes('export FOO=bar'), 'user export preserved');
79
+ } finally {
80
+ fs.unlinkSync(file);
81
+ }
82
+ });
83
+
84
+ test('TestRcEdit_Idempotent_Fenced_zsh', () => {
85
+ const file = tmpFile('rcedit-zsh');
86
+ fs.writeFileSync(file, '');
87
+ try {
88
+ const env = rc.envBlockValues(7878);
89
+ rc.applyBlock({ shell: 'zsh', file, env });
90
+ rc.applyBlock({ shell: 'zsh', file, env });
91
+ rc.applyBlock({ shell: 'zsh', file, env });
92
+ const after = fs.readFileSync(file, 'utf8');
93
+ assert.strictEqual(countMatches(after, rc.FENCE_BEGIN_POSIX), 1);
94
+ assert.strictEqual(countMatches(after, rc.FENCE_END_POSIX), 1);
95
+ } finally {
96
+ fs.unlinkSync(file);
97
+ }
98
+ });
99
+
100
+ test('TestRcEdit_Idempotent_Fenced_fish', () => {
101
+ const file = tmpFile('rcedit-fish');
102
+ fs.writeFileSync(file, '# fish prelude\n');
103
+ try {
104
+ const env = rc.envBlockValues(7878);
105
+ rc.applyBlock({ shell: 'fish', file, env });
106
+ rc.applyBlock({ shell: 'fish', file, env });
107
+ const after = fs.readFileSync(file, 'utf8');
108
+ assert.strictEqual(countMatches(after, rc.FENCE_BEGIN_POSIX), 1);
109
+ assert.ok(after.includes('set -gx ANTHROPIC_API_URL'));
110
+ assert.ok(after.includes('# fish prelude'), 'fish prelude preserved');
111
+ } finally {
112
+ fs.unlinkSync(file);
113
+ }
114
+ });
115
+
116
+ test('TestRcEdit_Idempotent_Fenced_powershell', () => {
117
+ const file = tmpFile('rcedit-ps');
118
+ fs.writeFileSync(file, '');
119
+ try {
120
+ const env = rc.envBlockValues(7878);
121
+ rc.applyBlock({ shell: 'powershell', file, env });
122
+ rc.applyBlock({ shell: 'powershell', file, env });
123
+ const after = fs.readFileSync(file, 'utf8');
124
+ assert.strictEqual(countMatches(after, rc.FENCE_BEGIN_PS), 1);
125
+ assert.strictEqual(countMatches(after, rc.FENCE_END_PS), 1);
126
+ assert.ok(after.includes("$env:ANTHROPIC_API_URL = 'http://127.0.0.1:7878'"));
127
+ } finally {
128
+ fs.unlinkSync(file);
129
+ }
130
+ });
131
+
132
+ test('TestRcEdit_Replaces_When_Port_Changes', () => {
133
+ const file = tmpFile('rcedit-port');
134
+ fs.writeFileSync(file, '');
135
+ try {
136
+ rc.applyBlock({ shell: 'bash', file, env: rc.envBlockValues(7878) });
137
+ const r2 = rc.applyBlock({ shell: 'bash', file, env: rc.envBlockValues(9999) });
138
+ assert.strictEqual(r2.changed, true, 'changing port should rewrite');
139
+ const after = fs.readFileSync(file, 'utf8');
140
+ assert.strictEqual(countMatches(after, rc.FENCE_BEGIN_POSIX), 1);
141
+ assert.ok(after.includes('http://127.0.0.1:9999'));
142
+ assert.ok(!after.includes('http://127.0.0.1:7878'));
143
+ } finally {
144
+ fs.unlinkSync(file);
145
+ }
146
+ });
147
+
148
+ test('TestRcEdit_RemoveBlock_Leaves_User_Content', () => {
149
+ const file = tmpFile('rcedit-remove');
150
+ fs.writeFileSync(file, 'export USER_VAR=1\n');
151
+ try {
152
+ rc.applyBlock({ shell: 'bash', file, env: rc.envBlockValues(7878) });
153
+ const r = rc.removeBlock({ shell: 'bash', file });
154
+ assert.strictEqual(r.changed, true);
155
+ const after = fs.readFileSync(file, 'utf8');
156
+ assert.ok(!after.includes(rc.FENCE_BEGIN_POSIX));
157
+ assert.ok(after.includes('export USER_VAR=1'), 'user var preserved');
158
+ } finally {
159
+ fs.unlinkSync(file);
160
+ }
161
+ });
162
+
163
+ test('TestRcEdit_Pure_Rewrite_Function', () => {
164
+ // The pure rewrite() function must satisfy:
165
+ // rewrite(rewrite(x, e), e) == rewrite(x, e)
166
+ const env = rc.envBlockValues(7878);
167
+ const samples = [
168
+ '',
169
+ '# header\n',
170
+ '# header\n# trailing',
171
+ 'export A=1\nexport B=2\n',
172
+ ];
173
+ for (const s of samples) {
174
+ const once = rc.rewrite(s, 'bash', env);
175
+ const twice = rc.rewrite(once, 'bash', env);
176
+ assert.strictEqual(once, twice, `idempotent for sample: ${JSON.stringify(s)}`);
177
+ }
178
+ });
179
+
180
+ test('TestRcEdit_Body_Has_All_Five_Vars', () => {
181
+ const env = rc.envBlockValues(7878);
182
+ const block = rc.buildBlock('bash', env);
183
+ for (const k of Object.keys(env)) {
184
+ assert.ok(block.includes(k), `missing var ${k}`);
185
+ }
186
+ });
187
+
188
+ process.stdout.write(`\n pass=${pass} fail=${fail}\n`);
189
+ return fail === 0 ? 0 : 1;
190
+ }
191
+
192
+ if (require.main === module) {
193
+ process.exit(run());
194
+ }
195
+
196
+ module.exports = { run };