@zlnap/astrolabe 0.0.0-stage → 0.17.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.
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const REMIND_CMD = 'astrolabe hooks remind --client=codebuddy';
7
+ const CLEANUP_CMD = 'astrolabe hooks cleanup';
8
+
9
+ function resolveCodebuddyMcpPath(home, existsFn) {
10
+ const candidates = [
11
+ path.join(home, '.codebuddy', '.mcp.json'),
12
+ path.join(home, '.codebuddy', 'mcp.json'),
13
+ path.join(home, '.codebuddy.json'),
14
+ ];
15
+ for (const cand of candidates) {
16
+ if (existsFn(cand)) return cand;
17
+ }
18
+ return candidates[0];
19
+ }
20
+
21
+ function detectCodebuddy(home, env, { whichFn, existsFn }) {
22
+ const evidence = [];
23
+ if (whichFn('codebuddy', env)) evidence.push('codebuddy on PATH');
24
+ if (existsFn(path.join(home, '.codebuddy'))) evidence.push('~/.codebuddy/');
25
+ if (existsFn(path.join(home, '.codebuddy.json'))) evidence.push('~/.codebuddy.json');
26
+ return { detected: evidence.length > 0, evidence };
27
+ }
28
+
29
+ function commandHookEntry(command) {
30
+ return {
31
+ matcher: '',
32
+ hooks: [{ type: 'command', command }],
33
+ };
34
+ }
35
+
36
+ function entryHasAstrolabeCommand(entry, needle) {
37
+ if (!entry || typeof entry !== 'object') return false;
38
+ const hooks = Array.isArray(entry.hooks) ? entry.hooks : [];
39
+ return hooks.some(
40
+ (h) => h && typeof h.command === 'string' && h.command.includes(needle),
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Merge CC-compatible PreToolUse + SessionEnd into ~/.codebuddy/settings.json
46
+ * without clobbering other hook entries.
47
+ */
48
+ function mergeCodebuddyHooksSettings(filePath, { create = true, existsFn = fs.existsSync } = {}) {
49
+ let created = false;
50
+ let doc = {};
51
+ if (existsFn(filePath)) {
52
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
53
+ doc = JSON.parse(raw);
54
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
55
+ throw new Error(`${filePath} root must be a JSON object`);
56
+ }
57
+ } else if (!create) {
58
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
59
+ } else {
60
+ created = true;
61
+ }
62
+
63
+ if (!doc.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
64
+ doc.hooks = {};
65
+ }
66
+ const hooks = doc.hooks;
67
+ let replaced = false;
68
+
69
+ for (const [event, command, needle] of [
70
+ ['PreToolUse', REMIND_CMD, 'astrolabe hooks remind --client=codebuddy'],
71
+ ['SessionEnd', CLEANUP_CMD, 'astrolabe hooks cleanup'],
72
+ ]) {
73
+ if (!Array.isArray(hooks[event])) hooks[event] = [];
74
+ const idx = hooks[event].findIndex((e) => entryHasAstrolabeCommand(e, needle));
75
+ const next = commandHookEntry(command);
76
+ if (idx >= 0) {
77
+ hooks[event][idx] = next;
78
+ replaced = true;
79
+ } else {
80
+ hooks[event].push(next);
81
+ }
82
+ }
83
+
84
+ return { filePath, doc, created, replaced };
85
+ }
86
+
87
+ module.exports = {
88
+ REMIND_CMD,
89
+ CLEANUP_CMD,
90
+ resolveCodebuddyMcpPath,
91
+ detectCodebuddy,
92
+ mergeCodebuddyHooksSettings,
93
+ };
@@ -0,0 +1,137 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const MANAGED_BEGIN = '# >>> astrolabe-managed-dsh-guard >>>';
7
+ const MANAGED_END = '# <<< astrolabe-managed-dsh-guard <<<';
8
+
9
+ const MANAGED_BLOCK = `${MANAGED_BEGIN}
10
+ - insert:
11
+ - id: astrolabe-guard
12
+ name: 'astrolabe-dsh-guard'
13
+ config:
14
+ client: claude-code
15
+ ${MANAGED_END}
16
+ `;
17
+
18
+ function resolveDshPluginSource(existsFn, startDir = __dirname) {
19
+ const vendor = path.join(startDir, 'vendor', 'astrolabe-dsh-guard');
20
+ if (existsFn(path.join(vendor, 'package.json'))) return vendor;
21
+
22
+ let dir = startDir;
23
+ for (let i = 0; i < 10; i += 1) {
24
+ const cand = path.join(dir, 'dsh-plugin');
25
+ if (existsFn(path.join(cand, 'package.json'))) return cand;
26
+ const parent = path.dirname(dir);
27
+ if (parent === dir) break;
28
+ dir = parent;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Prefer hoisted ~/.dsh/profiles/node_modules (seen on macOS installs), else
35
+ * each profiles/<name>/node_modules, else create the hoisted path.
36
+ */
37
+ function resolveDshPluginInstallDirs(home, existsFn) {
38
+ const profiles = path.join(home, '.dsh', 'profiles');
39
+ const dirs = [];
40
+ const hoisted = path.join(profiles, 'node_modules');
41
+ if (existsFn(hoisted) || existsFn(profiles)) {
42
+ dirs.push(path.join(hoisted, 'astrolabe-dsh-guard'));
43
+ }
44
+ if (existsFn(profiles)) {
45
+ let entries = [];
46
+ try {
47
+ entries = fs.readdirSync(profiles, { withFileTypes: true });
48
+ } catch {
49
+ entries = [];
50
+ }
51
+ for (const ent of entries) {
52
+ if (!ent.isDirectory()) continue;
53
+ if (ent.name === 'node_modules') continue;
54
+ const nm = path.join(profiles, ent.name, 'node_modules');
55
+ // Only install into a profile that already has node_modules or a package.json
56
+ // (active profile), so we do not invent empty profile trees.
57
+ if (existsFn(nm) || existsFn(path.join(profiles, ent.name, 'package.json'))) {
58
+ const target = path.join(nm, 'astrolabe-dsh-guard');
59
+ if (!dirs.includes(target)) dirs.push(target);
60
+ }
61
+ }
62
+ }
63
+ if (!dirs.length) {
64
+ dirs.push(path.join(home, '.dsh', 'profiles', 'node_modules', 'astrolabe-dsh-guard'));
65
+ }
66
+ // Dedupe while preferring hoisted first
67
+ return [...new Set(dirs)];
68
+ }
69
+
70
+ function copyDirRecursive(src, dest) {
71
+ fs.mkdirSync(dest, { recursive: true });
72
+ for (const ent of fs.readdirSync(src, { withFileTypes: true })) {
73
+ const from = path.join(src, ent.name);
74
+ const to = path.join(dest, ent.name);
75
+ if (ent.isDirectory()) copyDirRecursive(from, to);
76
+ else fs.copyFileSync(from, to);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Merge the managed astrolabe-guard insert into ~/.dsh/cordis.patch.yml without
82
+ * touching the orca-managed-dsh-hooks block (or any other content).
83
+ */
84
+ function mergeDshCordisPatch(filePath, { create = true, existsFn = fs.existsSync } = {}) {
85
+ let created = false;
86
+ let text = '';
87
+ if (existsFn(filePath)) {
88
+ text = fs.readFileSync(filePath, 'utf8');
89
+ } else if (!create) {
90
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
91
+ } else {
92
+ created = true;
93
+ text = '';
94
+ }
95
+
96
+ const begin = text.indexOf(MANAGED_BEGIN);
97
+ const end = text.indexOf(MANAGED_END);
98
+ let next;
99
+ let replaced = false;
100
+ if (begin >= 0 && end > begin) {
101
+ replaced = true;
102
+ const afterEnd = end + MANAGED_END.length;
103
+ let tail = text.slice(afterEnd);
104
+ if (tail.startsWith('\n')) tail = tail.slice(1);
105
+ next = `${text.slice(0, begin)}${MANAGED_BLOCK}${tail}`;
106
+ } else if (begin >= 0 || end >= 0) {
107
+ throw new Error(
108
+ `${filePath} has a broken astrolabe-managed-dsh-guard marker; fix or remove it, then re-run setup`,
109
+ );
110
+ } else {
111
+ const trimmed = text.replace(/\s*$/, '');
112
+ next = trimmed ? `${trimmed}\n\n${MANAGED_BLOCK}` : `${MANAGED_BLOCK}`;
113
+ }
114
+ if (!next.endsWith('\n')) next += '\n';
115
+ return { filePath, text: next, created, replaced };
116
+ }
117
+
118
+ function detectDsh(home, env, { whichFn, existsFn }) {
119
+ const evidence = [];
120
+ if (whichFn('dsh', env)) evidence.push('dsh on PATH');
121
+ if (existsFn(path.join(home, '.dsh'))) evidence.push('~/.dsh/');
122
+ if (existsFn(path.join(home, '.dsh', 'cordis.patch.yml'))) {
123
+ evidence.push('~/.dsh/cordis.patch.yml');
124
+ }
125
+ return { detected: evidence.length > 0, evidence };
126
+ }
127
+
128
+ module.exports = {
129
+ MANAGED_BEGIN,
130
+ MANAGED_END,
131
+ MANAGED_BLOCK,
132
+ resolveDshPluginSource,
133
+ resolveDshPluginInstallDirs,
134
+ copyDirRecursive,
135
+ mergeDshCordisPatch,
136
+ detectDsh,
137
+ };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const GROK_HOOKS_DOC = {
7
+ hooks: {
8
+ PreToolUse: [
9
+ {
10
+ matcher: 'Bash|Read|Grep|run_terminal_command|read_file|grep|mcp__astrolabe__.*',
11
+ hooks: [
12
+ {
13
+ type: 'command',
14
+ command: 'astrolabe hooks remind --client=grok',
15
+ timeout: 10,
16
+ },
17
+ ],
18
+ },
19
+ ],
20
+ SessionEnd: [
21
+ {
22
+ matcher: '',
23
+ hooks: [
24
+ {
25
+ type: 'command',
26
+ command: 'astrolabe hooks cleanup',
27
+ timeout: 10,
28
+ },
29
+ ],
30
+ },
31
+ ],
32
+ },
33
+ };
34
+
35
+ function detectGrok(home, env, { whichFn, existsFn }) {
36
+ const evidence = [];
37
+ if (whichFn('grok', env)) evidence.push('grok on PATH');
38
+ if (existsFn(path.join(home, '.grok'))) evidence.push('~/.grok/');
39
+ if (existsFn(path.join(home, '.grok', 'config.toml'))) evidence.push('~/.grok/config.toml');
40
+ return { detected: evidence.length > 0, evidence };
41
+ }
42
+
43
+ function resolveGrokConfigToml(home) {
44
+ return path.join(home, '.grok', 'config.toml');
45
+ }
46
+
47
+ function resolveGrokHooksPath(home) {
48
+ return path.join(home, '.grok', 'hooks', 'astrolabe.json');
49
+ }
50
+
51
+ /**
52
+ * Write/replace the dedicated Astrolabe hooks file (merge root.hooks keys only
53
+ * when an existing file already has unrelated top-level fields).
54
+ */
55
+ function mergeGrokHooksFile(filePath, { create = true, existsFn = fs.existsSync } = {}) {
56
+ let created = false;
57
+ let doc = {};
58
+ if (existsFn(filePath)) {
59
+ const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
60
+ doc = JSON.parse(raw);
61
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
62
+ throw new Error(`${filePath} root must be a JSON object`);
63
+ }
64
+ } else if (!create) {
65
+ return { skipped: true, reason: `missing ${filePath} (will not create)` };
66
+ } else {
67
+ created = true;
68
+ }
69
+
70
+ const replaced = Boolean(doc.hooks);
71
+ doc.hooks = GROK_HOOKS_DOC.hooks;
72
+ return { filePath, doc, created, replaced, entryPreview: JSON.stringify(GROK_HOOKS_DOC, null, 2) };
73
+ }
74
+
75
+ module.exports = {
76
+ GROK_HOOKS_DOC,
77
+ detectGrok,
78
+ resolveGrokConfigToml,
79
+ resolveGrokHooksPath,
80
+ mergeGrokHooksFile,
81
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+
3
+ const { run } = require('./run');
4
+ const { CLIENTS, resolveLaunch, mergeJsonMcpFile, mergeCodexToml, mergeCursorPermissions } = require('./clients');
5
+ const { parseArgs, USAGE } = require('./args');
6
+
7
+ module.exports = {
8
+ run,
9
+ CLIENTS,
10
+ resolveLaunch,
11
+ mergeJsonMcpFile,
12
+ mergeCodexToml,
13
+ mergeCursorPermissions,
14
+ parseArgs,
15
+ USAGE,
16
+ };
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal JSONC helpers for merge-only edits that preserve surrounding comments.
5
+ * Not a full JSONC parser: only brace-aware object-property upsert.
6
+ */
7
+
8
+ function stripJsonc(raw) {
9
+ let out = '';
10
+ let i = 0;
11
+ let inStr = false;
12
+ let esc = false;
13
+ while (i < raw.length) {
14
+ const c = raw[i];
15
+ if (inStr) {
16
+ out += c;
17
+ if (esc) esc = false;
18
+ else if (c === '\\') esc = true;
19
+ else if (c === '"') inStr = false;
20
+ i += 1;
21
+ continue;
22
+ }
23
+ if (c === '"') {
24
+ inStr = true;
25
+ out += c;
26
+ i += 1;
27
+ continue;
28
+ }
29
+ if (c === '/' && raw[i + 1] === '/') {
30
+ i += 2;
31
+ while (i < raw.length && raw[i] !== '\n') i += 1;
32
+ continue;
33
+ }
34
+ if (c === '/' && raw[i + 1] === '*') {
35
+ i += 2;
36
+ while (i + 1 < raw.length && !(raw[i] === '*' && raw[i + 1] === '/')) i += 1;
37
+ i += 2;
38
+ continue;
39
+ }
40
+ out += c;
41
+ i += 1;
42
+ }
43
+ return out;
44
+ }
45
+
46
+ function parseJsonc(raw) {
47
+ return JSON.parse(stripJsonc(raw.replace(/^\uFEFF/, '')));
48
+ }
49
+
50
+ function findMatchingBrace(text, openIdx) {
51
+ if (openIdx < 0 || text[openIdx] !== '{') return -1;
52
+ let depth = 0;
53
+ let inStr = false;
54
+ let esc = false;
55
+ for (let i = openIdx; i < text.length; i += 1) {
56
+ const c = text[i];
57
+ if (inStr) {
58
+ if (esc) {
59
+ esc = false;
60
+ continue;
61
+ }
62
+ if (c === '\\') {
63
+ esc = true;
64
+ continue;
65
+ }
66
+ if (c === '"') inStr = false;
67
+ continue;
68
+ }
69
+ if (c === '"') {
70
+ inStr = true;
71
+ continue;
72
+ }
73
+ if (c === '{') depth += 1;
74
+ else if (c === '}') {
75
+ depth -= 1;
76
+ if (depth === 0) return i;
77
+ }
78
+ }
79
+ return -1;
80
+ }
81
+
82
+ /**
83
+ * Find the `{` that opens the object at a dotted path of string keys from root.
84
+ * Returns index of `{` or -1.
85
+ */
86
+ function findObjectOpen(text, pathKeys) {
87
+ const stripped = stripJsonc(text);
88
+ // Walk on stripped text to find offsets, then map... actually comments shift
89
+ // offsets. Walk the original with a comment-aware scanner instead.
90
+ let pos = text.search(/\{/);
91
+ if (pos < 0) return -1;
92
+ // Root object open:
93
+ // skip BOM/whitespace/comments to first {
94
+ pos = indexOfFirstBrace(text);
95
+ if (pos < 0) return -1;
96
+
97
+ for (const key of pathKeys) {
98
+ const prop = findPropertyValueStart(text, pos, key);
99
+ if (prop < 0) return -1;
100
+ // skip whitespace to value
101
+ let i = prop;
102
+ while (i < text.length && /\s/.test(text[i])) i += 1;
103
+ if (text[i] !== '{') return -1;
104
+ pos = i;
105
+ }
106
+ return pos;
107
+ }
108
+
109
+ function indexOfFirstBrace(text) {
110
+ let i = 0;
111
+ let inStr = false;
112
+ let esc = false;
113
+ while (i < text.length) {
114
+ const c = text[i];
115
+ if (inStr) {
116
+ if (esc) esc = false;
117
+ else if (c === '\\') esc = true;
118
+ else if (c === '"') inStr = false;
119
+ i += 1;
120
+ continue;
121
+ }
122
+ if (c === '"') {
123
+ inStr = true;
124
+ i += 1;
125
+ continue;
126
+ }
127
+ if (c === '/' && text[i + 1] === '/') {
128
+ i += 2;
129
+ while (i < text.length && text[i] !== '\n') i += 1;
130
+ continue;
131
+ }
132
+ if (c === '/' && text[i + 1] === '*') {
133
+ i += 2;
134
+ while (i + 1 < text.length && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
135
+ i += 2;
136
+ continue;
137
+ }
138
+ if (c === '{') return i;
139
+ i += 1;
140
+ }
141
+ return -1;
142
+ }
143
+
144
+ /**
145
+ * Inside object starting at openIdx, find the start index of the value for "key".
146
+ */
147
+ function findPropertyValueStart(text, openIdx, key) {
148
+ const end = findMatchingBrace(text, openIdx);
149
+ if (end < 0) return -1;
150
+ const needle = `"${key}"`;
151
+ let i = openIdx + 1;
152
+ let inStr = false;
153
+ let esc = false;
154
+ let depth = 0; // relative depth inside arrays/objects under this object
155
+ while (i < end) {
156
+ const c = text[i];
157
+ if (inStr) {
158
+ if (esc) esc = false;
159
+ else if (c === '\\') esc = true;
160
+ else if (c === '"') inStr = false;
161
+ i += 1;
162
+ continue;
163
+ }
164
+ if (c === '"') {
165
+ if (depth === 0 && text.startsWith(needle, i)) {
166
+ let j = i + needle.length;
167
+ while (j < end && /\s/.test(text[j])) j += 1;
168
+ if (text[j] === ':') {
169
+ j += 1;
170
+ while (j < end && /\s/.test(text[j])) j += 1;
171
+ return j;
172
+ }
173
+ }
174
+ inStr = true;
175
+ i += 1;
176
+ continue;
177
+ }
178
+ if (c === '/' && text[i + 1] === '/') {
179
+ i += 2;
180
+ while (i < end && text[i] !== '\n') i += 1;
181
+ continue;
182
+ }
183
+ if (c === '/' && text[i + 1] === '*') {
184
+ i += 2;
185
+ while (i + 1 < end && !(text[i] === '*' && text[i + 1] === '/')) i += 1;
186
+ i += 2;
187
+ continue;
188
+ }
189
+ if (c === '{' || c === '[') depth += 1;
190
+ else if (c === '}' || c === ']') depth -= 1;
191
+ i += 1;
192
+ }
193
+ return -1;
194
+ }
195
+
196
+ function skipJsonValue(text, start) {
197
+ let i = start;
198
+ while (i < text.length && /\s/.test(text[i])) i += 1;
199
+ if (i >= text.length) return start;
200
+ const c = text[i];
201
+ if (c === '{') {
202
+ const end = findMatchingBrace(text, i);
203
+ return end < 0 ? start : end + 1;
204
+ }
205
+ if (c === '[') {
206
+ let depth = 0;
207
+ let inStr = false;
208
+ let esc = false;
209
+ for (let j = i; j < text.length; j += 1) {
210
+ const ch = text[j];
211
+ if (inStr) {
212
+ if (esc) esc = false;
213
+ else if (ch === '\\') esc = true;
214
+ else if (ch === '"') inStr = false;
215
+ continue;
216
+ }
217
+ if (ch === '"') {
218
+ inStr = true;
219
+ continue;
220
+ }
221
+ if (ch === '[') depth += 1;
222
+ else if (ch === ']') {
223
+ depth -= 1;
224
+ if (depth === 0) return j + 1;
225
+ }
226
+ }
227
+ return start;
228
+ }
229
+ if (c === '"') {
230
+ let esc = false;
231
+ for (let j = i + 1; j < text.length; j += 1) {
232
+ const ch = text[j];
233
+ if (esc) {
234
+ esc = false;
235
+ continue;
236
+ }
237
+ if (ch === '\\') {
238
+ esc = true;
239
+ continue;
240
+ }
241
+ if (ch === '"') return j + 1;
242
+ }
243
+ return start;
244
+ }
245
+ // number / literal
246
+ while (i < text.length && /[^\s,\}\]]/.test(text[i])) i += 1;
247
+ return i;
248
+ }
249
+
250
+ /**
251
+ * Upsert key: valueObject into the object at parentPath inside raw JSONC.
252
+ * valueObject is a plain JS object; serialized with JSON.stringify (2-space).
253
+ * Returns { text, replaced, createdParent }.
254
+ */
255
+ function upsertJsoncProperty(raw, parentPath, key, valueObject, { indent = 2 } = {}) {
256
+ let text = raw.replace(/^\uFEFF/, '');
257
+ if (!text.trim()) {
258
+ text = '{\n}\n';
259
+ }
260
+
261
+ // Ensure parent path exists (create missing parents as empty objects via parse+rewrite fallback)
262
+ let parentOpen = findObjectOpen(text, parentPath);
263
+ let createdParent = false;
264
+ if (parentOpen < 0) {
265
+ // Fall back: parse, ensure path, re-serialize whole doc (comments lost only when parent missing)
266
+ let doc;
267
+ try {
268
+ doc = parseJsonc(text);
269
+ } catch {
270
+ doc = {};
271
+ }
272
+ if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) doc = {};
273
+ let cursor = doc;
274
+ for (const p of parentPath) {
275
+ if (!cursor[p] || typeof cursor[p] !== 'object' || Array.isArray(cursor[p])) {
276
+ cursor[p] = {};
277
+ createdParent = true;
278
+ }
279
+ cursor = cursor[p];
280
+ }
281
+ cursor[key] = valueObject;
282
+ return {
283
+ text: `${JSON.stringify(doc, null, indent)}\n`,
284
+ replaced: false,
285
+ createdParent: true,
286
+ commentsPreserved: false,
287
+ };
288
+ }
289
+
290
+ const valueStart = findPropertyValueStart(text, parentOpen, key);
291
+ const pretty = JSON.stringify(valueObject, null, indent);
292
+ // Reindent relative to parent: detect parent indent
293
+ const lineStart = text.lastIndexOf('\n', parentOpen) + 1;
294
+ const parentIndent = text.slice(lineStart, parentOpen).match(/^\s*/)[0];
295
+ const childIndent = parentIndent + ' '.repeat(indent);
296
+ const indentedValue = pretty
297
+ .split('\n')
298
+ .map((line, idx) => (idx === 0 ? line : childIndent + line))
299
+ .join('\n');
300
+
301
+ if (valueStart >= 0) {
302
+ const valueEnd = skipJsonValue(text, valueStart);
303
+ const next = `${text.slice(0, valueStart)}${indentedValue}${text.slice(valueEnd)}`;
304
+ return { text: next, replaced: true, createdParent, commentsPreserved: true };
305
+ }
306
+
307
+ // Insert before closing brace of parent
308
+ const parentEnd = findMatchingBrace(text, parentOpen);
309
+ if (parentEnd < 0) {
310
+ throw new Error('unterminated JSON object while merging');
311
+ }
312
+ // Look at content between open and close for trailing comma needs
313
+ const inner = text.slice(parentOpen + 1, parentEnd);
314
+ const innerTrim = inner.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '').trim();
315
+ const needsComma = innerTrim.length > 0 && !innerTrim.endsWith(',');
316
+ const insertion = `${needsComma ? ',' : ''}\n${childIndent}"${key}": ${indentedValue}\n${parentIndent}`;
317
+ // If inner ends with whitespace before }, place insertion cleanly
318
+ let insertAt = parentEnd;
319
+ // trim trailing whitespace inside parent before insert
320
+ while (insertAt > parentOpen + 1 && /[ \t]/.test(text[insertAt - 1])) insertAt -= 1;
321
+ if (text[insertAt - 1] === '\n') {
322
+ // keep
323
+ }
324
+ const next = `${text.slice(0, insertAt)}${insertion}${text.slice(parentEnd)}`;
325
+ return { text: next, replaced: false, createdParent, commentsPreserved: true };
326
+ }
327
+
328
+ module.exports = {
329
+ stripJsonc,
330
+ parseJsonc,
331
+ findMatchingBrace,
332
+ findObjectOpen,
333
+ upsertJsoncProperty,
334
+ };