@worca/ui 0.13.0 → 0.14.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.
@@ -19,12 +19,17 @@ import {
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
+ import lockfile from 'proper-lockfile';
22
23
  import { actionAllowed } from '../app/utils/state-actions.js';
24
+ import { atomicWriteSync } from './atomic-write.js';
23
25
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
24
26
  import { dispatchExternal } from './dispatch-external.js';
25
27
  import { ensureWebhookForUi } from './ensure-webhook.js';
28
+ import { extractAndStripGlobalKeys } from './global-keys.js';
29
+ import { LaunchLock } from './launch-lock.js';
26
30
  import { readPreferences } from './preferences.js';
27
31
  import { ProcessManager } from './process-manager.js';
32
+ import { countRunningPipelinesAcrossProjects } from './process-registry.js';
28
33
  import {
29
34
  getMaxProjects,
30
35
  readProjects,
@@ -35,10 +40,12 @@ import {
35
40
  writeProject,
36
41
  } from './project-registry.js';
37
42
  import {
43
+ deepMerge,
38
44
  localPathFor,
39
45
  readLocalSettings,
40
46
  readMergedSettings,
41
47
  } from './settings-merge.js';
48
+ import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
42
49
  import { validateSettingsPayload } from './settings-validator.js';
43
50
  import { isVersionBehind } from './version-check.js';
44
51
  import { getVersionInfo } from './versions.js';
@@ -61,22 +68,11 @@ function validateRunId(runId) {
61
68
  );
62
69
  }
63
70
 
64
- /**
65
- * Find the status.json path for a given run ID.
66
- * Searches: runs/{id}/status.json results/{id}/status.json → results/{id}.json
67
- * Returns the first existing path, or null if none found.
68
- */
69
- export function findRunStatusPath(worcaDir, runId) {
70
- const candidates = [
71
- join(worcaDir, 'runs', runId, 'status.json'),
72
- join(worcaDir, 'results', runId, 'status.json'),
73
- join(worcaDir, 'results', `${runId}.json`),
74
- ];
75
- for (const p of candidates) {
76
- if (existsSync(p)) return p;
77
- }
78
- return null;
79
- }
71
+ // Re-exported from run-dir-resolver so callers (including older tests) can
72
+ // continue importing from project-routes. The implementation now overlays
73
+ // worktree runs registered in <worcaDir>/multi/pipelines.d/.
74
+ import { findRunStatusPath } from './run-dir-resolver.js';
75
+ export { findRunStatusPath };
80
76
 
81
77
  /** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
82
78
  const BRANCH_RE = /^[\w.\-/]+$/;
@@ -139,6 +135,7 @@ export function projectResolver({ prefsDir, projectRoot }) {
139
135
  settingsPath:
140
136
  project.settingsPath ||
141
137
  join(project.path, '.claude', 'settings.json'),
138
+ prefsDir,
142
139
  }),
143
140
  };
144
141
  next();
@@ -323,13 +320,17 @@ export function createProjectRoutes({
323
320
  /**
324
321
  * Router for project-scoped sub-routes.
325
322
  * The projectResolver middleware must run before this to set req.project.
326
- * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
327
- * worca-cc version lookup for /worca-status' `outdated` flag.
323
+ * @param {{ prefsDir?: string|null, launchLock?: LaunchLock }} [options] —
324
+ * prefsDir enables active worca-cc version lookup for /worca-status'
325
+ * `outdated` flag and gates the global max_concurrent_pipelines check.
326
+ * launchLock should be injected by createApp so all routers share the
327
+ * same mutex; falls back to a per-router instance if omitted (tests).
328
328
  */
329
329
  export function createProjectScopedRoutes({
330
330
  prefsDir = null,
331
331
  serverHost,
332
332
  serverPort,
333
+ launchLock = new LaunchLock(),
333
334
  } = {}) {
334
335
  const router = Router({ mergeParams: true });
335
336
  const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
@@ -447,7 +448,7 @@ export function createProjectScopedRoutes({
447
448
  });
448
449
 
449
450
  // POST /api/projects/:projectId/settings
450
- router.post('/settings', (req, res) => {
451
+ router.post('/settings', async (req, res) => {
451
452
  const { settingsPath } = req.project;
452
453
  if (!settingsPath) {
453
454
  return res.status(501).json({
@@ -481,25 +482,87 @@ export function createProjectScopedRoutes({
481
482
  }
482
483
 
483
484
  try {
484
- const lp = localPathFor(settingsPath);
485
- const local = readLocalSettings(settingsPath);
485
+ let baseChanged = false;
486
+ let base = {};
487
+ try {
488
+ if (existsSync(settingsPath)) {
489
+ base = JSON.parse(readFileSync(settingsPath, 'utf8'));
490
+ }
491
+ } catch {
492
+ base = {};
493
+ }
486
494
 
487
495
  if (body.worca && typeof body.worca === 'object') {
488
- if (!local.worca) local.worca = {};
489
- for (const key of Object.keys(body.worca)) {
490
- local.worca[key] = body.worca[key];
496
+ if (!base.worca || typeof base.worca !== 'object') base.worca = {};
497
+ base.worca = deepMerge(base.worca, body.worca);
498
+
499
+ if (
500
+ body.worca.governance &&
501
+ typeof body.worca.governance === 'object' &&
502
+ body.worca.governance.subagent_dispatch !== undefined &&
503
+ base.worca.governance &&
504
+ typeof base.worca.governance === 'object' &&
505
+ 'dispatch' in base.worca.governance
506
+ ) {
507
+ delete base.worca.governance.dispatch;
508
+ }
509
+ baseChanged = true;
510
+ }
511
+
512
+ // STEP 1: extract misplaced global keys + inert milestone keys
513
+ const autoMigrated = extractAndStripGlobalKeys(base);
514
+
515
+ // STEP 2: write extracted global keys to ~/.worca/settings.json
516
+ const globalSettingsPath = prefsDir
517
+ ? join(prefsDir, 'settings.json')
518
+ : null;
519
+
520
+ if (
521
+ globalSettingsPath &&
522
+ Object.keys(autoMigrated.globalExtracted).length > 0
523
+ ) {
524
+ let release;
525
+ try {
526
+ release = await lockfile.lock(globalSettingsPath, {
527
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
528
+ realpath: false,
529
+ });
530
+ writeGlobalSettings(globalSettingsPath, {
531
+ worca: autoMigrated.globalExtracted,
532
+ });
533
+ } catch (err) {
534
+ return res.status(500).json({
535
+ error: {
536
+ code: 'global_write_error',
537
+ message:
538
+ 'Failed to migrate global keys; project settings not saved.',
539
+ detail: err.message,
540
+ },
541
+ });
542
+ } finally {
543
+ if (release) await release();
491
544
  }
492
545
  }
546
+
547
+ // STEP 3: atomic project write
548
+ if (baseChanged) {
549
+ mkdirSync(dirname(settingsPath), { recursive: true });
550
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
551
+ }
552
+
493
553
  if (body.permissions !== undefined) {
554
+ const lp = localPathFor(settingsPath);
555
+ const local = readLocalSettings(settingsPath);
494
556
  local.permissions = body.permissions;
557
+ mkdirSync(dirname(lp), { recursive: true });
558
+ writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
495
559
  }
496
560
 
497
- writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
498
-
499
561
  const merged = readMergedSettings(settingsPath);
500
562
  res.json({
501
563
  worca: merged.worca || {},
502
564
  permissions: merged.permissions || {},
565
+ autoMigrated,
503
566
  });
504
567
  } catch (err) {
505
568
  res
@@ -540,17 +603,52 @@ export function createProjectScopedRoutes({
540
603
  }
541
604
 
542
605
  try {
606
+ // Mirror the persistence split used in POST: worca-namespace keys live
607
+ // in settings.json; top-level keys (permissions) live in
608
+ // settings.local.json. Reset removes from both as needed and also
609
+ // clears any legacy worca-namespace overrides that may still exist in
610
+ // settings.local.json from before the split.
543
611
  const lp = localPathFor(settingsPath);
544
612
  const local = readLocalSettings(settingsPath);
613
+ let baseChanged = false;
614
+ let base = {};
615
+ try {
616
+ if (existsSync(settingsPath)) {
617
+ base = JSON.parse(readFileSync(settingsPath, 'utf8'));
618
+ }
619
+ } catch {
620
+ base = {};
621
+ }
545
622
 
546
- if (mapping.worca && local.worca) {
547
- for (const key of mapping.worca) delete local.worca[key];
548
- if (Object.keys(local.worca).length === 0) delete local.worca;
623
+ if (mapping.worca) {
624
+ if (base.worca) {
625
+ for (const key of mapping.worca) {
626
+ if (key in base.worca) {
627
+ delete base.worca[key];
628
+ baseChanged = true;
629
+ }
630
+ }
631
+ if (Object.keys(base.worca).length === 0) delete base.worca;
632
+ }
633
+ // Strip any legacy local override at the same paths.
634
+ if (local.worca) {
635
+ for (const key of mapping.worca) delete local.worca[key];
636
+ if (Object.keys(local.worca).length === 0) delete local.worca;
637
+ }
549
638
  }
550
639
  if (mapping.top) {
551
640
  for (const key of mapping.top) delete local[key];
552
641
  }
553
642
 
643
+ if (baseChanged) {
644
+ mkdirSync(dirname(settingsPath), { recursive: true });
645
+ writeFileSync(
646
+ settingsPath,
647
+ `${JSON.stringify(base, null, 2)}\n`,
648
+ 'utf8',
649
+ );
650
+ }
651
+
554
652
  if (Object.keys(local).length === 0) {
555
653
  try {
556
654
  unlinkSync(lp);
@@ -725,26 +823,47 @@ export function createProjectScopedRoutes({
725
823
  ? Math.max(1, Math.min(10, Math.round(Number(mloops))))
726
824
  : 1;
727
825
 
728
- try {
729
- const result = await req.project.pm.startPipeline({
730
- sourceType,
731
- sourceValue: hasSource ? sourceValue : undefined,
732
- prompt: hasPrompt ? prompt : undefined,
733
- msize: msizeVal,
734
- mloops: mloopsVal,
735
- planFile: hasPlan ? planFile.trim() : undefined,
736
- branch: branch || undefined,
737
- template: template || undefined,
738
- });
739
- const { broadcast } = req.app.locals;
740
- if (broadcast) broadcast('run-started', { pid: result.pid });
741
- res.json({ ok: true, pid: result.pid, sourceType, prompt });
742
- } catch (err) {
743
- if (err.code === 'already_running') {
744
- return res.status(409).json({ ok: false, error: err.message });
826
+ // Atomically check global cap and start pipeline under lock
827
+ await launchLock.withLock(async () => {
828
+ if (prefsDir) {
829
+ const globalSettings = readGlobalSettings(
830
+ join(prefsDir, 'settings.json'),
831
+ );
832
+ const cap =
833
+ globalSettings.worca?.parallel?.max_concurrent_pipelines ?? 10;
834
+ const totalRunning = countRunningPipelinesAcrossProjects(prefsDir);
835
+ if (totalRunning >= cap) {
836
+ res.status(409).json({
837
+ ok: false,
838
+ error: `Maximum concurrent pipelines reached (${cap}). Stop a running pipeline or increase the limit in global preferences.`,
839
+ code: 'max_concurrent_exceeded',
840
+ });
841
+ return;
842
+ }
745
843
  }
746
- res.status(500).json({ ok: false, error: err.message });
747
- }
844
+
845
+ try {
846
+ const result = await req.project.pm.startPipeline({
847
+ sourceType,
848
+ sourceValue: hasSource ? sourceValue : undefined,
849
+ prompt: hasPrompt ? prompt : undefined,
850
+ msize: msizeVal,
851
+ mloops: mloopsVal,
852
+ planFile: hasPlan ? planFile.trim() : undefined,
853
+ branch: branch || undefined,
854
+ template: template || undefined,
855
+ });
856
+ const { broadcast } = req.app.locals;
857
+ if (broadcast) broadcast('run-started', { pid: result.pid });
858
+ res.json({ ok: true, pid: result.pid, sourceType, prompt });
859
+ } catch (err) {
860
+ if (err.code === 'already_running') {
861
+ res.status(409).json({ ok: false, error: err.message });
862
+ return;
863
+ }
864
+ res.status(500).json({ ok: false, error: err.message });
865
+ }
866
+ });
748
867
  });
749
868
 
750
869
  // POST /api/projects/:projectId/runs/:id/pause
@@ -986,6 +1105,57 @@ export function createProjectScopedRoutes({
986
1105
  }
987
1106
  });
988
1107
 
1108
+ // POST /api/projects/:projectId/runs/:id/control — generic run control action
1109
+ const VALID_CONTROL_ACTIONS = new Set(['approve', 'reject']);
1110
+ router.post('/runs/:id/control', requireWorcaDir, (req, res) => {
1111
+ const runId = req.params.id;
1112
+ if (!validateRunId(runId)) {
1113
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
1114
+ }
1115
+ const { action, source } = req.body || {};
1116
+ if (!action || !VALID_CONTROL_ACTIONS.has(action)) {
1117
+ return res.status(400).json({
1118
+ ok: false,
1119
+ error: `Invalid action. Must be one of: ${[...VALID_CONTROL_ACTIONS].join(', ')}`,
1120
+ });
1121
+ }
1122
+ const { worcaDir } = req.project;
1123
+ const statusPath = findRunStatusPath(worcaDir, runId);
1124
+ if (!statusPath) {
1125
+ return res
1126
+ .status(404)
1127
+ .json({ ok: false, error: `Run "${runId}" not found` });
1128
+ }
1129
+ try {
1130
+ const st = JSON.parse(readFileSync(statusPath, 'utf8'));
1131
+ if (st.pipeline_status !== 'paused') {
1132
+ return res.status(409).json({
1133
+ ok: false,
1134
+ error: 'Run is not paused',
1135
+ pipeline_status: st.pipeline_status,
1136
+ });
1137
+ }
1138
+ const controlDir = join(worcaDir, 'runs', runId);
1139
+ mkdirSync(controlDir, { recursive: true });
1140
+ writeFileSync(
1141
+ join(controlDir, 'control.json'),
1142
+ `${JSON.stringify(
1143
+ {
1144
+ action,
1145
+ requested_at: new Date().toISOString(),
1146
+ source: source || 'ui',
1147
+ },
1148
+ null,
1149
+ 2,
1150
+ )}\n`,
1151
+ 'utf8',
1152
+ );
1153
+ res.json({ ok: true, action, runId });
1154
+ } catch (err) {
1155
+ res.status(500).json({ ok: false, error: err.message });
1156
+ }
1157
+ });
1158
+
989
1159
  // POST /api/projects/:projectId/runs/:id/archive
990
1160
  router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
991
1161
  const runId = req.params.id;
@@ -1250,100 +1420,10 @@ export function createProjectScopedRoutes({
1250
1420
  res.json({ ok: true, runId, pid: child.pid });
1251
1421
  });
1252
1422
 
1253
- // POST /api/projects/:projectId/pipelines/:runId/stop stop a worktree pipeline
1254
- router.post('/pipelines/:runId/stop', requireWorcaDir, (req, res) => {
1255
- const runId = req.params.runId;
1256
- if (!validateRunId(runId)) {
1257
- return res.status(400).json({ ok: false, error: 'Invalid runId' });
1258
- }
1259
- const { worcaDir } = req.project;
1260
-
1261
- const pipelineFile = join(
1262
- worcaDir,
1263
- 'multi',
1264
- 'pipelines.d',
1265
- `${runId}.json`,
1266
- );
1267
- if (!existsSync(pipelineFile)) {
1268
- return res
1269
- .status(404)
1270
- .json({ ok: false, error: `Pipeline ${runId} not found` });
1271
- }
1272
-
1273
- let pipeline;
1274
- try {
1275
- pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
1276
- } catch {
1277
- return res
1278
- .status(500)
1279
- .json({ ok: false, error: 'Failed to read pipeline registry' });
1280
- }
1281
-
1282
- if (!pipeline.worktree_path) {
1283
- return res
1284
- .status(400)
1285
- .json({ ok: false, error: 'Pipeline has no worktree path' });
1286
- }
1287
-
1288
- const worktreePm = new ProcessManager({
1289
- worcaDir: join(pipeline.worktree_path, '.worca'),
1290
- });
1291
- try {
1292
- const result = worktreePm.stopPipeline(runId);
1293
- res.json({ ok: true, stopped: true, runId, pid: result.pid });
1294
- } catch (err) {
1295
- if (err.code === 'not_running') {
1296
- return res.status(404).json({ ok: false, error: err.message });
1297
- }
1298
- res.status(500).json({ ok: false, error: err.message });
1299
- }
1300
- });
1301
-
1302
- // POST /api/projects/:projectId/pipelines/:runId/pause — pause a worktree pipeline
1303
- router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
1304
- const runId = req.params.runId;
1305
- if (!validateRunId(runId)) {
1306
- return res.status(400).json({ ok: false, error: 'Invalid runId' });
1307
- }
1308
- const { worcaDir } = req.project;
1309
-
1310
- const pipelineFile = join(
1311
- worcaDir,
1312
- 'multi',
1313
- 'pipelines.d',
1314
- `${runId}.json`,
1315
- );
1316
- if (!existsSync(pipelineFile)) {
1317
- return res
1318
- .status(404)
1319
- .json({ ok: false, error: `Pipeline ${runId} not found` });
1320
- }
1321
-
1322
- let pipeline;
1323
- try {
1324
- pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
1325
- } catch {
1326
- return res
1327
- .status(500)
1328
- .json({ ok: false, error: 'Failed to read pipeline registry' });
1329
- }
1330
-
1331
- if (!pipeline.worktree_path) {
1332
- return res
1333
- .status(400)
1334
- .json({ ok: false, error: 'Pipeline has no worktree path' });
1335
- }
1336
-
1337
- const worktreePm = new ProcessManager({
1338
- worcaDir: join(pipeline.worktree_path, '.worca'),
1339
- });
1340
- try {
1341
- const result = worktreePm.pausePipeline(runId);
1342
- res.json({ ok: true, ...result });
1343
- } catch (err) {
1344
- res.status(500).json({ ok: false, error: err.message });
1345
- }
1346
- });
1423
+ // NOTE: The /pipelines/:runId/{stop,pause} routes were removed in favor of
1424
+ // unifying on /runs/:id/{stop,pause,cancel,resume}. The ProcessManager now
1425
+ // overlays worktree pipelines via pipelines.d/, so the same /runs/:id/*
1426
+ // family handles both local and worktree-hosted runs.
1347
1427
 
1348
1428
  // GET /api/projects/:projectId/templates — list available pipeline templates
1349
1429
  router.get('/templates', (req, res) => {
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Resolve a runId to its on-disk run directory and status file path.
3
+ *
4
+ * Worktree runs live under `<worktree_path>/.worca/runs/<runId>/`, but they
5
+ * are registered in the parent project's `<worcaDir>/multi/pipelines.d/<runId>.json`.
6
+ * Callers that only know the parent worcaDir + runId need this overlay to
7
+ * find the actual run files. Local (non-worktree) runs continue to live in
8
+ * `<worcaDir>/runs/<runId>/` or `<worcaDir>/results/<runId>/`.
9
+ *
10
+ * Resolution order:
11
+ * 1. `<worcaDir>/runs/<runId>/` (local active)
12
+ * 2. `<worcaDir>/results/<runId>/` (local archived)
13
+ * 3. `<worcaDir>/multi/pipelines.d/<runId>.json` → `<worktree_path>/.worca/runs/<runId>/`
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ /**
20
+ * Resolve a runId to its on-disk run directory.
21
+ * @param {string} worcaDir - the parent project's .worca directory
22
+ * @param {string} runId
23
+ * @returns {string|null} absolute path to the run dir, or null if not found
24
+ */
25
+ export function resolveRunDir(worcaDir, runId) {
26
+ if (!worcaDir || !runId) return null;
27
+
28
+ const localRunDir = join(worcaDir, 'runs', runId);
29
+ if (existsSync(localRunDir)) return localRunDir;
30
+
31
+ const localResultsDir = join(worcaDir, 'results', runId);
32
+ if (existsSync(localResultsDir)) return localResultsDir;
33
+
34
+ const overlay = readPipelineOverlay(worcaDir, runId);
35
+ if (overlay) {
36
+ const wtRunDir = join(overlay.worktree_path, '.worca', 'runs', runId);
37
+ if (existsSync(wtRunDir)) return wtRunDir;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Resolve a runId to its status.json path.
45
+ * @param {string} worcaDir
46
+ * @param {string} runId
47
+ * @returns {string|null} absolute path to status.json, or null
48
+ */
49
+ export function findRunStatusPath(worcaDir, runId) {
50
+ const runDir = resolveRunDir(worcaDir, runId);
51
+ if (runDir) {
52
+ const sp = join(runDir, 'status.json');
53
+ if (existsSync(sp)) return sp;
54
+ }
55
+
56
+ // Legacy file format: results/<id>.json
57
+ const legacyPath = join(worcaDir, 'results', `${runId}.json`);
58
+ if (existsSync(legacyPath)) return legacyPath;
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Read the worktree overlay for a runId, if registered.
65
+ * @param {string} worcaDir
66
+ * @param {string} runId
67
+ * @returns {{ run_id: string, worktree_path: string, pid?: number } | null}
68
+ */
69
+ export function readPipelineOverlay(worcaDir, runId) {
70
+ const regPath = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
71
+ if (!existsSync(regPath)) return null;
72
+ try {
73
+ const data = JSON.parse(readFileSync(regPath, 'utf8'));
74
+ if (!data || typeof data.worktree_path !== 'string') return null;
75
+ return data;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
@@ -1,4 +1,7 @@
1
- import { readMergedSettings } from './settings-merge.js';
1
+ import { readFileSync } from 'node:fs';
2
+ import { atomicWriteSync } from './atomic-write.js';
3
+ import { GLOBAL_DEFAULTS } from './keys-schema.js';
4
+ import { deepMerge, readMergedSettings } from './settings-merge.js';
2
5
 
3
6
  export function readSettings(path) {
4
7
  try {
@@ -21,3 +24,30 @@ export function readSettings(path) {
21
24
  };
22
25
  }
23
26
  }
27
+
28
+ export function readGlobalSettings(globalSettingsPath) {
29
+ let raw = {};
30
+ try {
31
+ raw = JSON.parse(readFileSync(globalSettingsPath, 'utf-8'));
32
+ } catch (err) {
33
+ if (err.code === 'ENOENT') {
34
+ // First-run: file doesn't exist yet — return defaults
35
+ } else if (err instanceof SyntaxError) {
36
+ console.error(
37
+ `Invalid JSON in ${globalSettingsPath}: ${err.message}; falling back to defaults`,
38
+ );
39
+ } else {
40
+ throw err;
41
+ }
42
+ }
43
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) raw = {};
44
+ raw.worca = deepMerge(GLOBAL_DEFAULTS, raw.worca || {});
45
+ return raw;
46
+ }
47
+
48
+ export function writeGlobalSettings(globalSettingsPath, partial) {
49
+ const existing = readGlobalSettings(globalSettingsPath);
50
+ const merged = deepMerge(existing, partial);
51
+ atomicWriteSync(globalSettingsPath, `${JSON.stringify(merged, null, 2)}\n`);
52
+ return merged;
53
+ }