orbital-command 0.3.0 → 1.0.1
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/README.md +67 -42
- package/bin/commands/config.js +19 -0
- package/bin/commands/events.js +40 -0
- package/bin/commands/launch.js +126 -0
- package/bin/commands/manifest.js +283 -0
- package/bin/commands/registry.js +104 -0
- package/bin/commands/update.js +24 -0
- package/bin/lib/helpers.js +229 -0
- package/bin/orbital.js +90 -873
- package/dist/assets/Landing-CfQdHR0N.js +11 -0
- package/dist/assets/PrimitivesConfig-DThSipFy.js +32 -0
- package/dist/assets/QualityGates-B4kxM5UU.js +26 -0
- package/dist/assets/SessionTimeline-Bz1iZnmg.js +1 -0
- package/dist/assets/Settings-DLcZwbCT.js +12 -0
- package/dist/assets/SourceControl-BMNIz7Lt.js +36 -0
- package/dist/assets/WorkflowVisualizer-CxuSBOYu.js +69 -0
- package/dist/assets/{arrow-down-CPy85_J6.js → arrow-down-DVPp6_qp.js} +1 -1
- package/dist/assets/bot-NFaJBDn_.js +6 -0
- package/dist/assets/{charts-DbDg0Psc.js → charts-LGLb8hyU.js} +1 -1
- package/dist/assets/{circle-x-Cwz6ZQDV.js → circle-x-IsFCkBZu.js} +1 -1
- package/dist/assets/{file-text-C46Xr65c.js → file-text-J1cebZXF.js} +1 -1
- package/dist/assets/{globe-Cn2yNZUD.js → globe-WzeyHsUc.js} +1 -1
- package/dist/assets/index-BdJ57EhC.css +1 -0
- package/dist/assets/index-o4ScMAuR.js +349 -0
- package/dist/assets/{key-OPaNTWJ5.js → key-CKR8JJSj.js} +1 -1
- package/dist/assets/{minus-GMsbpKym.js → minus-CHBsJyjp.js} +1 -1
- package/dist/assets/radio-xqZaR-Uk.js +6 -0
- package/dist/assets/rocket-D_xvvNG6.js +6 -0
- package/dist/assets/{shield-DwAFkDYI.js → shield-TdB1yv_a.js} +1 -1
- package/dist/assets/useSocketListener-0L5yiN5i.js +1 -0
- package/dist/assets/useWorkflowEditor-CqeRWVQX.js +11 -0
- package/dist/assets/workflow-constants-Rw-GmgHZ.js +6 -0
- package/dist/assets/zap-C9wqYMpl.js +6 -0
- package/dist/index.html +3 -3
- package/dist/server/server/__tests__/data-routes.test.js +2 -0
- package/dist/server/server/__tests__/scope-routes.test.js +1 -0
- package/dist/server/server/config-migrator.js +0 -3
- package/dist/server/server/config.js +35 -6
- package/dist/server/server/database.js +0 -22
- package/dist/server/server/index.js +28 -816
- package/dist/server/server/init.js +32 -399
- package/dist/server/server/launch.js +1 -1
- package/dist/server/server/parsers/event-parser.js +4 -1
- package/dist/server/server/project-context.js +19 -9
- package/dist/server/server/project-manager.js +6 -6
- package/dist/server/server/routes/aggregate-routes.js +871 -0
- package/dist/server/server/routes/config-routes.js +41 -88
- package/dist/server/server/routes/data-routes.js +5 -15
- package/dist/server/server/routes/dispatch-routes.js +24 -8
- package/dist/server/server/routes/manifest-routes.js +1 -1
- package/dist/server/server/routes/scope-routes.js +10 -7
- package/dist/server/server/schema.js +1 -0
- package/dist/server/server/services/batch-orchestrator.js +17 -3
- package/dist/server/server/services/config-service.js +10 -1
- package/dist/server/server/services/scope-service.js +7 -7
- package/dist/server/server/services/sprint-orchestrator.js +24 -11
- package/dist/server/server/services/sprint-service.js +2 -2
- package/dist/server/server/uninstall.js +195 -0
- package/dist/server/server/update.js +212 -0
- package/dist/server/server/utils/dispatch-utils.js +8 -6
- package/dist/server/server/utils/flag-builder.js +54 -0
- package/dist/server/server/utils/json-fields.js +14 -0
- package/dist/server/server/utils/json-fields.test.js +73 -0
- package/dist/server/server/utils/route-helpers.js +37 -0
- package/dist/server/server/utils/route-helpers.test.js +115 -0
- package/dist/server/server/watchers/event-watcher.js +28 -13
- package/dist/server/server/wizard/config-editor.js +4 -4
- package/dist/server/server/wizard/doctor.js +2 -2
- package/dist/server/server/wizard/index.js +224 -39
- package/dist/server/server/wizard/phases/welcome.js +1 -4
- package/dist/server/server/wizard/ui.js +6 -7
- package/dist/server/shared/api-types.js +80 -1
- package/dist/server/shared/workflow-engine.js +1 -1
- package/package.json +20 -20
- package/schemas/orbital.config.schema.json +1 -19
- package/scripts/postinstall.js +6 -42
- package/scripts/release.sh +53 -0
- package/server/__tests__/data-routes.test.ts +2 -0
- package/server/__tests__/scope-routes.test.ts +1 -0
- package/server/config-migrator.ts +0 -3
- package/server/config.ts +39 -11
- package/server/database.ts +0 -26
- package/server/global-config.ts +4 -0
- package/server/index.ts +31 -896
- package/server/init.ts +32 -443
- package/server/launch.ts +1 -1
- package/server/parsers/event-parser.ts +4 -1
- package/server/project-context.ts +26 -10
- package/server/project-manager.ts +5 -6
- package/server/routes/aggregate-routes.ts +968 -0
- package/server/routes/config-routes.ts +41 -81
- package/server/routes/data-routes.ts +7 -16
- package/server/routes/dispatch-routes.ts +29 -8
- package/server/routes/manifest-routes.ts +1 -1
- package/server/routes/scope-routes.ts +12 -7
- package/server/schema.ts +1 -0
- package/server/services/batch-orchestrator.ts +18 -2
- package/server/services/config-service.ts +10 -1
- package/server/services/scope-service.ts +6 -6
- package/server/services/sprint-orchestrator.ts +24 -9
- package/server/services/sprint-service.ts +2 -2
- package/server/uninstall.ts +214 -0
- package/server/update.ts +263 -0
- package/server/utils/dispatch-utils.ts +8 -6
- package/server/utils/flag-builder.ts +56 -0
- package/server/utils/json-fields.test.ts +83 -0
- package/server/utils/json-fields.ts +14 -0
- package/server/utils/route-helpers.test.ts +144 -0
- package/server/utils/route-helpers.ts +38 -0
- package/server/watchers/event-watcher.ts +24 -12
- package/server/wizard/config-editor.ts +4 -4
- package/server/wizard/doctor.ts +2 -2
- package/server/wizard/index.ts +291 -40
- package/server/wizard/phases/welcome.ts +1 -5
- package/server/wizard/ui.ts +6 -7
- package/shared/api-types.ts +106 -0
- package/shared/workflow-engine.ts +1 -1
- package/templates/agents/QUICK-REFERENCE.md +1 -0
- package/templates/agents/README.md +1 -0
- package/templates/agents/SKILL-TRIGGERS.md +11 -0
- package/templates/agents/green-team/deep-dive.md +361 -0
- package/templates/hooks/end-session.sh +1 -0
- package/templates/hooks/init-session.sh +1 -0
- package/templates/hooks/scope-commit-logger.sh +2 -2
- package/templates/hooks/scope-create-gate.sh +2 -4
- package/templates/hooks/scope-gate.sh +4 -6
- package/templates/hooks/scope-helpers.sh +10 -1
- package/templates/hooks/scope-lifecycle-gate.sh +14 -5
- package/templates/hooks/scope-prepare.sh +1 -1
- package/templates/hooks/scope-transition.sh +14 -6
- package/templates/hooks/time-tracker.sh +2 -5
- package/templates/orbital.config.json +1 -4
- package/templates/presets/development.json +4 -4
- package/templates/presets/gitflow.json +7 -0
- package/templates/prompts/README.md +23 -0
- package/templates/prompts/deep-dive-audit.md +94 -0
- package/templates/quick/rules.md +56 -5
- package/templates/skills/git-commit/SKILL.md +21 -6
- package/templates/skills/git-dev/SKILL.md +8 -4
- package/templates/skills/git-main/SKILL.md +8 -4
- package/templates/skills/git-production/SKILL.md +6 -3
- package/templates/skills/git-staging/SKILL.md +6 -3
- package/templates/skills/scope-fix-review/SKILL.md +8 -4
- package/templates/skills/scope-implement/SKILL.md +13 -5
- package/templates/skills/scope-post-review/SKILL.md +16 -4
- package/templates/skills/scope-pre-review/SKILL.md +6 -2
- package/dist/assets/PrimitivesConfig-CrmQXYh4.js +0 -32
- package/dist/assets/QualityGates-BbasOsF3.js +0 -21
- package/dist/assets/SessionTimeline-CGeJsVvy.js +0 -1
- package/dist/assets/Settings-oiM496mc.js +0 -12
- package/dist/assets/SourceControl-B1fP2nJL.js +0 -41
- package/dist/assets/WorkflowVisualizer-CWLYf-f0.js +0 -74
- package/dist/assets/formatDistanceToNow-BMqsSP44.js +0 -1
- package/dist/assets/index-Aj4sV8Al.css +0 -1
- package/dist/assets/index-Bc9dK3MW.js +0 -354
- package/dist/assets/useWorkflowEditor-BJkTX_NR.js +0 -16
- package/dist/assets/zap-DfbUoOty.js +0 -11
- package/dist/server/server/services/telemetry-service.js +0 -143
- package/server/services/telemetry-service.ts +0 -195
- /package/{shared/default-workflow.json → templates/presets/default.json} +0 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Uninstall logic — extracted from init.ts for focused maintainability.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { loadManifest } from './manifest.js';
|
|
8
|
+
import { removeAllOrbitalHooks } from './settings-sync.js';
|
|
9
|
+
import { unregisterProject } from './global-config.js';
|
|
10
|
+
import {
|
|
11
|
+
TEMPLATES_DIR,
|
|
12
|
+
cleanEmptyDirs,
|
|
13
|
+
listTemplateFiles,
|
|
14
|
+
} from './init.js';
|
|
15
|
+
|
|
16
|
+
// ─── Uninstall ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface UninstallOptions {
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
keepConfig?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function runUninstall(projectRoot: string, options: UninstallOptions = {}): void {
|
|
24
|
+
const { dryRun = false, keepConfig = false } = options;
|
|
25
|
+
const claudeDir = path.join(projectRoot, '.claude');
|
|
26
|
+
|
|
27
|
+
console.log(`\nOrbital Command — uninstall${dryRun ? ' (dry run)' : ''}`);
|
|
28
|
+
console.log(`Project root: ${projectRoot}\n`);
|
|
29
|
+
|
|
30
|
+
const manifest = loadManifest(projectRoot);
|
|
31
|
+
|
|
32
|
+
// Fall back to legacy uninstall if no manifest
|
|
33
|
+
if (!manifest) {
|
|
34
|
+
console.log(' No manifest found — falling back to legacy uninstall.');
|
|
35
|
+
runLegacyUninstall(projectRoot);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Compute what to remove vs preserve
|
|
40
|
+
const toRemove: string[] = [];
|
|
41
|
+
const toPreserve: string[] = [];
|
|
42
|
+
|
|
43
|
+
for (const [filePath, record] of Object.entries(manifest.files)) {
|
|
44
|
+
if (record.origin === 'user') {
|
|
45
|
+
toPreserve.push(filePath);
|
|
46
|
+
} else if (record.status === 'modified' || record.status === 'outdated') {
|
|
47
|
+
toPreserve.push(filePath);
|
|
48
|
+
} else {
|
|
49
|
+
toRemove.push(filePath);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
console.log(' Files to REMOVE:');
|
|
55
|
+
for (const f of toRemove) console.log(` ${f}`);
|
|
56
|
+
if (toPreserve.length > 0) {
|
|
57
|
+
console.log(' Files to PRESERVE:');
|
|
58
|
+
for (const f of toPreserve) console.log(` ${f} (${manifest.files[f].origin}/${manifest.files[f].status})`);
|
|
59
|
+
}
|
|
60
|
+
console.log(`\n Would also remove: settings hooks, generated artifacts, config files, gitignore entries`);
|
|
61
|
+
console.log(` No changes made. Run without --dry-run to apply.`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 1. Remove _orbital hooks from settings.local.json
|
|
66
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
67
|
+
const removedHooks = removeAllOrbitalHooks(settingsPath);
|
|
68
|
+
console.log(` Removed ${removedHooks} orbital hook registrations`);
|
|
69
|
+
|
|
70
|
+
// 2. Delete template files (synced + pinned, not modified or user-owned)
|
|
71
|
+
let filesRemoved = 0;
|
|
72
|
+
for (const filePath of toRemove) {
|
|
73
|
+
const absPath = path.join(claudeDir, filePath);
|
|
74
|
+
if (fs.existsSync(absPath)) {
|
|
75
|
+
fs.unlinkSync(absPath);
|
|
76
|
+
filesRemoved++;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
console.log(` Removed ${filesRemoved} template files`);
|
|
80
|
+
if (toPreserve.length > 0) {
|
|
81
|
+
console.log(` Preserved ${toPreserve.length} user/modified files`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 3. Clean up empty directories
|
|
85
|
+
for (const dir of ['hooks', 'skills', 'agents', 'config/workflows', 'quick', 'anti-patterns']) {
|
|
86
|
+
const dirPath = path.join(claudeDir, dir);
|
|
87
|
+
if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Remove generated artifacts
|
|
91
|
+
for (const artifact of manifest.generatedArtifacts) {
|
|
92
|
+
const artifactPath = path.join(claudeDir, artifact);
|
|
93
|
+
if (fs.existsSync(artifactPath)) {
|
|
94
|
+
fs.unlinkSync(artifactPath);
|
|
95
|
+
console.log(` Removed .claude/${artifact}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Remove template-sourced config files
|
|
100
|
+
const configFiles = [
|
|
101
|
+
'config/agent-triggers.json',
|
|
102
|
+
'config/workflow.json',
|
|
103
|
+
'lessons-learned.md',
|
|
104
|
+
];
|
|
105
|
+
for (const file of configFiles) {
|
|
106
|
+
const filePath = path.join(claudeDir, file);
|
|
107
|
+
if (fs.existsSync(filePath)) {
|
|
108
|
+
fs.unlinkSync(filePath);
|
|
109
|
+
console.log(` Removed .claude/${file}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Remove config/workflows/ directory entirely
|
|
114
|
+
const workflowsDir = path.join(claudeDir, 'config', 'workflows');
|
|
115
|
+
if (fs.existsSync(workflowsDir)) {
|
|
116
|
+
fs.rmSync(workflowsDir, { recursive: true, force: true });
|
|
117
|
+
console.log(` Removed .claude/config/workflows/`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 6. Remove gitignore entries
|
|
121
|
+
removeGitignoreEntries(projectRoot, manifest.gitignoreEntries);
|
|
122
|
+
console.log(` Cleaned .gitignore`);
|
|
123
|
+
|
|
124
|
+
// 7. Deregister from global registry
|
|
125
|
+
if (unregisterProject(projectRoot)) {
|
|
126
|
+
console.log(` Removed project from ~/.orbital/config.json`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 8. Remove orbital config and manifest (unless --keep-config)
|
|
130
|
+
if (!keepConfig) {
|
|
131
|
+
const toClean = ['orbital.config.json', 'orbital-manifest.json', 'orbital-sync.json'];
|
|
132
|
+
for (const file of toClean) {
|
|
133
|
+
const filePath = path.join(claudeDir, file);
|
|
134
|
+
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Remove backups directory
|
|
138
|
+
const backupsDir = path.join(claudeDir, '.orbital-backups');
|
|
139
|
+
if (fs.existsSync(backupsDir)) fs.rmSync(backupsDir, { recursive: true, force: true });
|
|
140
|
+
|
|
141
|
+
console.log(` Removed orbital config and manifest`);
|
|
142
|
+
} else {
|
|
143
|
+
// Still remove the manifest — it's invalid after uninstall
|
|
144
|
+
const manifestPath = path.join(claudeDir, 'orbital-manifest.json');
|
|
145
|
+
if (fs.existsSync(manifestPath)) fs.unlinkSync(manifestPath);
|
|
146
|
+
console.log(` Kept orbital.config.json (--keep-config)`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Clean up remaining empty directories
|
|
150
|
+
for (const dir of ['config', 'quick', 'anti-patterns', 'review-verdicts']) {
|
|
151
|
+
const dirPath = path.join(claudeDir, dir);
|
|
152
|
+
if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const total = removedHooks + filesRemoved;
|
|
156
|
+
console.log(`\nUninstall complete. ${total} items removed.`);
|
|
157
|
+
if (toPreserve.length > 0) {
|
|
158
|
+
console.log(`Note: ${toPreserve.length} user/modified files were preserved.`);
|
|
159
|
+
}
|
|
160
|
+
console.log(`Note: scopes/ and .claude/orbital-events/ were preserved.\n`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
/** Legacy uninstall for projects without a manifest (backward compat). */
|
|
166
|
+
function runLegacyUninstall(projectRoot: string): void {
|
|
167
|
+
const claudeDir = path.join(projectRoot, '.claude');
|
|
168
|
+
|
|
169
|
+
// Remove orbital hooks from settings.local.json
|
|
170
|
+
const settingsPath = path.join(claudeDir, 'settings.local.json');
|
|
171
|
+
const removedHooks = removeAllOrbitalHooks(settingsPath);
|
|
172
|
+
console.log(` Removed ${removedHooks} orbital hook registrations`);
|
|
173
|
+
|
|
174
|
+
// Delete hooks/skills/agents that match template files
|
|
175
|
+
for (const dir of ['hooks', 'skills', 'agents']) {
|
|
176
|
+
const templateDir = listTemplateFiles(path.join(TEMPLATES_DIR, dir), path.join(claudeDir, dir));
|
|
177
|
+
let removed = 0;
|
|
178
|
+
for (const f of templateDir) {
|
|
179
|
+
if (fs.existsSync(f)) { fs.unlinkSync(f); removed++; }
|
|
180
|
+
}
|
|
181
|
+
const dirPath = path.join(claudeDir, dir);
|
|
182
|
+
if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
|
|
183
|
+
console.log(` Removed ${removed} ${dir} files`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
console.log(`\nLegacy uninstall complete.`);
|
|
187
|
+
console.log(`Note: scopes/ and .claude/orbital-events/ were preserved.\n`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Remove Orbital-added entries from .gitignore. */
|
|
191
|
+
function removeGitignoreEntries(projectRoot: string, entries: string[]): void {
|
|
192
|
+
const gitignorePath = path.join(projectRoot, '.gitignore');
|
|
193
|
+
if (!fs.existsSync(gitignorePath)) return;
|
|
194
|
+
|
|
195
|
+
let content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
196
|
+
const marker = '# Orbital Command';
|
|
197
|
+
|
|
198
|
+
const markerIdx = content.indexOf(marker);
|
|
199
|
+
if (markerIdx !== -1) {
|
|
200
|
+
const before = content.slice(0, markerIdx).replace(/\n+$/, '');
|
|
201
|
+
const after = content.slice(markerIdx);
|
|
202
|
+
const lines = after.split('\n');
|
|
203
|
+
let endIdx = 0;
|
|
204
|
+
for (let i = 0; i < lines.length; i++) {
|
|
205
|
+
const line = lines[i].trim();
|
|
206
|
+
if (i === 0) { endIdx = i + 1; continue; }
|
|
207
|
+
if (line === '' || entries.includes(line)) { endIdx = i + 1; continue; }
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
const remaining = lines.slice(endIdx).join('\n');
|
|
211
|
+
content = before + (remaining ? '\n' + remaining : '') + '\n';
|
|
212
|
+
fs.writeFileSync(gitignorePath, content, 'utf-8');
|
|
213
|
+
}
|
|
214
|
+
}
|
package/server/update.ts
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template update logic — extracted from init.ts for focused maintainability.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import {
|
|
8
|
+
loadManifest,
|
|
9
|
+
saveManifest,
|
|
10
|
+
buildTemplateInventory,
|
|
11
|
+
refreshFileStatuses,
|
|
12
|
+
templateFileRecord,
|
|
13
|
+
safeBackupFile,
|
|
14
|
+
safeCopyTemplate,
|
|
15
|
+
reverseRemapPath,
|
|
16
|
+
} from './manifest.js';
|
|
17
|
+
import { needsLegacyMigration, migrateFromLegacy } from './migrate-legacy.js';
|
|
18
|
+
import { computeUpdatePlan, loadRenameMap, formatPlan, getFilesToBackup } from './update-planner.js';
|
|
19
|
+
import { syncSettingsHooks, getTemplateChecksum } from './settings-sync.js';
|
|
20
|
+
import { migrateConfig } from './config-migrator.js';
|
|
21
|
+
import { validate, formatValidationReport } from './validator.js';
|
|
22
|
+
import { createBackup } from './manifest.js';
|
|
23
|
+
import {
|
|
24
|
+
TEMPLATES_DIR,
|
|
25
|
+
ensureDir,
|
|
26
|
+
cleanEmptyDirs,
|
|
27
|
+
chmodScripts,
|
|
28
|
+
writeManifest,
|
|
29
|
+
generateIndexMd,
|
|
30
|
+
seedGlobalPrimitives,
|
|
31
|
+
getPackageVersion,
|
|
32
|
+
} from './init.js';
|
|
33
|
+
|
|
34
|
+
// ─── Update ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface UpdateOptions {
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
force?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function runUpdate(projectRoot: string, options: UpdateOptions = {}): void {
|
|
42
|
+
const { dryRun = false } = options;
|
|
43
|
+
const claudeDir = path.join(projectRoot, '.claude');
|
|
44
|
+
const newVersion = getPackageVersion();
|
|
45
|
+
|
|
46
|
+
console.log(`\nOrbital Command — update${dryRun ? ' (dry run)' : ''}`);
|
|
47
|
+
console.log(`Project root: ${projectRoot}\n`);
|
|
48
|
+
|
|
49
|
+
// 1. Load or create manifest (auto-migrate legacy installs)
|
|
50
|
+
let manifest = loadManifest(projectRoot);
|
|
51
|
+
if (!manifest) {
|
|
52
|
+
if (needsLegacyMigration(projectRoot)) {
|
|
53
|
+
console.log(' Migrating from legacy install...');
|
|
54
|
+
const result = migrateFromLegacy(projectRoot, TEMPLATES_DIR, newVersion);
|
|
55
|
+
console.log(` Migrated ${result.synced} synced, ${result.modified} modified, ${result.userOwned} user-owned files`);
|
|
56
|
+
if (result.importedPins > 0) console.log(` Imported ${result.importedPins} pinned files from orbital-sync.json`);
|
|
57
|
+
manifest = loadManifest(projectRoot);
|
|
58
|
+
}
|
|
59
|
+
if (!manifest) {
|
|
60
|
+
console.log(' No manifest found. Run `orbital` first.');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const oldVersion = manifest.packageVersion;
|
|
66
|
+
|
|
67
|
+
// 1b. Refresh file statuses so outdated vs modified is accurate
|
|
68
|
+
refreshFileStatuses(manifest, claudeDir);
|
|
69
|
+
|
|
70
|
+
// 2. Compute update plan
|
|
71
|
+
const renameMap = loadRenameMap(TEMPLATES_DIR, oldVersion, newVersion);
|
|
72
|
+
const plan = computeUpdatePlan({
|
|
73
|
+
templatesDir: TEMPLATES_DIR,
|
|
74
|
+
claudeDir,
|
|
75
|
+
manifest,
|
|
76
|
+
newVersion,
|
|
77
|
+
renameMap,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// 3. Dry-run mode — print plan and exit
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
console.log(formatPlan(plan, oldVersion, newVersion));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (plan.isEmpty && oldVersion === newVersion) {
|
|
87
|
+
console.log(' Everything up to date. No changes needed.');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. Create backup of files that will be modified
|
|
91
|
+
const filesToBackup = getFilesToBackup(plan);
|
|
92
|
+
if (filesToBackup.length > 0) {
|
|
93
|
+
const backupDir = createBackup(claudeDir, filesToBackup);
|
|
94
|
+
if (backupDir) {
|
|
95
|
+
console.log(` Backup ${filesToBackup.length} files → ${path.relative(claudeDir, backupDir)}/`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Execute plan
|
|
100
|
+
const templateInventory = buildTemplateInventory(TEMPLATES_DIR);
|
|
101
|
+
|
|
102
|
+
// 5a. Handle renames
|
|
103
|
+
for (const { from, to } of plan.toRename) {
|
|
104
|
+
const fromPath = path.join(claudeDir, from);
|
|
105
|
+
const toPath = path.join(claudeDir, to);
|
|
106
|
+
const toDir = path.dirname(toPath);
|
|
107
|
+
if (!fs.existsSync(toDir)) fs.mkdirSync(toDir, { recursive: true });
|
|
108
|
+
|
|
109
|
+
if (fs.existsSync(fromPath)) {
|
|
110
|
+
safeBackupFile(fromPath);
|
|
111
|
+
const stat = fs.lstatSync(fromPath);
|
|
112
|
+
if (stat.isSymbolicLink()) {
|
|
113
|
+
const target = fs.readlinkSync(fromPath);
|
|
114
|
+
fs.unlinkSync(fromPath);
|
|
115
|
+
fs.symlinkSync(target, toPath);
|
|
116
|
+
} else {
|
|
117
|
+
fs.renameSync(fromPath, toPath);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const record = manifest.files[from];
|
|
122
|
+
if (record) {
|
|
123
|
+
delete manifest.files[from];
|
|
124
|
+
const newHash = templateInventory.get(to);
|
|
125
|
+
manifest.files[to] = { ...record, templateHash: newHash, installedHash: newHash || record.installedHash };
|
|
126
|
+
}
|
|
127
|
+
console.log(` RENAME ${from} → ${to}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 5b. Add new files
|
|
131
|
+
for (const filePath of plan.toAdd) {
|
|
132
|
+
const templateHash = templateInventory.get(filePath);
|
|
133
|
+
if (!templateHash) continue;
|
|
134
|
+
|
|
135
|
+
const destPath = path.join(claudeDir, filePath);
|
|
136
|
+
const destDir = path.dirname(destPath);
|
|
137
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
138
|
+
|
|
139
|
+
copyTemplateFile(filePath, destPath);
|
|
140
|
+
manifest.files[filePath] = templateFileRecord(templateHash);
|
|
141
|
+
console.log(` ADD ${filePath}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 5c. Update changed synced/outdated files
|
|
145
|
+
for (const filePath of plan.toUpdate) {
|
|
146
|
+
const templateHash = templateInventory.get(filePath);
|
|
147
|
+
if (!templateHash) continue;
|
|
148
|
+
|
|
149
|
+
const destPath = path.join(claudeDir, filePath);
|
|
150
|
+
safeBackupFile(destPath);
|
|
151
|
+
copyTemplateFile(filePath, destPath);
|
|
152
|
+
manifest.files[filePath] = templateFileRecord(templateHash);
|
|
153
|
+
console.log(` UPDATE ${filePath}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 5d. Remove deleted files
|
|
157
|
+
for (const filePath of plan.toRemove) {
|
|
158
|
+
const absPath = path.join(claudeDir, filePath);
|
|
159
|
+
if (fs.existsSync(absPath)) {
|
|
160
|
+
safeBackupFile(absPath);
|
|
161
|
+
fs.unlinkSync(absPath);
|
|
162
|
+
}
|
|
163
|
+
delete manifest.files[filePath];
|
|
164
|
+
console.log(` REMOVE ${filePath}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 5e. Update pinned/modified file records (record new template hash without touching file)
|
|
168
|
+
for (const { file, reason, newTemplateHash } of plan.toSkip) {
|
|
169
|
+
if (manifest.files[file]) {
|
|
170
|
+
manifest.files[file].templateHash = newTemplateHash;
|
|
171
|
+
}
|
|
172
|
+
if (reason === 'modified') {
|
|
173
|
+
console.log(` SKIP ${file} (user modified)`);
|
|
174
|
+
} else {
|
|
175
|
+
console.log(` SKIP ${file} (pinned)`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 5f. Clean up empty directories
|
|
180
|
+
for (const dir of ['hooks', 'skills', 'agents', 'config/workflows']) {
|
|
181
|
+
const dirPath = path.join(claudeDir, dir);
|
|
182
|
+
if (fs.existsSync(dirPath)) cleanEmptyDirs(dirPath);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 6. Bidirectional settings hook sync
|
|
186
|
+
const settingsTarget = path.join(claudeDir, 'settings.local.json');
|
|
187
|
+
const settingsSrc = path.join(TEMPLATES_DIR, 'settings-hooks.json');
|
|
188
|
+
const syncResult = syncSettingsHooks(settingsTarget, settingsSrc, manifest.settingsHooksChecksum, renameMap);
|
|
189
|
+
if (!syncResult.skipped) {
|
|
190
|
+
console.log(` Settings +${syncResult.added} -${syncResult.removed} hooks (${syncResult.updated} renamed)`);
|
|
191
|
+
manifest.settingsHooksChecksum = getTemplateChecksum(settingsSrc);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 7. Config migrations
|
|
195
|
+
const configPath = path.join(claudeDir, 'orbital.config.json');
|
|
196
|
+
const migrationResult = migrateConfig(configPath, manifest.appliedMigrations);
|
|
197
|
+
if (migrationResult.applied.length > 0) {
|
|
198
|
+
manifest.appliedMigrations.push(...migrationResult.applied);
|
|
199
|
+
console.log(` Config ${migrationResult.applied.length} migration(s) applied`);
|
|
200
|
+
}
|
|
201
|
+
if (migrationResult.defaultsFilled.length > 0) {
|
|
202
|
+
console.log(` Config ${migrationResult.defaultsFilled.length} default(s) filled`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 8. Regenerate derived artifacts (always)
|
|
206
|
+
const workflowManifestOk = writeManifest(claudeDir);
|
|
207
|
+
console.log(` ${workflowManifestOk ? 'Updated' : 'Skipped'} .claude/config/workflow-manifest.sh`);
|
|
208
|
+
|
|
209
|
+
const indexContent = generateIndexMd(projectRoot, claudeDir);
|
|
210
|
+
fs.writeFileSync(path.join(claudeDir, 'INDEX.md'), indexContent, 'utf8');
|
|
211
|
+
console.log(` Updated .claude/INDEX.md`);
|
|
212
|
+
|
|
213
|
+
// 9. Update agent-triggers.json (template-managed)
|
|
214
|
+
const triggersSrc = path.join(TEMPLATES_DIR, 'config', 'agent-triggers.json');
|
|
215
|
+
const triggersDest = path.join(claudeDir, 'config', 'agent-triggers.json');
|
|
216
|
+
if (fs.existsSync(triggersSrc)) {
|
|
217
|
+
fs.copyFileSync(triggersSrc, triggersDest);
|
|
218
|
+
console.log(` Updated .claude/config/agent-triggers.json`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 10. Update scope template
|
|
222
|
+
const scopeTemplateSrc = path.join(TEMPLATES_DIR, 'scopes', '_template.md');
|
|
223
|
+
const scopeTemplateDest = path.join(projectRoot, 'scopes', '_template.md');
|
|
224
|
+
if (fs.existsSync(scopeTemplateSrc)) {
|
|
225
|
+
ensureDir(path.join(projectRoot, 'scopes'));
|
|
226
|
+
fs.copyFileSync(scopeTemplateSrc, scopeTemplateDest);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// 11. Make hook scripts executable
|
|
230
|
+
chmodScripts(path.join(claudeDir, 'hooks'));
|
|
231
|
+
|
|
232
|
+
// 12. Refresh global primitives
|
|
233
|
+
seedGlobalPrimitives();
|
|
234
|
+
|
|
235
|
+
// 13. Update manifest metadata
|
|
236
|
+
manifest.previousPackageVersion = oldVersion;
|
|
237
|
+
manifest.packageVersion = newVersion;
|
|
238
|
+
manifest.updatedAt = new Date().toISOString();
|
|
239
|
+
saveManifest(projectRoot, manifest);
|
|
240
|
+
|
|
241
|
+
// 14. Validate
|
|
242
|
+
const report = validate(projectRoot, newVersion);
|
|
243
|
+
if (report.errors > 0) {
|
|
244
|
+
console.log(`\n Validation: ${report.errors} errors found`);
|
|
245
|
+
console.log(formatValidationReport(report));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const totalChanges = plan.toAdd.length + plan.toUpdate.length + plan.toRemove.length + plan.toRename.length;
|
|
249
|
+
console.log(`\nUpdate complete. ${totalChanges} file changes, ${plan.toSkip.length} skipped.\n`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
function copyTemplateFile(claudeRelPath: string, destPath: string): void {
|
|
255
|
+
const templateRelPath = reverseRemapPath(claudeRelPath);
|
|
256
|
+
const srcPath = path.join(TEMPLATES_DIR, templateRelPath);
|
|
257
|
+
if (!fs.existsSync(srcPath)) {
|
|
258
|
+
throw new Error(`Template file not found: ${templateRelPath}`);
|
|
259
|
+
}
|
|
260
|
+
const destDir = path.dirname(destPath);
|
|
261
|
+
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
|
262
|
+
safeCopyTemplate(srcPath, destPath);
|
|
263
|
+
}
|
|
@@ -208,13 +208,13 @@ export function resolveDispatchesByDispatchId(
|
|
|
208
208
|
return [row.id];
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
/**
|
|
212
|
-
const
|
|
211
|
+
/** Default fallback age threshold for dispatches without a linked PID (10 minutes). */
|
|
212
|
+
const DEFAULT_STALE_AGE_MS = 10 * 60 * 1000;
|
|
213
213
|
|
|
214
214
|
/** Get all scope IDs that have actively running DISPATCH events.
|
|
215
215
|
* Uses PID liveness (process.kill(pid, 0)) when available, falls back to
|
|
216
216
|
* age-based heuristic for legacy dispatches without a linked PID. */
|
|
217
|
-
export function getActiveScopeIds(db: Database.Database, scopeService: ScopeService, engine: WorkflowEngine): number[] {
|
|
217
|
+
export function getActiveScopeIds(db: Database.Database, scopeService: ScopeService, engine: WorkflowEngine, staleTimeoutMinutes?: number): number[] {
|
|
218
218
|
const rows = db.prepare(
|
|
219
219
|
`SELECT scope_id, data FROM events
|
|
220
220
|
WHERE type = 'DISPATCH'
|
|
@@ -222,7 +222,8 @@ export function getActiveScopeIds(db: Database.Database, scopeService: ScopeServ
|
|
|
222
222
|
AND JSON_EXTRACT(data, '$.resolved') IS NULL`,
|
|
223
223
|
).all() as Array<{ scope_id: number; data: string }>;
|
|
224
224
|
|
|
225
|
-
const
|
|
225
|
+
const staleMs = staleTimeoutMinutes != null ? staleTimeoutMinutes * 60 * 1000 : DEFAULT_STALE_AGE_MS;
|
|
226
|
+
const cutoff = new Date(Date.now() - staleMs).toISOString();
|
|
226
227
|
const active = new Set<number>();
|
|
227
228
|
|
|
228
229
|
for (const row of rows) {
|
|
@@ -310,8 +311,9 @@ export function getActiveScopeIds(db: Database.Database, scopeService: ScopeServ
|
|
|
310
311
|
* safe recovery for edges like backlog→implementing where the session crashed
|
|
311
312
|
* before doing meaningful work. Edges without autoRevert leave the scope in place
|
|
312
313
|
* for manual recovery from the dashboard. */
|
|
313
|
-
export function resolveStaleDispatches(db: Database.Database, io: Emitter, scopeService: ScopeService, engine: WorkflowEngine): number {
|
|
314
|
-
const
|
|
314
|
+
export function resolveStaleDispatches(db: Database.Database, io: Emitter, scopeService: ScopeService, engine: WorkflowEngine, staleTimeoutMinutes?: number): number {
|
|
315
|
+
const staleMs = staleTimeoutMinutes != null ? staleTimeoutMinutes * 60 * 1000 : DEFAULT_STALE_AGE_MS;
|
|
316
|
+
const cutoff = new Date(Date.now() - staleMs).toISOString();
|
|
315
317
|
|
|
316
318
|
// Single query on events only — split by cache status
|
|
317
319
|
const rows = db.prepare(
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DispatchFlags } from '../../shared/api-types.js';
|
|
2
|
+
import { VALID_OUTPUT_FORMATS, validateToolName, validateEnvKey } from '../../shared/api-types.js';
|
|
3
|
+
import { shellQuote } from './terminal-launcher.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Compile a structured DispatchFlags object into a CLI flags string
|
|
7
|
+
* for the `claude` command. All parameterized values are validated
|
|
8
|
+
* and shell-quoted to prevent injection.
|
|
9
|
+
*/
|
|
10
|
+
export function buildClaudeFlags(flags: DispatchFlags): string {
|
|
11
|
+
const parts: string[] = [];
|
|
12
|
+
|
|
13
|
+
// Permission mode — 'default' means no flag (use Claude's built-in default)
|
|
14
|
+
if (flags.permissionMode === 'bypass') {
|
|
15
|
+
parts.push('--dangerously-skip-permissions');
|
|
16
|
+
} else if (flags.permissionMode && flags.permissionMode !== 'default') {
|
|
17
|
+
parts.push('--permission-mode', flags.permissionMode);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (flags.verbose) parts.push('--verbose');
|
|
21
|
+
if (flags.noMarkdown) parts.push('--no-markdown');
|
|
22
|
+
if (flags.printMode) parts.push('-p');
|
|
23
|
+
|
|
24
|
+
if (flags.outputFormat && VALID_OUTPUT_FORMATS.includes(flags.outputFormat)) {
|
|
25
|
+
parts.push('--output-format', flags.outputFormat);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (flags.allowedTools.length > 0) {
|
|
29
|
+
const safe = flags.allowedTools.filter(validateToolName);
|
|
30
|
+
if (safe.length > 0) parts.push('--allowedTools', safe.join(','));
|
|
31
|
+
}
|
|
32
|
+
if (flags.disallowedTools.length > 0) {
|
|
33
|
+
const safe = flags.disallowedTools.filter(validateToolName);
|
|
34
|
+
if (safe.length > 0) parts.push('--disallowedTools', safe.join(','));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (flags.appendSystemPrompt) {
|
|
38
|
+
const sanitized = flags.appendSystemPrompt.replace(/\n/g, '\\n');
|
|
39
|
+
parts.push('--append-system-prompt', `'${shellQuote(sanitized)}'`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return parts.join(' ');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build env var prefix string for dispatch commands.
|
|
47
|
+
* Keys are validated against POSIX naming rules.
|
|
48
|
+
* Returns empty string if no vars configured.
|
|
49
|
+
*/
|
|
50
|
+
export function buildEnvVarPrefix(envVars: Record<string, string>): string {
|
|
51
|
+
const entries = Object.entries(envVars).filter(([k]) => validateEnvKey(k));
|
|
52
|
+
if (entries.length === 0) return '';
|
|
53
|
+
return entries
|
|
54
|
+
.map(([k, v]) => `${k}='${v.replace(/'/g, "'\\''")}'`)
|
|
55
|
+
.join(' ') + ' ';
|
|
56
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseJsonFields } from './json-fields.js';
|
|
3
|
+
|
|
4
|
+
describe('parseJsonFields', () => {
|
|
5
|
+
it('parses stringified JSON arrays in known fields', () => {
|
|
6
|
+
const row = { tags: '["a","b"]', blocked_by: '[1,2]', title: 'scope-1' };
|
|
7
|
+
const result = parseJsonFields(row);
|
|
8
|
+
expect(result.tags).toEqual(['a', 'b']);
|
|
9
|
+
expect(result.blocked_by).toEqual([1, 2]);
|
|
10
|
+
expect(result.title).toBe('scope-1');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('parses stringified JSON objects in data field', () => {
|
|
14
|
+
const row = { data: '{"key":"val","nested":{"a":1}}' };
|
|
15
|
+
const result = parseJsonFields(row);
|
|
16
|
+
expect(result.data).toEqual({ key: 'val', nested: { a: 1 } });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('handles all 7 known JSON fields', () => {
|
|
20
|
+
const row = {
|
|
21
|
+
tags: '[]', blocked_by: '[]', blocks: '[]',
|
|
22
|
+
data: '{}', discoveries: '[]', next_steps: '[]', details: '{}',
|
|
23
|
+
};
|
|
24
|
+
const result = parseJsonFields(row);
|
|
25
|
+
expect(result.tags).toEqual([]);
|
|
26
|
+
expect(result.blocked_by).toEqual([]);
|
|
27
|
+
expect(result.blocks).toEqual([]);
|
|
28
|
+
expect(result.data).toEqual({});
|
|
29
|
+
expect(result.discoveries).toEqual([]);
|
|
30
|
+
expect(result.next_steps).toEqual([]);
|
|
31
|
+
expect(result.details).toEqual({});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('passes through already-parsed objects untouched', () => {
|
|
35
|
+
const tags = ['a', 'b'];
|
|
36
|
+
const row = { tags, data: { foo: 1 } };
|
|
37
|
+
const result = parseJsonFields(row);
|
|
38
|
+
expect(result.tags).toBe(tags); // same reference — not re-parsed
|
|
39
|
+
expect(result.data).toEqual({ foo: 1 });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps malformed JSON strings as-is without throwing', () => {
|
|
43
|
+
const row = { tags: '{broken json', data: 'not json at all', blocks: '["valid"]' };
|
|
44
|
+
const result = parseJsonFields(row);
|
|
45
|
+
expect(result.tags).toBe('{broken json');
|
|
46
|
+
expect(result.data).toBe('not json at all');
|
|
47
|
+
expect(result.blocks).toEqual(['valid']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns row unchanged when no JSON fields are present', () => {
|
|
51
|
+
const row = { id: 1, title: 'hello', status: 'active' };
|
|
52
|
+
const result = parseJsonFields(row);
|
|
53
|
+
expect(result).toEqual(row);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('does not mutate the original row', () => {
|
|
57
|
+
const row = { tags: '["x"]', title: 'scope' };
|
|
58
|
+
const result = parseJsonFields(row);
|
|
59
|
+
expect(row.tags).toBe('["x"]'); // original unchanged
|
|
60
|
+
expect(result.tags).toEqual(['x']); // copy was parsed
|
|
61
|
+
expect(result).not.toBe(row);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('handles null and undefined field values', () => {
|
|
65
|
+
const row = { tags: null, data: undefined, blocks: '["a"]' };
|
|
66
|
+
const result = parseJsonFields(row);
|
|
67
|
+
expect(result.tags).toBeNull();
|
|
68
|
+
expect(result.data).toBeUndefined();
|
|
69
|
+
expect(result.blocks).toEqual(['a']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('handles empty row', () => {
|
|
73
|
+
const result = parseJsonFields({});
|
|
74
|
+
expect(result).toEqual({});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('ignores non-JSON-field strings', () => {
|
|
78
|
+
const row = { title: '["not a json field"]', tags: '["real"]' };
|
|
79
|
+
const result = parseJsonFields(row);
|
|
80
|
+
expect(result.title).toBe('["not a json field"]'); // title is not in JSON_FIELDS
|
|
81
|
+
expect(result.tags).toEqual(['real']);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const JSON_FIELDS = ['tags', 'blocked_by', 'blocks', 'data', 'discoveries', 'next_steps', 'details'];
|
|
2
|
+
|
|
3
|
+
export type Row = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
/** Parse stringified JSON fields in a database row back to objects. */
|
|
6
|
+
export function parseJsonFields(row: Row): Row {
|
|
7
|
+
const parsed = { ...row };
|
|
8
|
+
for (const field of JSON_FIELDS) {
|
|
9
|
+
if (typeof parsed[field] === 'string') {
|
|
10
|
+
try { parsed[field] = JSON.parse(parsed[field] as string); } catch { /* keep string */ }
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|