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/AGENTS.md +13 -7
- package/README.md +22 -13
- package/bin/changeledger.mjs +10 -2
- package/package.json +1 -1
- package/src/check.mjs +35 -9
- package/src/commands/agent.mjs +1 -1
- package/src/commands/check.mjs +1 -1
- package/src/commands/context.mjs +65 -0
- package/src/commands/init.mjs +3 -6
- package/src/commands/new.mjs +1 -1
- package/src/commands/register.mjs +10 -6
- package/src/commands/view.mjs +10 -1
- package/src/config.mjs +6 -3
- package/src/contract.mjs +86 -50
- package/src/paths.mjs +1 -1
- package/src/registry.mjs +24 -1
- package/src/repo.mjs +7 -0
- package/src/viewer/domain.mjs +136 -3
- package/src/viewer/public/api.js +26 -0
- package/src/viewer/public/app-state.js +144 -5
- package/src/viewer/public/app.js +299 -11
- package/src/viewer/public/index.html +2 -0
- package/src/viewer/public/styles.css +202 -0
- package/src/viewer/server/router.mjs +50 -11
- package/templates/config.yml +4 -3
- package/templates/contract/blocked.md +6 -0
- package/templates/contract/close.md +18 -0
- package/templates/contract/core.md +64 -0
- package/templates/contract/discarded.md +5 -0
- package/templates/contract/implement.md +52 -0
- package/templates/contract/readiness.md +19 -0
- package/templates/contract/release.md +15 -0
- package/templates/contract/review.md +19 -0
- package/templates/contract/spec.md +94 -0
- package/templates/contract/validation.md +9 -0
- package/templates/AGENTS.md +0 -540
package/src/viewer/public/app.js
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
603
|
+
detail.onclick = (e) => handleSpecBodyClick(e, (href) => openSpecByName(href, state, openSpec));
|
|
604
|
+
renderExpandableMermaid(detail);
|
|
577
605
|
}
|
|
578
606
|
|
|
579
|
-
|
|
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 {
|
|
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 (
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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(
|
|
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') {
|