changeledger 0.3.0 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "changeledger",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Turn conversations into buildable changes.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/check.mjs CHANGED
@@ -15,9 +15,9 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
15
15
 
16
16
  checkConfig(config, (c, message) => err(c ?? { name: '.changeledger/config.yml' }, message));
17
17
 
18
- const statuses = config.statuses ?? [];
19
- const types = config.types ?? {};
20
- const canonical = config.stages ?? [];
18
+ const statuses = Array.isArray(config.statuses) ? config.statuses : [];
19
+ const types = isMapping(config.types) ? config.types : {};
20
+ const canonical = Array.isArray(config.stages) ? config.stages : [];
21
21
 
22
22
  // Scope: a single change (fast, post-write check) or the whole repo.
23
23
  let targets = changes;
@@ -64,7 +64,8 @@ export function checkRepo({ config, changes, specs = [], releases = [] }, opts =
64
64
  const ordered = [...known].sort((a, b) => canonical.indexOf(a) - canonical.indexOf(b));
65
65
  if (known.join(',') !== ordered.join(',')) err(c, 'stages are out of canonical order');
66
66
 
67
- const active = types[fm.type]?.stages;
67
+ const typeDefinition = isMapping(types[fm.type]) ? types[fm.type] : null;
68
+ const active = Array.isArray(typeDefinition?.stages) ? typeDefinition.stages : null;
68
69
  if (active) {
69
70
  for (const k of active)
70
71
  if (!present.includes(k)) err(c, `missing active stage "## ${k}" for type ${fm.type}`);
@@ -365,9 +366,20 @@ function misplacedVerificationSuffix(task, config) {
365
366
  }
366
367
 
367
368
  function readinessConfig(config) {
369
+ const readiness = isMapping(config?.readiness) ? config.readiness : null;
368
370
  return {
369
- target_patterns: config?.readiness?.target_patterns ?? ['src/**'],
370
- verification_patterns: config?.readiness?.verification_patterns ?? ['test/**'],
371
+ target_patterns:
372
+ readiness && 'target_patterns' in readiness
373
+ ? Array.isArray(readiness.target_patterns)
374
+ ? readiness.target_patterns
375
+ : []
376
+ : ['src/**'],
377
+ verification_patterns:
378
+ readiness && 'verification_patterns' in readiness
379
+ ? Array.isArray(readiness.verification_patterns)
380
+ ? readiness.verification_patterns
381
+ : []
382
+ : ['test/**'],
371
383
  };
372
384
  }
373
385
 
@@ -445,11 +457,21 @@ function checkConfig(config, err) {
445
457
  }
446
458
  if ('statuses' in c && !Array.isArray(c.statuses)) err(null, 'config "statuses" must be a list');
447
459
  if ('stages' in c && !Array.isArray(c.stages)) err(null, 'config "stages" must be a list');
460
+ if ('types' in c && !isMapping(c.types)) err(null, 'config "types" must be a mapping');
448
461
  if ('readiness' in c) checkReadinessConfig(c.readiness, err);
449
- if ('release' in c) checkReleaseConfig(c.release, c.types ?? {}, err);
462
+ const configuredTypes = isMapping(c.types) ? c.types : {};
463
+ if ('release' in c) checkReleaseConfig(c.release, configuredTypes, err);
450
464
  const canonical = Array.isArray(c.stages) ? c.stages : [];
451
- for (const [type, def] of Object.entries(c.types ?? {})) {
452
- for (const s of def?.stages ?? []) {
465
+ for (const [type, def] of Object.entries(configuredTypes)) {
466
+ if (!isMapping(def)) {
467
+ err(null, `config type "${type}" must be a mapping`);
468
+ continue;
469
+ }
470
+ if (!Array.isArray(def.stages)) {
471
+ err(null, `config type "${type}": stages must be a list`);
472
+ continue;
473
+ }
474
+ for (const s of def.stages) {
453
475
  if (!canonical.includes(s))
454
476
  err(null, `config type "${type}" references unknown stage "${s}"`);
455
477
  }
@@ -458,6 +480,10 @@ function checkConfig(config, err) {
458
480
  }
459
481
  }
460
482
 
483
+ function isMapping(value) {
484
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
485
+ }
486
+
461
487
  function checkReleaseConfig(release, types, err) {
462
488
  if (!release || typeof release !== 'object' || Array.isArray(release)) {
463
489
  err(null, 'config "release" must be a mapping');
@@ -5,7 +5,16 @@ import { resolveProjects } from '../viewer/domain.mjs';
5
5
  import { createRequestListener, staticFile } from '../viewer/server/router.mjs';
6
6
  import { hostnameOf, isAuthorizedWrite, isLocalHost } from '../viewer/server/security.mjs';
7
7
 
8
- export { changeStatus, resolveProjects, searchProjects, serialize } from '../viewer/domain.mjs';
8
+ export {
9
+ changeStatus,
10
+ readProjectConfig,
11
+ repairProjectPath,
12
+ resolveProjects,
13
+ saveProjectConfig,
14
+ searchProjects,
15
+ serialize,
16
+ unregisterProject,
17
+ } from '../viewer/domain.mjs';
9
18
  export { createRequestListener, hostnameOf, isAuthorizedWrite, isLocalHost, staticFile };
10
19
 
11
20
  export async function view(args = [], cwd = process.cwd()) {
package/src/config.mjs CHANGED
@@ -2,13 +2,16 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from './yaml.mjs';
4
4
 
5
- // Walk up from `start` looking for a `.changeledger/` directory. Returns its absolute path
6
- // or null if none is found.
5
+ // Walk up from `start` looking for a project `.changeledger/config.yml`. The
6
+ // config file is the marker: `~/.changeledger/` may exist only as global state
7
+ // and must not be mistaken for a repository (notably when Windows temp dirs
8
+ // live below the user's home). Returns the project data directory or null.
7
9
  export function findChangeledgerDir(start = process.cwd()) {
8
10
  let dir = path.resolve(start);
9
11
  for (;;) {
10
12
  const candidate = path.join(dir, '.changeledger');
11
- if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
13
+ const config = path.join(candidate, 'config.yml');
14
+ if (fs.existsSync(config) && fs.statSync(config).isFile()) return candidate;
12
15
  const parent = path.dirname(dir);
13
16
  if (parent === dir) return null;
14
17
  dir = parent;
package/src/registry.mjs CHANGED
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { withFileLock, writeFileAtomic } from './atomic-write.mjs';
10
+ import { loadConfig } from './config.mjs';
10
11
 
11
12
  export function registryDir() {
12
13
  return path.join(process.env.CHANGELEDGER_HOME || os.homedir(), '.changeledger');
@@ -42,7 +43,18 @@ export function register({ id, name, path: repoPath }) {
42
43
  }
43
44
 
44
45
  export function listProjects() {
45
- return Object.entries(readRegistry()).map(([id, v]) => ({ id, name: v.name, path: v.path }));
46
+ return Object.entries(readRegistry()).map(([id, value]) => {
47
+ let name = value.name;
48
+ try {
49
+ const config = loadConfig(path.join(value.path, '.changeledger'));
50
+ if (String(config.project_id) === id && typeof config.project_name === 'string') {
51
+ name = config.project_name;
52
+ }
53
+ } catch {
54
+ // Missing projects keep their last registered display name.
55
+ }
56
+ return { id, name, path: value.path };
57
+ });
46
58
  }
47
59
 
48
60
  export function remove(id) {
@@ -53,3 +65,14 @@ export function remove(id) {
53
65
  writeRegistry(reg);
54
66
  });
55
67
  }
68
+
69
+ export function update(id, values) {
70
+ fs.mkdirSync(registryDir(), { recursive: true });
71
+ return withFileLock(registryPath(), () => {
72
+ const reg = readRegistry();
73
+ if (!reg[id]) throw new Error(`no registered project "${id}"`);
74
+ reg[id] = { ...reg[id], ...values };
75
+ writeRegistry(reg);
76
+ return reg[id];
77
+ });
78
+ }
package/src/repo.mjs CHANGED
@@ -47,6 +47,13 @@ export function loadRepo(start = process.cwd()) {
47
47
  }
48
48
  const repoRoot = path.dirname(changeledgerDir);
49
49
  const config = loadConfig(changeledgerDir);
50
+ return loadRepoWithConfig(repoRoot, changeledgerDir, config);
51
+ }
52
+
53
+ // Loads repository content using an already parsed candidate config. The viewer
54
+ // uses this before replacing config.yml so changes to configured directories are
55
+ // validated against the content they would actually expose after the save.
56
+ export function loadRepoWithConfig(repoRoot, changeledgerDir, config) {
50
57
  const changesDir = resolveRepoPath(repoRoot, config.changes_dir, 'changes_dir');
51
58
 
52
59
  const changes = [];
@@ -1,11 +1,15 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { mutateFileAtomic } from '../atomic-write.mjs';
5
+ import { checkRepo } from '../check.mjs';
3
6
  import { status as applyStatusCmd, validation as applyValidation } from '../commands/agent.mjs';
4
- import { findChangeledgerDir, loadConfig } from '../config.mjs';
7
+ import { findChangeledgerDir, loadConfig, resolveRepoPath, resolveSpecsDir } from '../config.mjs';
5
8
  import { computeMetrics } from '../metrics.mjs';
6
9
  import { nowUtc } from '../paths.mjs';
7
- import { listProjects } from '../registry.mjs';
8
- import { loadRepo } from '../repo.mjs';
10
+ import { listProjects, remove, update } from '../registry.mjs';
11
+ import { loadRepo, loadRepoWithConfig } from '../repo.mjs';
12
+ import { parseYaml } from '../yaml.mjs';
9
13
 
10
14
  // Serializes a loaded repo into the flat shape the UI consumes.
11
15
  export function serialize(repo) {
@@ -131,3 +135,132 @@ export function changeStatus(projects, { project, id, status, reason }) {
131
135
  return { code: 400, body: { error: e.message } };
132
136
  }
133
137
  }
138
+
139
+ const revision = (text) => crypto.createHash('sha256').update(text).digest('hex');
140
+
141
+ function projectFor(projects, id) {
142
+ const project = projects.find((item) => item.id === id);
143
+ if (!project) return { code: 404, body: { error: `no project "${id}"` } };
144
+ if (!project.alive) return { code: 410, body: { error: 'project path is gone' } };
145
+ return { project };
146
+ }
147
+
148
+ export function readProjectConfig(projects, id) {
149
+ const found = projectFor(projects, id);
150
+ if (!found.project) return found;
151
+ const file = path.join(found.project.path, '.changeledger', 'config.yml');
152
+ const content = fs.readFileSync(file, 'utf8');
153
+ return { code: 200, body: { content, revision: revision(content) } };
154
+ }
155
+
156
+ export function saveProjectConfig(projects, payload, { mutateConfig = mutateFileAtomic } = {}) {
157
+ const found = projectFor(projects, payload.project);
158
+ if (!found.project) return found;
159
+ if (typeof payload.content !== 'string' || typeof payload.revision !== 'string') {
160
+ return { code: 400, body: { error: 'content and revision are required' } };
161
+ }
162
+
163
+ let candidate;
164
+ try {
165
+ candidate = parseYaml(payload.content);
166
+ } catch (error) {
167
+ return { code: 400, body: { error: error.message } };
168
+ }
169
+ if (String(candidate.project_id ?? '') !== String(found.project.id)) {
170
+ return { code: 400, body: { error: 'project_id cannot be changed from the viewer' } };
171
+ }
172
+
173
+ let repo;
174
+ try {
175
+ repo = loadRepo(found.project.path);
176
+ } catch {
177
+ return { code: 400, body: { error: 'unable to load the current project configuration' } };
178
+ }
179
+ try {
180
+ resolveRepoPath(repo.repoRoot, candidate.changes_dir, 'changes_dir');
181
+ resolveSpecsDir(repo.repoRoot, candidate);
182
+ } catch (error) {
183
+ return { code: 400, body: { error: error.message } };
184
+ }
185
+ let candidateRepo;
186
+ try {
187
+ candidateRepo = loadRepoWithConfig(repo.repoRoot, repo.changeledgerDir, candidate);
188
+ } catch {
189
+ return { code: 400, body: { error: 'candidate configuration cannot load the repository' } };
190
+ }
191
+ let errors;
192
+ try {
193
+ ({ errors } = checkRepo(candidateRepo));
194
+ } catch {
195
+ return {
196
+ code: 400,
197
+ body: { error: 'candidate configuration violates the ChangeLedger contract' },
198
+ };
199
+ }
200
+ if (errors.length) return { code: 400, body: { error: errors[0].message } };
201
+
202
+ const file = path.join(repo.changeledgerDir, 'config.yml');
203
+ const projectName =
204
+ typeof candidate.project_name === 'string' && candidate.project_name.trim()
205
+ ? candidate.project_name
206
+ : found.project.name;
207
+ try {
208
+ mutateConfig(file, (before) => {
209
+ if (revision(before) !== payload.revision) {
210
+ throw new Error('configuration changed on disk; reload before saving');
211
+ }
212
+ return payload.content;
213
+ });
214
+ } catch (error) {
215
+ if (error.message === 'configuration changed on disk; reload before saving') {
216
+ return { code: 409, body: { error: error.message } };
217
+ }
218
+ return { code: 400, body: { error: 'unable to save project configuration' } };
219
+ }
220
+ return {
221
+ code: 200,
222
+ body: { ok: true, name: projectName, revision: revision(payload.content) },
223
+ };
224
+ }
225
+
226
+ export function repairProjectPath(projects, payload, { localOnly = false } = {}) {
227
+ if (localOnly)
228
+ return { code: 403, body: { error: 'registry management is unavailable in local mode' } };
229
+ const project = projects.find((item) => item.id === payload.project);
230
+ if (!project) return { code: 404, body: { error: `no project "${payload.project}"` } };
231
+ if (typeof payload.path !== 'string' || !path.isAbsolute(payload.path)) {
232
+ return { code: 400, body: { error: 'project path must be absolute' } };
233
+ }
234
+ const root = path.resolve(payload.path);
235
+ let config;
236
+ try {
237
+ config = loadConfig(path.join(root, '.changeledger'));
238
+ } catch {
239
+ return { code: 400, body: { error: 'project path is not a ChangeLedger repository' } };
240
+ }
241
+ if (String(config.project_id ?? '') !== String(project.id)) {
242
+ return { code: 400, body: { error: 'project path belongs to a different project_id' } };
243
+ }
244
+ try {
245
+ update(project.id, { name: config.project_name ?? project.name, path: root });
246
+ } catch {
247
+ return { code: 400, body: { error: 'unable to update project registry' } };
248
+ }
249
+ return { code: 200, body: { ok: true } };
250
+ }
251
+
252
+ export function unregisterProject(projects, payload, { localOnly = false } = {}) {
253
+ if (localOnly)
254
+ return { code: 403, body: { error: 'registry management is unavailable in local mode' } };
255
+ const project = projects.find((item) => item.id === payload.project);
256
+ if (!project) return { code: 404, body: { error: `no project "${payload.project}"` } };
257
+ if (payload.confirm !== project.name) {
258
+ return { code: 400, body: { error: `type "${project.name}" to confirm` } };
259
+ }
260
+ try {
261
+ remove(project.id);
262
+ } catch {
263
+ return { code: 400, body: { error: 'unable to update project registry' } };
264
+ }
265
+ return { code: 200, body: { ok: true } };
266
+ }
@@ -1,5 +1,12 @@
1
1
  export const getProjects = () => fetch('/api/projects').then((r) => r.json());
2
2
 
3
+ export const getProjectConfig = (project) =>
4
+ fetch(`/api/project-config?project=${encodeURIComponent(project)}`).then(async (response) => {
5
+ const body = await response.json();
6
+ if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`);
7
+ return body;
8
+ });
9
+
3
10
  export const getRepo = async (project) => {
4
11
  const res = await fetch(`/api/repo?project=${encodeURIComponent(project)}`);
5
12
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -23,3 +30,22 @@ export const postStatus = (project, id, status, reason) =>
23
30
  },
24
31
  body: JSON.stringify({ project, id, status, ...(reason ? { reason } : {}) }),
25
32
  });
33
+
34
+ const postProject = (route, body) =>
35
+ fetch(route, {
36
+ method: 'POST',
37
+ headers: {
38
+ 'Content-Type': 'application/json',
39
+ 'x-changeledger-token': window.__CHANGELEDGER_TOKEN__,
40
+ },
41
+ body: JSON.stringify(body),
42
+ });
43
+
44
+ export const postProjectConfig = (project, content, revision) =>
45
+ postProject('/api/project-config', { project, content, revision });
46
+
47
+ export const postProjectPath = (project, path) =>
48
+ postProject('/api/project-path', { project, path });
49
+
50
+ export const postProjectRemove = (project, confirm) =>
51
+ postProject('/api/project-remove', { project, confirm });
@@ -1,3 +1,17 @@
1
+ export const VIEWER_STATE_KEY = 'changeledger.viewer-state.v1';
2
+
3
+ const VALID_VIEWS = new Set(['board', 'table', 'graph', 'specs', 'metrics', 'projects']);
4
+ const VALID_SORT_KEYS = new Set(['id', 'title', 'type', 'status', 'progress', 'deps']);
5
+ let storage = null;
6
+
7
+ const emptyProjectFilters = () => ({
8
+ type: 'all',
9
+ owner: 'all',
10
+ statuses: [],
11
+ showArchived: false,
12
+ showDiscarded: false,
13
+ });
14
+
1
15
  export const state = {
2
16
  repo: null,
3
17
  lastJson: '',
@@ -9,14 +23,129 @@ export const state = {
9
23
  showArchived: false,
10
24
  showDiscarded: false,
11
25
  },
26
+ projectFilters: {},
12
27
  currentView: 'board',
13
28
  sortKey: 'id',
14
29
  sortDir: 1,
15
30
  currentProject: null,
16
31
  projectsList: [],
32
+ localOnly: false,
17
33
  globalMode: false,
18
34
  };
19
35
 
36
+ function currentProjectFilters() {
37
+ return {
38
+ type: state.filters.type,
39
+ owner: state.filters.owner,
40
+ statuses: [...state.filters.statuses],
41
+ showArchived: state.filters.showArchived,
42
+ showDiscarded: state.filters.showDiscarded,
43
+ };
44
+ }
45
+
46
+ function saveCurrentProjectFilters() {
47
+ if (state.currentProject) state.projectFilters[state.currentProject] = currentProjectFilters();
48
+ }
49
+
50
+ function applyProjectFilters(id) {
51
+ const filters = state.projectFilters[id] ?? emptyProjectFilters();
52
+ state.filters.type = typeof filters.type === 'string' ? filters.type : 'all';
53
+ state.filters.owner = typeof filters.owner === 'string' ? filters.owner : 'all';
54
+ state.filters.statuses = new Set(
55
+ Array.isArray(filters.statuses)
56
+ ? filters.statuses.filter((value) => typeof value === 'string')
57
+ : [],
58
+ );
59
+ state.filters.showArchived = filters.showArchived === true;
60
+ state.filters.showDiscarded = filters.showDiscarded === true;
61
+ }
62
+
63
+ export function serializeViewerState() {
64
+ saveCurrentProjectFilters();
65
+ return {
66
+ version: 1,
67
+ currentProject: state.currentProject,
68
+ currentView: state.currentView,
69
+ globalMode: state.globalMode,
70
+ text: state.filters.text,
71
+ sortKey: state.sortKey,
72
+ sortDir: state.sortDir,
73
+ projects: state.projectFilters,
74
+ };
75
+ }
76
+
77
+ export function persistViewerState() {
78
+ if (!storage) return false;
79
+ try {
80
+ storage.setItem(VIEWER_STATE_KEY, JSON.stringify(serializeViewerState()));
81
+ return true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ export function restoreViewerState(storageLike) {
88
+ storage = storageLike;
89
+ let snapshot;
90
+ try {
91
+ snapshot = JSON.parse(storage.getItem(VIEWER_STATE_KEY) || 'null');
92
+ } catch {
93
+ return false;
94
+ }
95
+ if (snapshot?.version !== 1 || typeof snapshot !== 'object') return false;
96
+ if (typeof snapshot.currentProject === 'string') state.currentProject = snapshot.currentProject;
97
+ if (typeof snapshot.currentView === 'string') state.currentView = snapshot.currentView;
98
+ state.globalMode = snapshot.globalMode === true;
99
+ if (typeof snapshot.text === 'string') state.filters.text = snapshot.text;
100
+ if (typeof snapshot.sortKey === 'string') state.sortKey = snapshot.sortKey;
101
+ if (snapshot.sortDir === 1 || snapshot.sortDir === -1) state.sortDir = snapshot.sortDir;
102
+ if (
103
+ snapshot.projects &&
104
+ typeof snapshot.projects === 'object' &&
105
+ !Array.isArray(snapshot.projects)
106
+ ) {
107
+ state.projectFilters = snapshot.projects;
108
+ }
109
+ if (state.currentProject) applyProjectFilters(state.currentProject);
110
+ return true;
111
+ }
112
+
113
+ export function initializeProjects(projects, serverCurrent) {
114
+ state.projectsList = projects;
115
+ const alive = new Set(projects.filter((project) => project.alive).map((project) => project.id));
116
+ const selected = alive.has(state.currentProject)
117
+ ? state.currentProject
118
+ : alive.has(serverCurrent)
119
+ ? serverCurrent
120
+ : (projects.find((project) => project.alive)?.id ?? null);
121
+ if (selected !== state.currentProject) {
122
+ saveCurrentProjectFilters();
123
+ state.currentProject = selected;
124
+ if (selected) applyProjectFilters(selected);
125
+ }
126
+ if (!selected) state.globalMode = false;
127
+ persistViewerState();
128
+ return selected;
129
+ }
130
+
131
+ export function normalizeRepoState(repo) {
132
+ if (!VALID_VIEWS.has(state.currentView)) state.currentView = 'board';
133
+ if (!VALID_SORT_KEYS.has(state.sortKey)) {
134
+ state.sortKey = 'id';
135
+ state.sortDir = 1;
136
+ }
137
+ if (state.sortDir !== 1 && state.sortDir !== -1) state.sortDir = 1;
138
+ if (!repo.types.includes(state.filters.type)) state.filters.type = 'all';
139
+ const owners = new Set(repo.changes.map((change) => change.owner).filter(Boolean));
140
+ if (state.filters.owner !== 'all' && !owners.has(state.filters.owner))
141
+ state.filters.owner = 'all';
142
+ const statuses = new Set(repo.statuses);
143
+ state.filters.statuses = new Set(
144
+ [...state.filters.statuses].filter((status) => statuses.has(status)),
145
+ );
146
+ persistViewerState();
147
+ }
148
+
20
149
  export function setRepo(json) {
21
150
  state.lastJson = json;
22
151
  state.repo = JSON.parse(json);
@@ -28,19 +157,23 @@ export function invalidateCache() {
28
157
 
29
158
  export function setTextFilter(text) {
30
159
  state.filters.text = text;
160
+ persistViewerState();
31
161
  }
32
162
 
33
163
  export function setTypeFilter(type) {
34
164
  state.filters.type = type;
165
+ persistViewerState();
35
166
  }
36
167
 
37
168
  export function setOwnerFilter(owner) {
38
169
  state.filters.owner = owner;
170
+ persistViewerState();
39
171
  }
40
172
 
41
173
  export function toggleStatusFilter(status) {
42
174
  if (state.filters.statuses.has(status)) state.filters.statuses.delete(status);
43
175
  else state.filters.statuses.add(status);
176
+ persistViewerState();
44
177
  return state.filters.statuses.has(status);
45
178
  }
46
179
 
@@ -48,29 +181,33 @@ export function clearStatusFilters() {
48
181
  state.filters.statuses.clear();
49
182
  state.filters.showArchived = false;
50
183
  state.filters.showDiscarded = false;
184
+ persistViewerState();
51
185
  }
52
186
 
53
187
  export function toggleShowArchived() {
54
188
  state.filters.showArchived = !state.filters.showArchived;
189
+ persistViewerState();
55
190
  return state.filters.showArchived;
56
191
  }
57
192
 
58
193
  export function toggleShowDiscarded() {
59
194
  state.filters.showDiscarded = !state.filters.showDiscarded;
195
+ persistViewerState();
60
196
  return state.filters.showDiscarded;
61
197
  }
62
198
 
63
- export function setView(v) {
64
- state.currentView = v;
199
+ export function setView(view) {
200
+ state.currentView = view;
65
201
  state.globalMode = false;
202
+ persistViewerState();
66
203
  }
67
204
 
68
205
  export function selectProject(id) {
206
+ saveCurrentProjectFilters();
69
207
  state.currentProject = id;
70
208
  state.lastJson = '';
71
- state.filters.type = 'all';
72
- state.filters.owner = 'all';
73
- state.filters.statuses.clear();
209
+ applyProjectFilters(id);
210
+ persistViewerState();
74
211
  }
75
212
 
76
213
  export function setSortKey(key) {
@@ -79,9 +216,11 @@ export function setSortKey(key) {
79
216
  state.sortKey = key;
80
217
  state.sortDir = 1;
81
218
  }
219
+ persistViewerState();
82
220
  }
83
221
 
84
222
  export function toggleGlobalMode() {
85
223
  state.globalMode = !state.globalMode;
224
+ persistViewerState();
86
225
  return state.globalMode;
87
226
  }
@@ -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
 
@@ -576,12 +602,242 @@ function openSpec(s) {
576
602
  renderExpandableMermaid($('#detail'));
577
603
  }
578
604
 
579
- const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics'];
605
+ const VIEWS = ['board', 'table', 'graph', 'specs', 'metrics', 'projects'];
580
606
 
581
607
  function renderMetrics() {
582
608
  litRender(metricsHtml(state.repo.metrics || {}), $('#metrics'));
583
609
  }
584
610
 
611
+ export function syncViewerShell(root = document, renderContent = true) {
612
+ root.querySelector('#search').value = state.filters.text;
613
+ root.querySelector('#toggle-global').classList.toggle('active', state.globalMode);
614
+ for (const name of VIEWS) {
615
+ root.querySelector(`#view-${name}`).classList.toggle('active', name === state.currentView);
616
+ root
617
+ .querySelector(`#${name}`)
618
+ .classList.toggle('hidden', state.globalMode || name !== state.currentView);
619
+ }
620
+ root.querySelector('#global').classList.toggle('hidden', !state.globalMode);
621
+ if (!renderContent) return;
622
+ if (state.globalMode) renderGlobal();
623
+ else render();
624
+ }
625
+
626
+ export function restoreInitialViewerShell(root = document, getStorage = () => window.localStorage) {
627
+ let browserStorage = null;
628
+ try {
629
+ browserStorage = getStorage();
630
+ } catch {
631
+ // Storage access itself may be forbidden (opaque origins/privacy policy).
632
+ }
633
+ restoreViewerState(browserStorage);
634
+ syncViewerShell(root, false);
635
+ }
636
+
637
+ let managedProject = null;
638
+ let managedConfig = null;
639
+
640
+ export function projectsViewTemplate(projects, selected, config, localOnly) {
641
+ const project = projects.find((item) => item.id === selected);
642
+ return html`<div class="projects-shell">
643
+ <div class="projects-list">
644
+ <div class="projects-heading">
645
+ <div><span class="eyebrow">Registry</span><h1>Projects</h1></div>
646
+ <span class="count">${projects.length}</span>
647
+ </div>
648
+ ${
649
+ projects.length
650
+ ? html`<div class="project-rows">${projects.map(
651
+ (
652
+ item,
653
+ ) => html`<button type="button" class=${`project-row${item.id === selected ? ' active' : ''}`} data-manage-project=${item.id}>
654
+ <span class=${`health-dot ${item.alive ? 'available' : 'missing'}`} aria-hidden="true"></span>
655
+ <span class="project-summary"><strong>${item.name}</strong><small>${item.path}</small></span>
656
+ <span class="mono project-id">${item.id}</span>
657
+ <span class=${`project-health ${item.alive ? 'available' : 'missing'}`}>${item.alive ? 'Available' : 'Missing'}</span>
658
+ </button>`,
659
+ )}</div>`
660
+ : html`<p class="empty">No projects registered.</p>`
661
+ }
662
+ </div>
663
+ <div class="project-editor">
664
+ ${
665
+ !project
666
+ ? 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>`
667
+ : html`<div class="project-editor-head">
668
+ <div><span class="eyebrow">${project.id}</span><h2>${project.name}</h2><p>${project.path}</p></div>
669
+ <span class=${`project-health ${project.alive ? 'available' : 'missing'}`}>${project.alive ? 'Available' : 'Missing'}</span>
670
+ </div>
671
+ ${
672
+ !localOnly
673
+ ? html`<form class="project-path-form">
674
+ <label for="project-path">Registered path</label>
675
+ <div><input id="project-path" name="path" .value=${project.path} /><button class="button secondary" type="submit">Repair path</button></div>
676
+ </form>`
677
+ : nothing
678
+ }
679
+ ${
680
+ project.alive
681
+ ? config?.loading
682
+ ? html`<p class="empty">Loading configuration…</p>`
683
+ : html`<form class="config-form">
684
+ <div class="config-label"><label for="project-config">.changeledger/config.yml</label><button type="button" class="text-button" data-reload-config>Reload</button></div>
685
+ <textarea id="project-config" spellcheck="false" .value=${config?.content ?? ''}></textarea>
686
+ <p class="project-error" ?hidden=${!config?.error}>${config?.error ?? ''}</p>
687
+ <div class="project-actions"><button class="button" type="submit">Save configuration</button></div>
688
+ </form>`
689
+ : html`<div class="missing-config"><h3>Configuration unavailable</h3><p>Repair the registered path to edit this project.</p></div>`
690
+ }
691
+ ${
692
+ !localOnly
693
+ ? 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>`
694
+ : nothing
695
+ }`
696
+ }
697
+ </div>
698
+ </div>`;
699
+ }
700
+
701
+ async function openManagedProject(id, { reload = false } = {}) {
702
+ managedProject = id;
703
+ const project = state.projectsList.find((item) => item.id === id);
704
+ if (!project?.alive) {
705
+ managedConfig = null;
706
+ renderProjects();
707
+ return;
708
+ }
709
+ if (!reload && managedConfig?.id === id && !managedConfig.error) {
710
+ renderProjects();
711
+ return;
712
+ }
713
+ managedConfig = { id, loading: true };
714
+ renderProjects();
715
+ try {
716
+ managedConfig = { id, ...(await getProjectConfig(id)) };
717
+ } catch (error) {
718
+ managedConfig = { id, content: '', revision: '', error: error.message };
719
+ }
720
+ renderProjects();
721
+ }
722
+
723
+ export function setProjectFormPending(root, pending) {
724
+ root.querySelectorAll('button, input, textarea').forEach((control) => {
725
+ control.disabled = pending;
726
+ });
727
+ root.classList.toggle('is-pending', pending);
728
+ }
729
+
730
+ export async function projectMutation(root, request, onSuccess) {
731
+ setProjectFormPending(root, true);
732
+ const error = root.querySelector('.project-error');
733
+ if (error) error.hidden = true;
734
+ try {
735
+ const response = await request();
736
+ const body = await response.json();
737
+ if (!response.ok) throw new Error(body.error || 'Project update failed.');
738
+ await onSuccess(body);
739
+ } catch (failure) {
740
+ if (error) {
741
+ error.textContent = failure.message;
742
+ error.hidden = false;
743
+ } else alert(failure.message);
744
+ } finally {
745
+ setProjectFormPending(root, false);
746
+ }
747
+ }
748
+
749
+ export function requestUnregisterConfirmation(project, ask = prompt) {
750
+ return ask(
751
+ `Type "${project.name}" to unregister this project. No repository files will be deleted.`,
752
+ );
753
+ }
754
+
755
+ async function refreshProjectRegistry() {
756
+ const { projects, current, localOnly } = await getProjects();
757
+ state.localOnly = localOnly;
758
+ initializeProjects(projects, current);
759
+ const select = $('#project');
760
+ litRender(
761
+ projects.map(
762
+ (item) =>
763
+ html`<option value=${item.id} ?disabled=${!item.alive}>${item.name}${item.alive ? '' : ' (missing)'}</option>`,
764
+ ),
765
+ select,
766
+ );
767
+ if (state.currentProject) select.value = state.currentProject;
768
+ select.style.display = projects.length > 1 ? '' : 'none';
769
+ }
770
+
771
+ function renderProjects() {
772
+ const root = $('#projects');
773
+ litRender(
774
+ projectsViewTemplate(state.projectsList, managedProject, managedConfig, state.localOnly),
775
+ root,
776
+ );
777
+ bindProjectViewActions(root, {
778
+ select: (id) => openManagedProject(id),
779
+ reload: () => openManagedProject(managedProject, { reload: true }),
780
+ save: (content, configForm) =>
781
+ projectMutation(
782
+ configForm,
783
+ () => postProjectConfig(managedProject, content, managedConfig.revision),
784
+ async (body) => {
785
+ managedConfig = { id: managedProject, content, revision: body.revision };
786
+ await refreshProjectRegistry();
787
+ renderProjects();
788
+ },
789
+ ),
790
+ repair: (projectPath, pathForm) =>
791
+ projectMutation(
792
+ pathForm,
793
+ () => postProjectPath(managedProject, projectPath),
794
+ async () => {
795
+ await refreshProjectRegistry();
796
+ await openManagedProject(managedProject, { reload: true });
797
+ },
798
+ ),
799
+ unregister: (editor) => {
800
+ const project = state.projectsList.find((item) => item.id === managedProject);
801
+ const confirm = requestUnregisterConfirmation(project);
802
+ if (confirm === null) return;
803
+ projectMutation(
804
+ editor,
805
+ () => postProjectRemove(managedProject, confirm),
806
+ async () => {
807
+ managedProject = null;
808
+ managedConfig = null;
809
+ await refreshProjectRegistry();
810
+ if (state.currentProject) await load();
811
+ renderProjects();
812
+ },
813
+ );
814
+ },
815
+ });
816
+ }
817
+
818
+ export function bindProjectViewActions(root, handlers) {
819
+ root.querySelectorAll('[data-manage-project]').forEach((button) => {
820
+ button.onclick = () => handlers.select(button.dataset.manageProject);
821
+ });
822
+ const reload = root.querySelector('[data-reload-config]');
823
+ if (reload) reload.onclick = () => handlers.reload();
824
+ const configForm = root.querySelector('.config-form');
825
+ if (configForm)
826
+ configForm.onsubmit = (event) => {
827
+ event.preventDefault();
828
+ handlers.save(configForm.querySelector('textarea').value, configForm);
829
+ };
830
+ const pathForm = root.querySelector('.project-path-form');
831
+ if (pathForm)
832
+ pathForm.onsubmit = (event) => {
833
+ event.preventDefault();
834
+ handlers.repair(pathForm.elements.path.value, pathForm);
835
+ };
836
+ const unregister = root.querySelector('[data-unregister]');
837
+ if (unregister)
838
+ unregister.onclick = () => handlers.unregister(root.querySelector('.project-editor'));
839
+ }
840
+
585
841
  function activateView(v) {
586
842
  setView(v);
587
843
  $('#toggle-global').classList.remove('active');
@@ -660,6 +916,7 @@ const fmtDate = (iso) => {
660
916
  // Wire the DOM and start polling. Guarded below so importing this module (tests)
661
917
  // has no side effects; only a real browser page bootstraps.
662
918
  function bootstrap() {
919
+ restoreInitialViewerShell();
663
920
  diagramLightbox = createDiagramLightbox({
664
921
  overlay: $('#diagram-overlay'),
665
922
  canvas: $('#diagram-canvas'),
@@ -692,6 +949,7 @@ function bootstrap() {
692
949
  $('#view-graph').onclick = () => activateView('graph');
693
950
  $('#view-specs').onclick = () => activateView('specs');
694
951
  $('#view-metrics').onclick = () => activateView('metrics');
952
+ $('#view-projects').onclick = () => activateView('projects');
695
953
  $('#project').onchange = (e) => {
696
954
  selectProject(e.target.value);
697
955
  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') {