changeledger 0.3.0 → 0.5.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.
@@ -1,7 +1,20 @@
1
- import { getGitRefs, getProjects, getRepo, postStatus, searchAllProjects } from './api.js';
1
+ import {
2
+ getGitRefs,
3
+ getProjectConfig,
4
+ getProjects,
5
+ getRepo,
6
+ postProjectConfig,
7
+ postProjectPath,
8
+ postProjectRemove,
9
+ postStatus,
10
+ searchAllProjects,
11
+ } from './api.js';
2
12
  import {
3
13
  clearStatusFilters,
14
+ initializeProjects,
4
15
  invalidateCache,
16
+ normalizeRepoState,
17
+ restoreViewerState,
5
18
  selectProject,
6
19
  setOwnerFilter,
7
20
  setRepo,
@@ -47,8 +60,9 @@ const $ = (sel) => document.querySelector(sel);
47
60
  initMermaid();
48
61
 
49
62
  async function loadProjects() {
50
- const { projects, current } = await getProjects();
63
+ const { projects, current, localOnly } = await getProjects();
51
64
  state.projectsList = projects;
65
+ state.localOnly = localOnly;
52
66
  const sel = $('#project');
53
67
  litRender(
54
68
  projects.map(
@@ -57,7 +71,7 @@ async function loadProjects() {
57
71
  ),
58
72
  sel,
59
73
  );
60
- state.currentProject = current ?? projects.find((p) => p.alive)?.id ?? null;
74
+ initializeProjects(projects, current);
61
75
  if (state.currentProject) sel.value = state.currentProject;
62
76
  sel.style.display = projects.length > 1 ? '' : 'none';
63
77
  await load();
@@ -65,23 +79,34 @@ async function loadProjects() {
65
79
 
66
80
  async function load() {
67
81
  if (!state.currentProject) {
68
- litRender(
69
- html`<p class="empty" style="padding:20px">No projects registered. Run <code>changeledger init</code> in a repo.</p>`,
70
- $('#board'),
71
- );
82
+ if (state.currentView === 'projects') {
83
+ syncViewerShell();
84
+ return;
85
+ }
86
+ showNoProjects();
72
87
  return;
73
88
  }
74
89
  try {
75
90
  const text = await getRepo(state.currentProject);
76
91
  if (text === state.lastJson) return;
77
92
  setRepo(text);
93
+ normalizeRepoState(state.repo);
78
94
  hydrateFilters();
79
- render();
95
+ syncViewerShell();
80
96
  } catch (e) {
81
97
  litRender(html`<p style="color:var(--bug);padding:20px">${e.message}</p>`, $('#board'));
82
98
  }
83
99
  }
84
100
 
101
+ export function showNoProjects(root = document) {
102
+ setView('board');
103
+ litRender(
104
+ html`<p class="empty" style="padding:20px">No projects registered. Run <code>changeledger init</code> in a repo.</p>`,
105
+ root.querySelector('#board'),
106
+ );
107
+ syncViewerShell(root, false);
108
+ }
109
+
85
110
  // Rebuilt on each project load (types/statuses can differ per project).
86
111
  function hydrateFilters() {
87
112
  litRender(
@@ -176,6 +201,7 @@ function render() {
176
201
  else if (state.currentView === 'table') renderTable();
177
202
  else if (state.currentView === 'specs') renderSpecs();
178
203
  else if (state.currentView === 'metrics') renderMetrics();
204
+ else if (state.currentView === 'projects') renderProjects();
179
205
  else renderBoard();
180
206
  }
181
207
 
@@ -569,19 +595,279 @@ function openSpec(s) {
569
595
  );
570
596
  const overlay = $('#overlay');
571
597
  overlay.classList.remove('hidden');
572
- $('#detail').querySelector('.close').onclick = closeDetail;
598
+ const detail = $('#detail');
599
+ detail.querySelector('.close').onclick = closeDetail;
573
600
  overlay.onclick = (e) => {
574
601
  if (e.target === overlay) closeDetail();
575
602
  };
576
- renderExpandableMermaid($('#detail'));
603
+ detail.onclick = (e) => handleSpecBodyClick(e, (href) => openSpecByName(href, state, openSpec));
604
+ renderExpandableMermaid(detail);
577
605
  }
578
606
 
579
- const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics'];
607
+ /**
608
+ * Normalizes a spec href and opens the matching spec.
609
+ * Exported for testing: accepts `repoState` and `_openSpec` to avoid DOM coupling.
610
+ */
611
+ export function openSpecByName(href, repoState = state, _openSpec = openSpec) {
612
+ const name = href.replace(/^\.\//, '').replace(/\.md$/, '');
613
+ const found = (repoState.repo?.specs ?? []).find((s) => s.name.replace(/\.md$/, '') === name);
614
+ if (found) _openSpec(found);
615
+ }
616
+
617
+ /**
618
+ * Click handler for the spec body container.
619
+ * Exported for testing: accepts `_openSpecByName` callback to avoid DOM coupling.
620
+ * Intercepts relative *.md links only; lets external links through unchanged.
621
+ */
622
+ export function handleSpecBodyClick(event, _openSpecByName) {
623
+ const anchor = event.target.closest('a');
624
+ if (!anchor) return;
625
+ const href = anchor.getAttribute('href');
626
+ if (!href) return;
627
+ // Let external links (with scheme or absolute path) through unchanged.
628
+ if (/^[a-z][a-z\d+\-.]*:/i.test(href) || href.startsWith('/')) return;
629
+ // Only intercept relative *.md links.
630
+ if (!href.endsWith('.md')) return;
631
+ event.preventDefault();
632
+ _openSpecByName(href);
633
+ }
634
+
635
+ const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
580
636
 
581
637
  function renderMetrics() {
582
638
  litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
583
639
  }
584
640
 
641
+ export function syncViewerShell(root = document, renderContent = true) {
642
+ root.querySelector('#search').value = state.filters.text;
643
+ root.querySelector('#toggle-global').classList.toggle('active', state.globalMode);
644
+ for (const name of VIEWS) {
645
+ root.querySelector(`#view-${name}`).classList.toggle('active', name === state.currentView);
646
+ root
647
+ .querySelector(`#${name}`)
648
+ .classList.toggle('hidden', state.globalMode || name !== state.currentView);
649
+ }
650
+ root.querySelector('#global').classList.toggle('hidden', !state.globalMode);
651
+ if (!renderContent) return;
652
+ if (state.globalMode) renderGlobal();
653
+ else render();
654
+ }
655
+
656
+ export function restoreInitialViewerShell(root = document, getStorage = () => window.localStorage) {
657
+ let browserStorage = null;
658
+ try {
659
+ browserStorage = getStorage();
660
+ } catch {
661
+ // Storage access itself may be forbidden (opaque origins/privacy policy).
662
+ }
663
+ restoreViewerState(browserStorage);
664
+ syncViewerShell(root, false);
665
+ }
666
+
667
+ let managedProject = null;
668
+ let managedConfig = null;
669
+
670
+ export function projectsViewTemplate(projects, selected, config, localOnly) {
671
+ const project = projects.find((item) => item.id === selected);
672
+ return html`<div class="projects-shell">
673
+ <div class="projects-list">
674
+ <div class="projects-heading">
675
+ <div><span class="eyebrow">Registry</span><h1>Projects</h1></div>
676
+ <span class="count">${projects.length}</span>
677
+ </div>
678
+ ${
679
+ projects.length
680
+ ? html`<div class="project-rows">${projects.map(
681
+ (
682
+ item,
683
+ ) => html`<button type="button" class=${`project-row${item.id === selected ? ' active' : ''}`} data-manage-project=${item.id}>
684
+ <span class=${`health-dot ${item.alive ? 'available' : 'missing'}`} aria-hidden="true"></span>
685
+ <span class="project-summary"><strong>${item.name}</strong><small>${item.path}</small></span>
686
+ <span class="mono project-id">${item.id}</span>
687
+ <span class=${`project-health ${item.alive ? 'available' : 'missing'}`}>${item.alive ? 'Available' : 'Missing'}</span>
688
+ </button>`,
689
+ )}</div>`
690
+ : html`<p class="empty">No projects registered.</p>`
691
+ }
692
+ </div>
693
+ <div class="project-editor">
694
+ ${
695
+ !project
696
+ ? html`<div class="project-placeholder"><span class="eyebrow">Configuration</span><h2>Select a project</h2><p>Inspect its registry entry and edit its ChangeLedger configuration.</p></div>`
697
+ : html`<div class="project-editor-head">
698
+ <div><span class="eyebrow">${project.id}</span><h2>${project.name}</h2><p>${project.path}</p></div>
699
+ <span class=${`project-health ${project.alive ? 'available' : 'missing'}`}>${project.alive ? 'Available' : 'Missing'}</span>
700
+ </div>
701
+ ${
702
+ !localOnly
703
+ ? html`<form class="project-path-form">
704
+ <label for="project-path">Registered path</label>
705
+ <div><input id="project-path" name="path" .value=${project.path} /><button class="button secondary" type="submit">Repair path</button></div>
706
+ </form>`
707
+ : nothing
708
+ }
709
+ ${
710
+ project.alive
711
+ ? config?.loading
712
+ ? html`<p class="empty">Loading configuration…</p>`
713
+ : html`<form class="config-form">
714
+ <div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
715
+ <textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
716
+ <p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
717
+ <div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
718
+ </form>`
719
+ : html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
720
+ }
721
+ ${
722
+ !localOnly
723
+ ? html`<div class="danger-zone"><div><strong>Unregister project</strong><p>Removes this local registry entry. Repository files are never deleted.</p></div><button type="button" class="button danger" data-unregister>Unregister</button></div>`
724
+ : nothing
725
+ }`
726
+ }
727
+ </div>
728
+ </div>`;
729
+ }
730
+
731
+ async function openManagedProject(id, { reload = false } = {}) {
732
+ managedProject = id;
733
+ const project = state.projectsList.find((item) => item.id === id);
734
+ if (!project?.alive) {
735
+ managedConfig = null;
736
+ renderProjects();
737
+ return;
738
+ }
739
+ if (!reload && managedConfig?.id === id && !managedConfig.error) {
740
+ renderProjects();
741
+ return;
742
+ }
743
+ managedConfig = { id, loading: true };
744
+ renderProjects();
745
+ try {
746
+ managedConfig = { id, ...(await getProjectConfig(id)) };
747
+ } catch (error) {
748
+ managedConfig = { id, content: '', revision: '', error: error.message };
749
+ }
750
+ renderProjects();
751
+ }
752
+
753
+ export function setProjectFormPending(root, pending) {
754
+ root.querySelectorAll('button, input, textarea').forEach((control) => {
755
+ control.disabled = pending;
756
+ });
757
+ root.classList.toggle('is-pending', pending);
758
+ }
759
+
760
+ export async function projectMutation(root, request, onSuccess) {
761
+ setProjectFormPending(root, true);
762
+ const error = root.querySelector('.project-error');
763
+ if (error) error.hidden = true;
764
+ try {
765
+ const response = await request();
766
+ const body = await response.json();
767
+ if (!response.ok) throw new Error(body.error || 'Project update failed.');
768
+ await onSuccess(body);
769
+ } catch (failure) {
770
+ if (error) {
771
+ error.textContent = failure.message;
772
+ error.hidden = false;
773
+ } else alert(failure.message);
774
+ } finally {
775
+ setProjectFormPending(root, false);
776
+ }
777
+ }
778
+
779
+ export function requestUnregisterConfirmation(project, ask = prompt) {
780
+ return ask(
781
+ `Type "${project.name}" to unregister this project. No repository files will be deleted.`,
782
+ );
783
+ }
784
+
785
+ async function refreshProjectRegistry() {
786
+ const { projects, current, localOnly } = await getProjects();
787
+ state.localOnly = localOnly;
788
+ initializeProjects(projects, current);
789
+ const select = $('#project');
790
+ litRender(
791
+ projects.map(
792
+ (item) =>
793
+ html`<option value=${item.id} ?disabled=${!item.alive}>${item.name}${item.alive ? '' : ' (missing)'}</option>`,
794
+ ),
795
+ select,
796
+ );
797
+ if (state.currentProject) select.value = state.currentProject;
798
+ select.style.display = projects.length > 1 ? '' : 'none';
799
+ }
800
+
801
+ function renderProjects() {
802
+ const root = $('#projects');
803
+ litRender(
804
+ projectsViewTemplate(state.projectsList, managedProject, managedConfig, state.localOnly),
805
+ root,
806
+ );
807
+ bindProjectViewActions(root, {
808
+ select: (id) => openManagedProject(id),
809
+ reload: () => openManagedProject(managedProject, { reload: true }),
810
+ save: (content, configForm) =>
811
+ projectMutation(
812
+ configForm,
813
+ () => postProjectConfig(managedProject, content, managedConfig.revision),
814
+ async (body) => {
815
+ managedConfig = { id: managedProject, content, revision: body.revision };
816
+ await refreshProjectRegistry();
817
+ renderProjects();
818
+ },
819
+ ),
820
+ repair: (projectPath, pathForm) =>
821
+ projectMutation(
822
+ pathForm,
823
+ () => postProjectPath(managedProject, projectPath),
824
+ async () => {
825
+ await refreshProjectRegistry();
826
+ await openManagedProject(managedProject, { reload: true });
827
+ },
828
+ ),
829
+ unregister: (editor) => {
830
+ const project = state.projectsList.find((item) => item.id === managedProject);
831
+ const confirm = requestUnregisterConfirmation(project);
832
+ if (confirm === null) return;
833
+ projectMutation(
834
+ editor,
835
+ () => postProjectRemove(managedProject, confirm),
836
+ async () => {
837
+ managedProject = null;
838
+ managedConfig = null;
839
+ await refreshProjectRegistry();
840
+ if (state.currentProject) await load();
841
+ renderProjects();
842
+ },
843
+ );
844
+ },
845
+ });
846
+ }
847
+
848
+ export function bindProjectViewActions(root, handlers) {
849
+ root.querySelectorAll('[data-manage-project]').forEach((button) => {
850
+ button.onclick = () => handlers.select(button.dataset.manageProject);
851
+ });
852
+ const reload = root.querySelector('[data-reload-config]');
853
+ if (reload) reload.onclick = () => handlers.reload();
854
+ const configForm = root.querySelector('.config-form');
855
+ if (configForm)
856
+ configForm.onsubmit = (event) => {
857
+ event.preventDefault();
858
+ handlers.save(configForm.querySelector('textarea').value, configForm);
859
+ };
860
+ const pathForm = root.querySelector('.project-path-form');
861
+ if (pathForm)
862
+ pathForm.onsubmit = (event) => {
863
+ event.preventDefault();
864
+ handlers.repair(pathForm.elements.path.value, pathForm);
865
+ };
866
+ const unregister = root.querySelector('[data-unregister]');
867
+ if (unregister)
868
+ unregister.onclick = () => handlers.unregister(root.querySelector('.project-editor'));
869
+ }
870
+
585
871
  function activateView(v) {
586
872
  setView(v);
587
873
  $('#toggle-global').classList.remove('active');
@@ -660,6 +946,7 @@ const fmtDate = (iso) => {
660
946
  // Wire the DOM and start polling. Guarded below so importing this module (tests)
661
947
  // has no side effects; only a real browser page bootstraps.
662
948
  function bootstrap() {
949
+ restoreInitialViewerShell();
663
950
  diagramLightbox = createDiagramLightbox({
664
951
  overlay: $('#diagram-overlay'),
665
952
  canvas: $('#diagram-canvas'),
@@ -692,6 +979,7 @@ function bootstrap() {
692
979
  $('#view-graph').onclick = () => activateView('graph');
693
980
  $('#view-specs').onclick = () => activateView('specs');
694
981
  $('#view-metrics').onclick = () => activateView('metrics');
982
+ $('#view-projects').onclick = () => activateView('projects');
695
983
  $('#project').onchange = (e) => {
696
984
  selectProject(e.target.value);
697
985
  load();
@@ -36,6 +36,7 @@
36
36
  <button type="button" id="view-graph" class="tab">Graph</button>
37
37
  <button type="button" id="view-specs" class="tab">Specs</button>
38
38
  <button type="button" id="view-metrics" class="tab">Metrics</button>
39
+ <button type="button" id="view-projects" class="tab">Projects</button>
39
40
  </div>
40
41
  </header>
41
42
 
@@ -45,6 +46,7 @@
45
46
  <section id="specs" class="specs-view hidden"></section>
46
47
  <section id="global" class="global-view hidden"></section>
47
48
  <section id="metrics" class="metrics-view hidden"></section>
49
+ <section id="projects" class="projects-view hidden"></section>
48
50
 
49
51
  <div id="overlay" class="overlay hidden">
50
52
  <div id="detail" class="detail"></div>
@@ -90,6 +90,208 @@ body {
90
90
  gap: 4px;
91
91
  }
92
92
 
93
+ /* Project management */
94
+ .projects-view {
95
+ padding: 18px;
96
+ }
97
+ .projects-shell {
98
+ display: grid;
99
+ grid-template-columns: minmax(320px, 0.8fr) minmax(460px, 1.2fr);
100
+ gap: 16px;
101
+ max-width: 1500px;
102
+ margin: 0 auto;
103
+ }
104
+ .projects-list,
105
+ .project-editor {
106
+ min-width: 0;
107
+ border: 1px solid var(--line);
108
+ border-radius: 14px;
109
+ background: var(--panel);
110
+ }
111
+ .projects-heading,
112
+ .project-editor-head,
113
+ .danger-zone {
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: space-between;
117
+ gap: 16px;
118
+ }
119
+ .projects-heading,
120
+ .project-editor-head {
121
+ padding: 18px 20px;
122
+ border-bottom: 1px solid var(--line);
123
+ }
124
+ .projects-heading h1,
125
+ .project-editor-head h2,
126
+ .project-placeholder h2 {
127
+ margin: 2px 0 0;
128
+ }
129
+ .project-editor-head p,
130
+ .project-placeholder p,
131
+ .danger-zone p,
132
+ .missing-config p {
133
+ margin: 4px 0 0;
134
+ color: var(--muted);
135
+ }
136
+ .eyebrow {
137
+ color: var(--accent);
138
+ font-size: 10px;
139
+ font-weight: 700;
140
+ letter-spacing: 0.12em;
141
+ text-transform: uppercase;
142
+ }
143
+ .project-rows {
144
+ padding: 8px;
145
+ }
146
+ .project-row {
147
+ width: 100%;
148
+ display: grid;
149
+ grid-template-columns: 10px minmax(0, 1fr) auto auto;
150
+ align-items: center;
151
+ gap: 10px;
152
+ padding: 11px 12px;
153
+ border: 1px solid transparent;
154
+ border-radius: 9px;
155
+ background: transparent;
156
+ color: var(--text);
157
+ text-align: left;
158
+ cursor: pointer;
159
+ }
160
+ .project-row:hover,
161
+ .project-row.active {
162
+ border-color: var(--line);
163
+ background: var(--panel-2);
164
+ }
165
+ .health-dot {
166
+ width: 8px;
167
+ height: 8px;
168
+ border-radius: 50%;
169
+ background: var(--muted);
170
+ }
171
+ .health-dot.available {
172
+ background: var(--done);
173
+ }
174
+ .health-dot.missing {
175
+ background: var(--blocked);
176
+ }
177
+ .project-summary {
178
+ min-width: 0;
179
+ display: grid;
180
+ }
181
+ .project-summary small {
182
+ overflow: hidden;
183
+ color: var(--muted);
184
+ text-overflow: ellipsis;
185
+ white-space: nowrap;
186
+ }
187
+ .project-health {
188
+ padding: 3px 8px;
189
+ border-radius: 999px;
190
+ font-size: 10px;
191
+ font-weight: 700;
192
+ text-transform: uppercase;
193
+ }
194
+ .project-health.available {
195
+ color: var(--done);
196
+ background: rgba(63, 185, 80, 0.1);
197
+ }
198
+ .project-health.missing {
199
+ color: var(--blocked);
200
+ background: rgba(248, 81, 73, 0.1);
201
+ }
202
+ .project-placeholder,
203
+ .missing-config {
204
+ padding: 28px 20px;
205
+ }
206
+ .project-path-form,
207
+ .config-form {
208
+ padding: 16px 20px;
209
+ border-bottom: 1px solid var(--line);
210
+ }
211
+ .project-path-form label,
212
+ .config-label label {
213
+ color: var(--muted);
214
+ font-size: 11px;
215
+ font-weight: 600;
216
+ }
217
+ .project-path-form > div {
218
+ display: flex;
219
+ gap: 8px;
220
+ margin-top: 7px;
221
+ }
222
+ .project-path-form input {
223
+ flex: 1;
224
+ min-width: 0;
225
+ padding: 8px 10px;
226
+ border: 1px solid var(--line);
227
+ border-radius: 8px;
228
+ background: var(--bg);
229
+ color: var(--text);
230
+ }
231
+ .config-label {
232
+ display: flex;
233
+ justify-content: space-between;
234
+ margin-bottom: 7px;
235
+ }
236
+ .config-form textarea {
237
+ width: 100%;
238
+ min-height: 430px;
239
+ resize: vertical;
240
+ padding: 14px;
241
+ border: 1px solid var(--line);
242
+ border-radius: 9px;
243
+ background: #0b0d11;
244
+ color: #d9e1ea;
245
+ font:
246
+ 12px / 1.55 ui-monospace,
247
+ monospace;
248
+ tab-size: 2;
249
+ }
250
+ .project-actions {
251
+ display: flex;
252
+ justify-content: flex-end;
253
+ margin-top: 10px;
254
+ }
255
+ .project-error {
256
+ margin: 8px 0 0;
257
+ color: #ff8078;
258
+ font-size: 12px;
259
+ }
260
+ .text-button {
261
+ border: 0;
262
+ background: transparent;
263
+ color: var(--accent);
264
+ cursor: pointer;
265
+ }
266
+ .danger-zone {
267
+ margin: 20px;
268
+ padding: 14px;
269
+ border: 1px solid rgba(248, 81, 73, 0.35);
270
+ border-radius: 10px;
271
+ }
272
+ .danger-zone p {
273
+ font-size: 12px;
274
+ }
275
+ .button.danger {
276
+ border-color: var(--bug);
277
+ color: #ff8078;
278
+ }
279
+ .is-pending {
280
+ opacity: 0.65;
281
+ cursor: wait;
282
+ }
283
+ @media (max-width: 900px) {
284
+ .projects-shell {
285
+ grid-template-columns: 1fr;
286
+ }
287
+ .project-row {
288
+ grid-template-columns: 10px minmax(0, 1fr) auto;
289
+ }
290
+ .project-id {
291
+ display: none;
292
+ }
293
+ }
294
+
93
295
  .chip {
94
296
  font-size: 12px;
95
297
  padding: 5px 10px;
@@ -4,7 +4,16 @@ import path from 'node:path';
4
4
  import { gitRefs } from '../../git.mjs';
5
5
  import { publicDir } from '../../paths.mjs';
6
6
  import { loadRepoAsync } from '../../repo.mjs';
7
- import { changeStatus, resolveProjects, searchProjects, serialize } from '../domain.mjs';
7
+ import {
8
+ changeStatus,
9
+ readProjectConfig,
10
+ repairProjectPath,
11
+ resolveProjects,
12
+ saveProjectConfig,
13
+ searchProjects,
14
+ serialize,
15
+ unregisterProject,
16
+ } from '../domain.mjs';
8
17
  import { isAuthorizedWrite, isLocalHost } from './security.mjs';
9
18
 
10
19
  const require = createRequire(import.meta.url);
@@ -78,7 +87,12 @@ export function createRequestListener(cwd, localOnly, token) {
78
87
  const route = url.pathname;
79
88
  const params = url.searchParams;
80
89
 
81
- if (req.method === 'POST' && route === '/api/status') {
90
+ if (
91
+ req.method === 'POST' &&
92
+ ['/api/status', '/api/project-config', '/api/project-path', '/api/project-remove'].includes(
93
+ route,
94
+ )
95
+ ) {
82
96
  if (!isAuthorizedWrite(req, token)) {
83
97
  send(res, 403, MIME['.json'], JSON.stringify({ error: 'unauthorized write' }));
84
98
  req.destroy();
@@ -97,22 +111,47 @@ export function createRequestListener(cwd, localOnly, token) {
97
111
  });
98
112
  req.on('end', () => {
99
113
  if (aborted) return;
100
- let payload;
101
114
  try {
102
- payload = JSON.parse(raw || '{}');
103
- } catch {
104
- send(res, 400, MIME['.json'], JSON.stringify({ error: 'invalid JSON body' }));
105
- return;
115
+ let payload;
116
+ try {
117
+ payload = JSON.parse(raw || '{}');
118
+ } catch {
119
+ send(res, 400, MIME['.json'], JSON.stringify({ error: 'invalid JSON body' }));
120
+ return;
121
+ }
122
+ const { projects } = resolveProjects(cwd, localOnly);
123
+ const options = { localOnly };
124
+ const result =
125
+ route === '/api/status'
126
+ ? changeStatus(projects, payload)
127
+ : route === '/api/project-config'
128
+ ? saveProjectConfig(projects, payload, options)
129
+ : route === '/api/project-path'
130
+ ? repairProjectPath(projects, payload, options)
131
+ : unregisterProject(projects, payload, options);
132
+ const { code, body } = result;
133
+ send(res, code, MIME['.json'], JSON.stringify(body));
134
+ } catch (error) {
135
+ process.stderr.write(`[changeledger-viewer] ${error.message}\n`);
136
+ send(res, 500, MIME['.json'], JSON.stringify({ error: 'Internal server error' }));
106
137
  }
107
- const { projects } = resolveProjects(cwd, localOnly);
108
- const { code, body } = changeStatus(projects, payload);
109
- send(res, code, MIME['.json'], JSON.stringify(body));
110
138
  });
111
139
  return;
112
140
  }
113
141
 
114
142
  if (route === '/api/projects') {
115
- send(res, 200, MIME['.json'], JSON.stringify(resolveProjects(cwd, localOnly)));
143
+ send(
144
+ res,
145
+ 200,
146
+ MIME['.json'],
147
+ JSON.stringify({ ...resolveProjects(cwd, localOnly), localOnly }),
148
+ );
149
+ return;
150
+ }
151
+ if (route === '/api/project-config') {
152
+ const { projects } = resolveProjects(cwd, localOnly);
153
+ const { code, body } = readProjectConfig(projects, params.get('project'));
154
+ send(res, code, MIME['.json'], JSON.stringify(body));
116
155
  return;
117
156
  }
118
157
  if (route === '/api/git') {