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.
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
  }