@worca/app 0.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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,302 @@
1
+ // src/core/guardrails.mjs
2
+ // Per-project guardrails: the pure policy layer.
3
+ // - 5-key settings shape: { honorProjectSettings, envScrub, envAllowlist,
4
+ // protectedPaths, deny } — what enforcement consumes.
5
+ // - stored config shape: { level, custom } — what the DB holds. Preset levels
6
+ // resolve from GUARDRAIL_PRESETS at read time so preset improvements ship
7
+ // with worca upgrades; `custom` is the user's pinned blob (dormant unless
8
+ // level === 'custom', preserved across level switches).
9
+ // Storage: project_config.extra.guardrails (config.mjs). Enforcement: a Claude
10
+ // Code `permissions` object in ONE --settings payload + a scrubbed spawn env
11
+ // (claude-runner.mjs). Everything here is pure and throw-free except the
12
+ // validate* functions, which report instead of throwing.
13
+ //
14
+ // Rule-spelling invariants (see plan Global Constraints):
15
+ // - Bash denies are exact+prefix PAIRS: Bash(cmd) + Bash(cmd:*).
16
+ // - protectedPaths: slash-less patterns match at any depth; slash-containing
17
+ // patterns MUST carry a **/ prefix or they anchor to cwd and miss members
18
+ // on detached workspace runs.
19
+
20
+ export const DEFAULT_GUARDRAILS = Object.freeze({
21
+ honorProjectSettings: true,
22
+ envScrub: false,
23
+ envAllowlist: Object.freeze([]),
24
+ protectedPaths: Object.freeze([]),
25
+ deny: Object.freeze([]),
26
+ });
27
+
28
+ export const GUARDRAIL_LEVELS = Object.freeze(['permissive', 'normal', 'secure', 'custom']);
29
+
30
+ const deepFreeze = (o) => {
31
+ for (const v of Object.values(o)) {
32
+ if (v && typeof v === 'object' && !Object.isFrozen(v)) deepFreeze(v);
33
+ }
34
+ return Object.freeze(o);
35
+ };
36
+
37
+ // Credential-material file patterns every non-permissive level protects.
38
+ const NORMAL_PROTECTED = [
39
+ '.env*', // .env, .env.local, .env.production, .envrc — any depth
40
+ '*.pem', '*.key', // TLS / private key material
41
+ 'id_rsa', 'id_ed25519', // bare SSH keys checked into odd places
42
+ '*.p12', '*.pfx', // bundled cert+key stores
43
+ ];
44
+
45
+ // Irreversible-publication commands no pipeline role ever needs.
46
+ const NORMAL_DENY = [
47
+ 'Bash(git push)', 'Bash(git push:*)',
48
+ 'Bash(npm publish)', 'Bash(npm publish:*)',
49
+ 'Bash(yarn publish)', 'Bash(yarn publish:*)',
50
+ 'Bash(pnpm publish)', 'Bash(pnpm publish:*)',
51
+ ];
52
+
53
+ /**
54
+ * The built-in levels. `custom` is not here — it resolves from storage.
55
+ * permissive IS DEFAULT_GUARDRAILS (same object): an unconfigured project and a
56
+ * permissive project are indistinguishable, including byte-identical spawn argv.
57
+ * Normal: protect credential files, block publication; never breaks a pipeline
58
+ * (git commit / npm install / npm test / curl localhost all untouched).
59
+ * Secure++: Normal + env scrub (the real exfil control) + egress binaries +
60
+ * publish channels + cloud-credential CLIs + WebFetch/WebSearch (defense against
61
+ * frontmatter-widened agents). Still functional: project file Read/Write/Edit,
62
+ * npm install/test, and local git commits are untouched.
63
+ */
64
+ export const GUARDRAIL_PRESETS = deepFreeze({
65
+ permissive: DEFAULT_GUARDRAILS,
66
+ normal: {
67
+ honorProjectSettings: true,
68
+ envScrub: false,
69
+ envAllowlist: [],
70
+ protectedPaths: [...NORMAL_PROTECTED],
71
+ deny: [...NORMAL_DENY],
72
+ },
73
+ secure: {
74
+ honorProjectSettings: true,
75
+ envScrub: true,
76
+ envAllowlist: [],
77
+ protectedPaths: [
78
+ ...NORMAL_PROTECTED,
79
+ '.npmrc', '.netrc', // token-bearing rc files (project-level)
80
+ '*.tfstate*', // terraform state embeds raw secrets
81
+ '*.keystore', '*.jks',
82
+ '**/secrets/**', // slash-containing ⇒ needs **/ (anchoring)
83
+ '**/.git/config', // can embed https://user:token@ remotes
84
+ '~/.git-credentials', // git credential-store: plaintext https://user:token@ lines
85
+ '~/.ssh/**', '~/.aws/**', '~/.config/gcloud/**', '~/.kube/**', '~/.config/gh/**', // gh hosts.yml holds the OAuth token
86
+ '~/.npmrc', '~/.netrc', '~/.docker/config.json',
87
+ ],
88
+ deny: [
89
+ ...NORMAL_DENY,
90
+ 'Bash(curl)', 'Bash(curl:*)', 'Bash(wget)', 'Bash(wget:*)',
91
+ 'Bash(nc)', 'Bash(nc:*)', 'Bash(ncat)', 'Bash(ncat:*)', 'Bash(netcat)', 'Bash(netcat:*)',
92
+ 'Bash(telnet)', 'Bash(telnet:*)',
93
+ 'Bash(ssh)', 'Bash(ssh:*)', 'Bash(scp)', 'Bash(scp:*)', 'Bash(sftp)', 'Bash(sftp:*)',
94
+ 'Bash(rsync)', 'Bash(rsync:*)', 'Bash(ftp)', 'Bash(ftp:*)',
95
+ 'Bash(gh)', 'Bash(gh:*)',
96
+ 'Bash(docker push)', 'Bash(docker push:*)',
97
+ 'Bash(aws)', 'Bash(aws:*)', 'Bash(gcloud)', 'Bash(gcloud:*)', 'Bash(az)', 'Bash(az:*)',
98
+ 'WebFetch', 'WebSearch',
99
+ ],
100
+ },
101
+ });
102
+
103
+ // A permission rule is `Tool(pattern)` (pattern non-empty), a bare tool name, a
104
+ // tool-name glob (`*` = all tools, `Bash*`), or an mcp__ tool id. Deliberately
105
+ // permissive inside the parens — the CLI owns pattern semantics; we only reject
106
+ // obvious shell text / malformed shapes. (The `*`/`Tool*` forms are valid deny
107
+ // globs per Claude Code docs; accepting them keeps the custom editor from 400ing
108
+ // a legitimate rule.)
109
+ const RULE_RE = /^(?:[A-Za-z*][A-Za-z0-9_*]*(?:\(.+\))?|mcp__[A-Za-z0-9_.*-]+(?:__[A-Za-z0-9_.*-]+)*)$/;
110
+
111
+ export function isPermissionRule(s) {
112
+ return typeof s === 'string' && RULE_RE.test(s.trim());
113
+ }
114
+
115
+ const cleanStrings = (v) =>
116
+ Array.isArray(v) ? v.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) : [];
117
+
118
+ /** Read-path sanitizer for the 5-key settings shape. Malformed → defaults; invalid deny rules drop. */
119
+ export function sanitizeGuardrails(raw) {
120
+ const src = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
121
+ return {
122
+ honorProjectSettings:
123
+ typeof src.honorProjectSettings === 'boolean' ? src.honorProjectSettings : true,
124
+ envScrub: src.envScrub === true,
125
+ envAllowlist: cleanStrings(src.envAllowlist),
126
+ protectedPaths: cleanStrings(src.protectedPaths),
127
+ deny: cleanStrings(src.deny).filter(isPermissionRule),
128
+ };
129
+ }
130
+
131
+ /** Write-path validator for the 5-key settings shape: strict, collects errors, never throws. */
132
+ export function validateGuardrails(raw) {
133
+ const errors = [];
134
+ if (raw === undefined || raw === null) return { ok: true, errors };
135
+ if (typeof raw !== 'object' || Array.isArray(raw)) return { ok: false, errors: ['guardrails must be an object'] };
136
+ for (const k of ['honorProjectSettings', 'envScrub']) {
137
+ if (k in raw && typeof raw[k] !== 'boolean') errors.push(`${k} must be a boolean`);
138
+ }
139
+ for (const k of ['envAllowlist', 'protectedPaths', 'deny']) {
140
+ if (k in raw) {
141
+ if (!Array.isArray(raw[k])) errors.push(`${k} must be an array of strings`);
142
+ else for (const s of raw[k]) if (typeof s !== 'string' || !s.trim()) errors.push(`${k} entries must be non-empty strings`);
143
+ }
144
+ }
145
+ if (Array.isArray(raw.deny)) {
146
+ for (const r of raw.deny) {
147
+ if (typeof r === 'string' && r.trim() && !isPermissionRule(r)) {
148
+ errors.push(`deny rule "${r}" is not a valid permission rule (expected Tool(pattern))`);
149
+ }
150
+ }
151
+ }
152
+ const known = new Set(['honorProjectSettings', 'envScrub', 'envAllowlist', 'protectedPaths', 'deny']);
153
+ for (const k of Object.keys(raw)) if (!known.has(k)) errors.push(`unknown key "${k}"`);
154
+ return { ok: errors.length === 0, errors };
155
+ }
156
+
157
+ const FIVE_KEYS = ['honorProjectSettings', 'envScrub', 'envAllowlist', 'protectedPaths', 'deny'];
158
+
159
+ /**
160
+ * Read-path sanitizer for the STORED config shape.
161
+ * { level, custom } passes through (bad level fails open to permissive — parity);
162
+ * a v1-era bare 5-key blob upgrades losslessly to { level:'custom', custom };
163
+ * anything else → { level:'permissive', custom:null }.
164
+ * Lenient about unknown wrapper keys by design (a future v3 blob read by v2
165
+ * degrades gracefully); the WRITE path (validateGuardrailsConfig) stays strict.
166
+ */
167
+ export function sanitizeGuardrailsConfig(raw) {
168
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { level: 'permissive', custom: null };
169
+ const custom = raw.custom && typeof raw.custom === 'object' && !Array.isArray(raw.custom)
170
+ ? sanitizeGuardrails(raw.custom)
171
+ : null;
172
+ if (typeof raw.level === 'string') {
173
+ return GUARDRAIL_LEVELS.includes(raw.level)
174
+ ? { level: raw.level, custom }
175
+ : { level: 'permissive', custom };
176
+ }
177
+ if (FIVE_KEYS.some((k) => k in raw)) return { level: 'custom', custom: sanitizeGuardrails(raw) };
178
+ return { level: 'permissive', custom: null };
179
+ }
180
+
181
+ /**
182
+ * Write-path validator for the { level, custom? } shape (the API's 400 source).
183
+ * @param {object} raw
184
+ * @param {{hasStoredCustom?: boolean}} opts whether the DB already holds a custom blob
185
+ */
186
+ export function validateGuardrailsConfig(raw, { hasStoredCustom = false } = {}) {
187
+ const errors = [];
188
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
189
+ return { ok: false, errors: ['guardrails must be an object'] };
190
+ }
191
+ if (!GUARDRAIL_LEVELS.includes(raw.level)) {
192
+ errors.push(`level must be one of: ${GUARDRAIL_LEVELS.join(', ')}`);
193
+ }
194
+ const hasCustomPayload = raw.custom !== undefined && raw.custom !== null;
195
+ if (hasCustomPayload) {
196
+ const v = validateGuardrails(raw.custom);
197
+ errors.push(...v.errors);
198
+ } else if (raw.level === 'custom' && !hasStoredCustom) {
199
+ errors.push('custom guardrail settings required when level is "custom"');
200
+ }
201
+ const known = new Set(['level', 'custom']);
202
+ for (const k of Object.keys(raw)) if (!known.has(k)) errors.push(`unknown key "${k}"`);
203
+ return { ok: errors.length === 0, errors };
204
+ }
205
+
206
+ /**
207
+ * Stored config → EFFECTIVE 5-key settings. Preset levels resolve from the code
208
+ * table (fresh copies — the frozen table never leaks); custom resolves from the
209
+ * stored blob (null custom degrades to the empty policy).
210
+ */
211
+ export function resolveGuardrails(stored) {
212
+ const cfg = sanitizeGuardrailsConfig(stored);
213
+ if (cfg.level === 'custom') return cfg.custom ? { ...cfg.custom, envAllowlist: [...cfg.custom.envAllowlist], protectedPaths: [...cfg.custom.protectedPaths], deny: [...cfg.custom.deny] } : sanitizeGuardrails(undefined);
214
+ return sanitizeGuardrails(GUARDRAIL_PRESETS[cfg.level]);
215
+ }
216
+
217
+ const presetKey = (g) => {
218
+ const s = sanitizeGuardrails(g);
219
+ return JSON.stringify({
220
+ honorProjectSettings: s.honorProjectSettings,
221
+ envScrub: s.envScrub,
222
+ envAllowlist: [...new Set(s.envAllowlist)].sort(),
223
+ protectedPaths: [...new Set(s.protectedPaths)].sort(),
224
+ deny: [...new Set(s.deny)].sort(),
225
+ });
226
+ };
227
+
228
+ /**
229
+ * Which built-in preset a 5-key settings object equals, or null (⇒ Custom).
230
+ * Order-insensitive: the lists are semantically sets (everything downstream
231
+ * de-dupes/unions), so a reordered list must not read as "customised".
232
+ */
233
+ export function detectPreset(settings) {
234
+ const key = presetKey(settings);
235
+ for (const level of ['permissive', 'normal', 'secure']) {
236
+ if (presetKey(GUARDRAIL_PRESETS[level]) === key) return level;
237
+ }
238
+ return null;
239
+ }
240
+
241
+ /** Expand 5-key settings into Claude Code permission rules. null when empty. */
242
+ export function guardrailsToPermissionRules(g) {
243
+ const gg = sanitizeGuardrails(g);
244
+ const deny = [];
245
+ const push = (r) => { if (!deny.includes(r)) deny.push(r); };
246
+ // Read + Edit ONLY. Claude Code consults only Read/Edit path rules for file
247
+ // permissions (Edit covers all file-editing tools: Write/NotebookEdit); a
248
+ // Write() rule is never consulted AND prints a stderr warning per rule per
249
+ // spawn on CLI 2.1.210+ (which runReal folds into the failure message). Read
250
+ // is the load-bearing secret guard; a Read deny also blocks Edit (≥2.1.208).
251
+ for (const p of gg.protectedPaths) { push(`Read(${p})`); push(`Edit(${p})`); }
252
+ for (const r of gg.deny) push(r);
253
+ return deny.length ? { deny } : null;
254
+ }
255
+
256
+ /** De-duped union of two {deny?,allow?,ask?} rule objects. null when empty. */
257
+ export function mergePermissionRules(a, b) {
258
+ const out = {};
259
+ for (const key of ['deny', 'allow', 'ask']) {
260
+ const seen = new Set();
261
+ const arr = [];
262
+ for (const src of [a, b]) {
263
+ for (const r of Array.isArray(src?.[key]) ? src[key] : []) {
264
+ if (typeof r === 'string' && r.trim() && !seen.has(r)) { seen.add(r); arr.push(r); }
265
+ }
266
+ }
267
+ if (arr.length) out[key] = arr;
268
+ }
269
+ return Object.keys(out).length ? out : null;
270
+ }
271
+
272
+ /**
273
+ * Deny-safe union across workspace members' EFFECTIVE settings: any member
274
+ * scrubbing scrubs the run; the RESTRICTION lists (protectedPaths, deny) union
275
+ * de-duped across ALL members; the envAllowlist — a WIDENER — unions ONLY over
276
+ * members that actually scrub, so a non-scrubbing member's dormant allowlist can
277
+ * never punch a hole in another member's scrub. More guarding always wins; a
278
+ * member can never relax another member's policy. (Permissive member + Secure
279
+ * member ⇒ Secure for the whole run — by design; surfaced in docs (Task 12) and
280
+ * the panel hint (Task 10).)
281
+ * NOTE: `honorProjectSettings` is unioned here for shape completeness only and is
282
+ * ADVISORY — the repo-settings lift is gated PER MEMBER by each member's own
283
+ * honorProjectSettings (Task 6/7), never by this any-true scalar.
284
+ */
285
+ export function unionGuardrails(list) {
286
+ const gs = (Array.isArray(list) ? list : []).map(sanitizeGuardrails);
287
+ if (!gs.length) return sanitizeGuardrails(undefined);
288
+ const unionList = (src, key) => {
289
+ const seen = new Set();
290
+ const arr = [];
291
+ for (const g of src) for (const s of g[key]) if (!seen.has(s)) { seen.add(s); arr.push(s); }
292
+ return arr;
293
+ };
294
+ const scrubbers = gs.filter((g) => g.envScrub);
295
+ return {
296
+ honorProjectSettings: gs.some((g) => g.honorProjectSettings),
297
+ envScrub: scrubbers.length > 0,
298
+ envAllowlist: unionList(scrubbers, 'envAllowlist'),
299
+ protectedPaths: unionList(gs, 'protectedPaths'),
300
+ deny: unionList(gs, 'deny'),
301
+ };
302
+ }
@@ -0,0 +1,267 @@
1
+ // src/core/marketplaces.mjs
2
+ // Persisted plugin-marketplace registry (marketplace spec §4.2): a git repo
3
+ // (URL, owner/repo, or local path) registered as a discovery source, with a
4
+ // cached discovery snapshot so the Plugins view renders with zero network.
5
+ // File conventions mirror plugins-lock.mjs: reads never throw, writes are
6
+ // temp+rename atomic, unknown keys survive read-modify-write.
7
+ // Installed plugins do NOT depend on this registry — the lock's own
8
+ // repo/subdir/pinnedSha provenance keeps update/uninstall working after a
9
+ // marketplace is removed (spec §4.5).
10
+
11
+ import {
12
+ existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, rmSync,
13
+ } from 'node:fs';
14
+ import { join, resolve, dirname } from 'node:path';
15
+ import { randomBytes } from 'node:crypto';
16
+ import { fileURLToPath } from 'node:url';
17
+ import { pluginsRoot, readPluginsLock } from './plugins-lock.mjs';
18
+ import { addPluginRepo, repoCacheDir, repoSlug } from './plugin-repo.mjs';
19
+ import { inventoryFromCache } from './plugin-inventory.mjs';
20
+
21
+ export function marketplacesFile() { return join(pluginsRoot(), 'marketplaces.json'); }
22
+
23
+ /** owner/repo -> GitHub URL (unless a real local path); URLs lose trailing /
24
+ * and .git; local paths resolve to absolute. null for empty input. */
25
+ export function normalizeMarketplaceUrl(input) {
26
+ let s = String(input ?? '').trim();
27
+ if (!s) return null;
28
+ if (/^[\w.-]+\/[\w.-]+$/.test(s) && !existsSync(s)) s = `https://github.com/${s}`;
29
+ if (/^[a-z+]+:\/\//i.test(s)) return s.replace(/\/+$/, '').replace(/\.git$/i, '');
30
+ // scp-style git remote ([user@]host:path) — a git URL, never a local path.
31
+ if (/^[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:(?!\/)/.test(s)) return s.replace(/\/+$/, '').replace(/\.git$/i, '');
32
+ return resolve(s);
33
+ }
34
+
35
+ // normalizeMarketplaceUrl is idempotent (C2), so hashing the normalized url is
36
+ // stable even when addMarketplace has already normalized before calling.
37
+ export function marketplaceId(url) { return repoSlug(normalizeMarketplaceUrl(url) ?? String(url)); }
38
+
39
+ /** Missing/corrupt/non-object -> empty state. Entries not normalized on read. */
40
+ export function readMarketplaces() {
41
+ try {
42
+ const v = JSON.parse(readFileSync(marketplacesFile(), 'utf8'));
43
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
44
+ return {
45
+ ...v, // unknown keys survive read-modify-write; normalized fields below win
46
+ seededBuiltin: v.seededBuiltin === true,
47
+ marketplaces: v.marketplaces && typeof v.marketplaces === 'object' && !Array.isArray(v.marketplaces)
48
+ ? Object.assign(Object.create(null), v.marketplaces) : Object.create(null),
49
+ };
50
+ }
51
+ } catch { /* fall through */ }
52
+ return { seededBuiltin: false, marketplaces: Object.create(null) };
53
+ }
54
+
55
+ export function writeMarketplaces(state) {
56
+ const file = marketplacesFile();
57
+ mkdirSync(pluginsRoot(), { recursive: true });
58
+ const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify(state ?? { seededBuiltin: false, marketplaces: {} }, null, 2) + '\n', 'utf8');
60
+ renameSync(tmp, file);
61
+ return state;
62
+ }
63
+
64
+ // relTimeCore(iso, now) -> "just now"/"5m ago"/"3h ago"/"2d ago"/ISO date. Pure;
65
+ // mirrors plugins-view.mjs relTime so core has no DOM dependency (C4).
66
+ function relTimeCore(iso, now = Date.now()) {
67
+ const t = Date.parse(iso);
68
+ if (!Number.isFinite(t)) return String(iso || 'unknown');
69
+ const s = Math.max(0, Math.round((now - t) / 1000));
70
+ if (s < 45) return 'just now';
71
+ const m = Math.round(s / 60); if (m < 60) return `${m}m ago`;
72
+ const h = Math.round(m / 60); if (h < 24) return `${h}h ago`;
73
+ const d = Math.round(h / 24); if (d < 30) return `${d}d ago`;
74
+ return String(iso).slice(0, 10);
75
+ }
76
+
77
+ /** Reduce a multi-line git failure to its first `fatal:` line; map the empty-repo
78
+ * case to plain language. Avoids leaking the internal .cache path into the UI. */
79
+ function firstFatal(err) {
80
+ const raw = String(err?.stderr || err?.message || err || '');
81
+ if (/unknown revision|ambiguous argument 'HEAD'/.test(raw)) return 'repository has no commits yet';
82
+ return raw.split('\n').map((l) => l.trim()).find((l) => l.startsWith('fatal:'))?.replace(/^fatal:\s*/, '')
83
+ || (raw.split('\n')[0] || 'unknown error');
84
+ }
85
+
86
+ /** Discovery + per-plugin consent inventories -> fresh snapshot on the entry.
87
+ * Failure keeps the previous snapshot and records a warning (stale-but-usable). */
88
+ async function syncEntry(entry, { exec } = {}) {
89
+ try {
90
+ const found = await addPluginRepo(entry.url, ...(exec ? [{ exec }] : []));
91
+ const plugins = [];
92
+ for (const d of found.discovered) {
93
+ plugins.push({
94
+ name: d.name,
95
+ subdir: d.subdir,
96
+ description: d.manifest.description ?? '',
97
+ version: d.manifest.version ?? null,
98
+ inventory: await inventoryFromCache(found.repoUrl, found.sha, d.subdir, ...(exec ? [{ exec }] : [])),
99
+ });
100
+ }
101
+ if (found.marketplace) {
102
+ entry.name = found.marketplace.name;
103
+ entry.description = found.marketplace.description;
104
+ }
105
+ entry.lastSync = { sha: found.sha, at: new Date().toISOString() };
106
+ entry.plugins = plugins;
107
+ entry.warnings = found.warnings;
108
+ } catch (err) {
109
+ const when = entry.lastSync ? `last sync ${relTimeCore(entry.lastSync.at)}` : 'never synced';
110
+ const msg = firstFatal(err);
111
+ entry.warnings = [`${when}; refresh failed: ${msg}`,
112
+ ...(entry.warnings || []).filter((w) => !/refresh failed:/.test(w))];
113
+ }
114
+ return entry;
115
+ }
116
+
117
+ /** Register + immediately sync. Throws EXISTS on a duplicate (normalized) url.
118
+ * A first-sync failure throws WITHOUT recording — a typo'd url never leaves a
119
+ * junk entry (spec §4.2). The insert RE-READS the registry after the long git
120
+ * sync (B8) — inserting into the pre-sync snapshot would resurrect entries
121
+ * removed meanwhile and drop concurrent adds (lost update). */
122
+ export async function addMarketplace(url, { exec } = {}) {
123
+ const norm = normalizeMarketplaceUrl(url);
124
+ if (!norm) throw Object.assign(new Error('marketplace url is required'), { code: 'BAD_REQUEST' });
125
+ const id = marketplaceId(norm);
126
+ if (readMarketplaces().marketplaces[id]) { // cheap pre-check: fail before any git work
127
+ throw Object.assign(new Error(`marketplace already added: ${norm}`), { code: 'EXISTS' });
128
+ }
129
+ const entry = {
130
+ id, url: norm, name: id, description: '',
131
+ addedAt: new Date().toISOString(), lastSync: null, plugins: [], warnings: [],
132
+ };
133
+ await syncEntry(entry, { exec });
134
+ if (!entry.lastSync) {
135
+ // C8: drop the orphan bare cache the failed clone left — but never a cache an
136
+ // installed plugin still shares (transient failures must not evict it).
137
+ const inUse = Object.values(readPluginsLock()).some((e) =>
138
+ e && e.repo && (e.repo === norm || normalizeMarketplaceUrl(e.repo) === norm));
139
+ if (!inUse) rmSync(repoCacheDir(norm), { recursive: true, force: true });
140
+ throw Object.assign(new Error(entry.warnings[0] || `could not read ${norm}`), { code: 'BAD_REQUEST' });
141
+ }
142
+ const state = readMarketplaces(); // B8: LATEST state, not the pre-sync snapshot
143
+ if (state.marketplaces[id]) {
144
+ throw Object.assign(new Error(`marketplace already added: ${norm}`), { code: 'EXISTS' });
145
+ }
146
+ state.marketplaces[id] = entry;
147
+ writeMarketplaces(state);
148
+ return entry;
149
+ }
150
+
151
+ /** Read-modify-write the LATEST state, one entry only — so a long git sync can't
152
+ * clobber a concurrent remove/add. Drops the write if the entry vanished. */
153
+ function mutateEntry(id, apply) {
154
+ const state = readMarketplaces();
155
+ if (!Object.hasOwn(state.marketplaces, id)) return null;
156
+ apply(state.marketplaces[id]);
157
+ writeMarketplaces(state);
158
+ return state.marketplaces[id];
159
+ }
160
+
161
+ export async function syncMarketplace(id, { exec } = {}) {
162
+ const cur = readMarketplaces().marketplaces[id];
163
+ if (!cur) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
164
+ const clone = { ...cur, plugins: [...(cur.plugins || [])], warnings: [...(cur.warnings || [])] };
165
+ await syncEntry(clone, { exec });
166
+ const saved = mutateEntry(id, (e) => Object.assign(e, {
167
+ name: clone.name, description: clone.description, lastSync: clone.lastSync,
168
+ plugins: clone.plugins, warnings: clone.warnings,
169
+ }));
170
+ if (!saved) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
171
+ return saved;
172
+ }
173
+
174
+ /** Sequential, per-entry tolerant: a dead repo (sync warning) AND an entry removed
175
+ * mid-refresh (NOT_FOUND) both leave the others intact. */
176
+ export async function refreshAllMarketplaces({ exec } = {}) {
177
+ const out = [];
178
+ for (const id of Object.keys(readMarketplaces().marketplaces).sort()) {
179
+ try { out.push(await syncMarketplace(id, { exec })); }
180
+ catch (err) { if (err?.code !== 'NOT_FOUND') throw err; }
181
+ }
182
+ return out;
183
+ }
184
+
185
+ /** Remove the registry entry + snapshot. Installed plugins are untouched; the
186
+ * bare cache goes only when no plugins.lock.json entry shares the repo. */
187
+ export function removeMarketplace(id) {
188
+ const state = readMarketplaces();
189
+ const entry = state.marketplaces[id];
190
+ if (!entry) throw Object.assign(new Error(`marketplace "${id}" not found`), { code: 'NOT_FOUND' });
191
+ const inUse = Object.values(readPluginsLock()).some((e) =>
192
+ e && e.repo && (e.repo === entry.url || normalizeMarketplaceUrl(e.repo) === entry.url));
193
+ delete state.marketplaces[id];
194
+ writeMarketplaces(state);
195
+ if (!inUse) rmSync(repoCacheDir(entry.url), { recursive: true, force: true });
196
+ return { ok: true, id };
197
+ }
198
+
199
+ export function listMarketplaces() {
200
+ const state = readMarketplaces();
201
+ return Object.values(state.marketplaces)
202
+ .sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id)));
203
+ }
204
+
205
+ /** Install-source resolution (spec §4.10): explicit --repo > lock provenance >
206
+ * unique snapshot match > {candidates} on ambiguity > null. */
207
+ export function resolveInstallSource(name, { repo } = {}) {
208
+ if (repo) return { repoUrl: normalizeMarketplaceUrl(repo), subdir: null, sha: null, marketplace: null };
209
+ const lockEntry = readPluginsLock()[name];
210
+ if (lockEntry && lockEntry.repo) {
211
+ return { repoUrl: lockEntry.repo, subdir: lockEntry.subdir ?? null, sha: null, marketplace: lockEntry.marketplace ?? null };
212
+ }
213
+ const hits = [];
214
+ for (const m of listMarketplaces()) {
215
+ for (const p of m.plugins || []) {
216
+ if (p.name === name) {
217
+ hits.push({ repoUrl: m.url, subdir: p.subdir, sha: m.lastSync ? m.lastSync.sha : null, marketplace: m.id });
218
+ }
219
+ }
220
+ }
221
+ if (hits.length === 1) return hits[0];
222
+ if (hits.length > 1) return { candidates: hits };
223
+ return null;
224
+ }
225
+
226
+ /** The checkout the host code runs from: two dirs above src/core/. Only a real
227
+ * marketplace checkout counts (must have worca-cc-marketplace.json + .git) —
228
+ * an npm-dist install without either returns null and seeding is skipped. */
229
+ export function hostRepoRoot() {
230
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
231
+ return existsSync(join(root, 'worca-cc-marketplace.json')) && existsSync(join(root, '.git'))
232
+ ? root : null;
233
+ }
234
+
235
+ /** First-run builtin seed (spec §4.2): register the host checkout as a
236
+ * local-path marketplace. NO git operations — plugins:[] / lastSync:null; the
237
+ * first sync happens via the Plugins view's background refresh or an explicit
238
+ * `worca marketplace refresh`. seededBuiltin is set only on success, so a
239
+ * removed builtin never auto-returns, while a non-checkout host stays eligible
240
+ * to seed on a later run from a real checkout. */
241
+ export function seedBuiltinMarketplace({ rootDir = hostRepoRoot() } = {}) {
242
+ const state = readMarketplaces();
243
+ if (state.seededBuiltin) return { seeded: false, reason: 'already-seeded' };
244
+ // Same contract as hostRepoRoot for an INJECTED rootDir too: a real checkout must
245
+ // carry both the manifest and .git, else this is an npm-dist dir — skip (E7).
246
+ if (!rootDir || !existsSync(join(rootDir, 'worca-cc-marketplace.json')) || !existsSync(join(rootDir, '.git'))) {
247
+ return { seeded: false, reason: 'no-host-checkout' };
248
+ }
249
+ const url = resolve(rootDir);
250
+ const id = marketplaceId(url);
251
+ let name = 'Worca CC Official';
252
+ let description = '';
253
+ try {
254
+ const raw = JSON.parse(readFileSync(join(rootDir, 'worca-cc-marketplace.json'), 'utf8'));
255
+ if (typeof raw?.name === 'string' && raw.name.trim()) name = raw.name.trim();
256
+ if (typeof raw?.description === 'string') description = raw.description.trim();
257
+ } catch { /* keep defaults */ }
258
+ if (!state.marketplaces[id]) {
259
+ state.marketplaces[id] = {
260
+ id, url, name, description, builtin: true,
261
+ addedAt: new Date().toISOString(), lastSync: null, plugins: [], warnings: [],
262
+ };
263
+ }
264
+ state.seededBuiltin = true;
265
+ writeMarketplaces(state);
266
+ return { seeded: true, id };
267
+ }