hammoc 1.5.0 → 1.6.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.
Files changed (197) hide show
  1. package/README.md +8 -2
  2. package/package.json +2 -2
  3. package/packages/client/dist/assets/{agentExampleHighlight-BgwTm15v.js → agentExampleHighlight-ltj9ce0U.js} +1 -1
  4. package/packages/client/dist/assets/{commandTokenHighlight-BljHwnrK.js → commandTokenHighlight-ji_ViMb4.js} +1 -1
  5. package/packages/client/dist/assets/{index-D3LxqW3f.js → index-B-DiRGuz.js} +1 -1
  6. package/packages/client/dist/assets/index-B09doO8H.js +139 -0
  7. package/packages/client/dist/assets/{index-NqJdhlek.js → index-BT4RIi0U.js} +535 -510
  8. package/packages/client/dist/assets/index-DyNJ5jEW.css +32 -0
  9. package/packages/client/dist/assets/{snippetTokenHighlight-DWsaQXX0.js → snippetTokenHighlight-CP3v4o2g.js} +1 -1
  10. package/packages/client/dist/index.html +2 -2
  11. package/packages/client/dist/sw.js +1 -1
  12. package/packages/server/dist/controllers/bmadCoreConfigController.d.ts +41 -0
  13. package/packages/server/dist/controllers/bmadCoreConfigController.d.ts.map +1 -0
  14. package/packages/server/dist/controllers/bmadCoreConfigController.js +172 -0
  15. package/packages/server/dist/controllers/bmadCoreConfigController.js.map +1 -0
  16. package/packages/server/dist/controllers/contextBuilderController.d.ts +43 -0
  17. package/packages/server/dist/controllers/contextBuilderController.d.ts.map +1 -0
  18. package/packages/server/dist/controllers/contextBuilderController.js +159 -0
  19. package/packages/server/dist/controllers/contextBuilderController.js.map +1 -0
  20. package/packages/server/dist/controllers/harnessAgentController.d.ts +7 -0
  21. package/packages/server/dist/controllers/harnessAgentController.d.ts.map +1 -1
  22. package/packages/server/dist/controllers/harnessAgentController.js +33 -0
  23. package/packages/server/dist/controllers/harnessAgentController.js.map +1 -1
  24. package/packages/server/dist/controllers/harnessBundleController.d.ts +37 -0
  25. package/packages/server/dist/controllers/harnessBundleController.d.ts.map +1 -0
  26. package/packages/server/dist/controllers/harnessBundleController.js +312 -0
  27. package/packages/server/dist/controllers/harnessBundleController.js.map +1 -0
  28. package/packages/server/dist/controllers/harnessCommandController.d.ts +7 -0
  29. package/packages/server/dist/controllers/harnessCommandController.d.ts.map +1 -1
  30. package/packages/server/dist/controllers/harnessCommandController.js +33 -0
  31. package/packages/server/dist/controllers/harnessCommandController.js.map +1 -1
  32. package/packages/server/dist/controllers/harnessHookController.d.ts.map +1 -1
  33. package/packages/server/dist/controllers/harnessHookController.js +44 -1
  34. package/packages/server/dist/controllers/harnessHookController.js.map +1 -1
  35. package/packages/server/dist/controllers/harnessMcpController.d.ts.map +1 -1
  36. package/packages/server/dist/controllers/harnessMcpController.js +62 -1
  37. package/packages/server/dist/controllers/harnessMcpController.js.map +1 -1
  38. package/packages/server/dist/controllers/harnessShareScopeController.d.ts +9 -0
  39. package/packages/server/dist/controllers/harnessShareScopeController.d.ts.map +1 -1
  40. package/packages/server/dist/controllers/harnessShareScopeController.js +48 -1
  41. package/packages/server/dist/controllers/harnessShareScopeController.js.map +1 -1
  42. package/packages/server/dist/controllers/marketplaceController.d.ts +19 -0
  43. package/packages/server/dist/controllers/marketplaceController.d.ts.map +1 -0
  44. package/packages/server/dist/controllers/marketplaceController.js +74 -0
  45. package/packages/server/dist/controllers/marketplaceController.js.map +1 -0
  46. package/packages/server/dist/controllers/observabilityController.d.ts +32 -0
  47. package/packages/server/dist/controllers/observabilityController.d.ts.map +1 -0
  48. package/packages/server/dist/controllers/observabilityController.js +148 -0
  49. package/packages/server/dist/controllers/observabilityController.js.map +1 -0
  50. package/packages/server/dist/handlers/streamCallbacks.d.ts +8 -0
  51. package/packages/server/dist/handlers/streamCallbacks.d.ts.map +1 -1
  52. package/packages/server/dist/handlers/streamCallbacks.js +8 -0
  53. package/packages/server/dist/handlers/streamCallbacks.js.map +1 -1
  54. package/packages/server/dist/handlers/websocket.d.ts.map +1 -1
  55. package/packages/server/dist/handlers/websocket.js +24 -2
  56. package/packages/server/dist/handlers/websocket.js.map +1 -1
  57. package/packages/server/dist/routes/harness.d.ts.map +1 -1
  58. package/packages/server/dist/routes/harness.js +58 -0
  59. package/packages/server/dist/routes/harness.js.map +1 -1
  60. package/packages/server/dist/services/bmadCoreConfigService.d.ts +86 -0
  61. package/packages/server/dist/services/bmadCoreConfigService.d.ts.map +1 -0
  62. package/packages/server/dist/services/bmadCoreConfigService.js +175 -0
  63. package/packages/server/dist/services/bmadCoreConfigService.js.map +1 -0
  64. package/packages/server/dist/services/bmadStatusService.d.ts +9 -0
  65. package/packages/server/dist/services/bmadStatusService.d.ts.map +1 -1
  66. package/packages/server/dist/services/bmadStatusService.js +59 -6
  67. package/packages/server/dist/services/bmadStatusService.js.map +1 -1
  68. package/packages/server/dist/services/chatService.js +1 -1
  69. package/packages/server/dist/services/chatService.js.map +1 -1
  70. package/packages/server/dist/services/contextBuilderScriptTemplate.d.ts +24 -0
  71. package/packages/server/dist/services/contextBuilderScriptTemplate.d.ts.map +1 -0
  72. package/packages/server/dist/services/contextBuilderScriptTemplate.js +181 -0
  73. package/packages/server/dist/services/contextBuilderScriptTemplate.js.map +1 -0
  74. package/packages/server/dist/services/contextBuilderService.d.ts +68 -0
  75. package/packages/server/dist/services/contextBuilderService.d.ts.map +1 -0
  76. package/packages/server/dist/services/contextBuilderService.js +345 -0
  77. package/packages/server/dist/services/contextBuilderService.js.map +1 -0
  78. package/packages/server/dist/services/fileWatcherService.d.ts.map +1 -1
  79. package/packages/server/dist/services/fileWatcherService.js +40 -0
  80. package/packages/server/dist/services/fileWatcherService.js.map +1 -1
  81. package/packages/server/dist/services/harnessAgentService.d.ts +18 -0
  82. package/packages/server/dist/services/harnessAgentService.d.ts.map +1 -1
  83. package/packages/server/dist/services/harnessAgentService.js +55 -0
  84. package/packages/server/dist/services/harnessAgentService.js.map +1 -1
  85. package/packages/server/dist/services/harnessBundleService.d.ts +145 -0
  86. package/packages/server/dist/services/harnessBundleService.d.ts.map +1 -0
  87. package/packages/server/dist/services/harnessBundleService.js +1318 -0
  88. package/packages/server/dist/services/harnessBundleService.js.map +1 -0
  89. package/packages/server/dist/services/harnessCommandService.d.ts +21 -0
  90. package/packages/server/dist/services/harnessCommandService.d.ts.map +1 -1
  91. package/packages/server/dist/services/harnessCommandService.js +64 -0
  92. package/packages/server/dist/services/harnessCommandService.js.map +1 -1
  93. package/packages/server/dist/services/harnessHookService.d.ts +27 -0
  94. package/packages/server/dist/services/harnessHookService.d.ts.map +1 -1
  95. package/packages/server/dist/services/harnessHookService.js +52 -0
  96. package/packages/server/dist/services/harnessHookService.js.map +1 -1
  97. package/packages/server/dist/services/harnessMcpService.d.ts +24 -1
  98. package/packages/server/dist/services/harnessMcpService.d.ts.map +1 -1
  99. package/packages/server/dist/services/harnessMcpService.js +70 -0
  100. package/packages/server/dist/services/harnessMcpService.js.map +1 -1
  101. package/packages/server/dist/services/harnessShareScopeService.d.ts +19 -0
  102. package/packages/server/dist/services/harnessShareScopeService.d.ts.map +1 -1
  103. package/packages/server/dist/services/harnessShareScopeService.js +65 -0
  104. package/packages/server/dist/services/harnessShareScopeService.js.map +1 -1
  105. package/packages/server/dist/services/issueService.d.ts.map +1 -1
  106. package/packages/server/dist/services/issueService.js +1 -0
  107. package/packages/server/dist/services/issueService.js.map +1 -1
  108. package/packages/server/dist/services/marketplaceService.d.ts +50 -0
  109. package/packages/server/dist/services/marketplaceService.d.ts.map +1 -0
  110. package/packages/server/dist/services/marketplaceService.js +326 -0
  111. package/packages/server/dist/services/marketplaceService.js.map +1 -0
  112. package/packages/server/dist/services/observabilityService.d.ts +87 -0
  113. package/packages/server/dist/services/observabilityService.d.ts.map +1 -0
  114. package/packages/server/dist/services/observabilityService.js +0 -0
  115. package/packages/server/dist/services/observabilityService.js.map +1 -0
  116. package/packages/server/dist/services/queueService.d.ts.map +1 -1
  117. package/packages/server/dist/services/queueService.js +3 -0
  118. package/packages/server/dist/services/queueService.js.map +1 -1
  119. package/packages/server/dist/services/sessionService.d.ts +16 -0
  120. package/packages/server/dist/services/sessionService.d.ts.map +1 -1
  121. package/packages/server/dist/services/sessionService.js +125 -0
  122. package/packages/server/dist/services/sessionService.js.map +1 -1
  123. package/packages/server/dist/services/tokenCountService.d.ts +71 -0
  124. package/packages/server/dist/services/tokenCountService.d.ts.map +1 -0
  125. package/packages/server/dist/services/tokenCountService.js +313 -0
  126. package/packages/server/dist/services/tokenCountService.js.map +1 -0
  127. package/packages/server/dist/snippets/apply-qa-fixes +7 -5
  128. package/packages/server/dist/snippets/qa-review +5 -1
  129. package/packages/server/dist/utils/assertSafeBundlePath.d.ts +29 -0
  130. package/packages/server/dist/utils/assertSafeBundlePath.d.ts.map +1 -0
  131. package/packages/server/dist/utils/assertSafeBundlePath.js +53 -0
  132. package/packages/server/dist/utils/assertSafeBundlePath.js.map +1 -0
  133. package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts +7 -0
  134. package/packages/server/dist/utils/bundledBinaryModelSupport.d.ts.map +1 -0
  135. package/packages/server/dist/utils/bundledBinaryModelSupport.js +107 -0
  136. package/packages/server/dist/utils/bundledBinaryModelSupport.js.map +1 -0
  137. package/packages/server/dist/utils/effortUtils.d.ts +2 -2
  138. package/packages/server/dist/utils/effortUtils.js +5 -5
  139. package/packages/server/dist/utils/effortUtils.js.map +1 -1
  140. package/packages/server/dist/utils/errors.d.ts +1 -0
  141. package/packages/server/dist/utils/errors.d.ts.map +1 -1
  142. package/packages/server/dist/utils/errors.js +17 -0
  143. package/packages/server/dist/utils/errors.js.map +1 -1
  144. package/packages/server/dist/utils/harnessBundleSchema.d.ts +14 -12
  145. package/packages/server/dist/utils/harnessBundleSchema.d.ts.map +1 -1
  146. package/packages/server/dist/utils/harnessBundleSchema.js +11 -1
  147. package/packages/server/dist/utils/harnessBundleSchema.js.map +1 -1
  148. package/packages/server/dist/utils/harnessPaths.d.ts +40 -0
  149. package/packages/server/dist/utils/harnessPaths.d.ts.map +1 -1
  150. package/packages/server/dist/utils/harnessPaths.js +123 -0
  151. package/packages/server/dist/utils/harnessPaths.js.map +1 -1
  152. package/packages/server/package.json +2 -1
  153. package/packages/server/resources/internals/INDEX.md +3 -1
  154. package/packages/server/resources/internals/bmad-qa-fix-marker.md +32 -0
  155. package/packages/server/resources/internals/harness-files.md +22 -0
  156. package/packages/server/resources/internals/observability-storage.md +23 -0
  157. package/packages/server/resources/manual/02-chat.md +2 -2
  158. package/packages/server/resources/manual/05-projects.md +3 -1
  159. package/packages/server/resources/manual/10-project-board.md +4 -3
  160. package/packages/server/resources/manual/11-bmad-method-integration.md +10 -8
  161. package/packages/server/resources/manual/12-harness-workbench.md +82 -1
  162. package/packages/server/resources/manual/13-settings.md +4 -4
  163. package/packages/shared/dist/index.d.ts +4 -0
  164. package/packages/shared/dist/index.d.ts.map +1 -1
  165. package/packages/shared/dist/index.js +8 -0
  166. package/packages/shared/dist/index.js.map +1 -1
  167. package/packages/shared/dist/types/bmadCoreConfig.d.ts +71 -0
  168. package/packages/shared/dist/types/bmadCoreConfig.d.ts.map +1 -0
  169. package/packages/shared/dist/types/bmadCoreConfig.js +30 -0
  170. package/packages/shared/dist/types/bmadCoreConfig.js.map +1 -0
  171. package/packages/shared/dist/types/bmadStatus.d.ts +10 -0
  172. package/packages/shared/dist/types/bmadStatus.d.ts.map +1 -1
  173. package/packages/shared/dist/types/bmadStatus.js.map +1 -1
  174. package/packages/shared/dist/types/board.d.ts +6 -0
  175. package/packages/shared/dist/types/board.d.ts.map +1 -1
  176. package/packages/shared/dist/types/contextBuilder.d.ts +102 -0
  177. package/packages/shared/dist/types/contextBuilder.d.ts.map +1 -0
  178. package/packages/shared/dist/types/contextBuilder.js +55 -0
  179. package/packages/shared/dist/types/contextBuilder.js.map +1 -0
  180. package/packages/shared/dist/types/harnessBundle.d.ts +35 -0
  181. package/packages/shared/dist/types/harnessBundle.d.ts.map +1 -1
  182. package/packages/shared/dist/types/marketplace.d.ts +83 -0
  183. package/packages/shared/dist/types/marketplace.d.ts.map +1 -0
  184. package/packages/shared/dist/types/marketplace.js +18 -0
  185. package/packages/shared/dist/types/marketplace.js.map +1 -0
  186. package/packages/shared/dist/types/observability.d.ts +148 -0
  187. package/packages/shared/dist/types/observability.d.ts.map +1 -0
  188. package/packages/shared/dist/types/observability.js +24 -0
  189. package/packages/shared/dist/types/observability.js.map +1 -0
  190. package/packages/shared/dist/types/preferences.d.ts +2 -0
  191. package/packages/shared/dist/types/preferences.d.ts.map +1 -1
  192. package/packages/shared/dist/types/preferences.js.map +1 -1
  193. package/packages/shared/dist/types/sdk.d.ts +1 -1
  194. package/packages/shared/dist/types/sdk.d.ts.map +1 -1
  195. package/packages/shared/dist/types/sdk.js +1 -1
  196. package/packages/shared/dist/types/sdk.js.map +1 -1
  197. package/packages/client/dist/assets/index-CjyjnXB8.css +0 -32
@@ -0,0 +1,1318 @@
1
+ /**
2
+ * Story 30.5 (Task A): Harness Export/Import bundle service — server-side
3
+ * single source of truth that backs the 4 REST endpoints
4
+ * (`/api/harness/bundle/export`, `.../import/preview`, `.../import/apply`,
5
+ * `.../plugin-deps`).
6
+ *
7
+ * Two public methods:
8
+ * - `export({ projectSlug, includes, secretsPolicy, acknowledgedSecretInclusion? })`
9
+ * produces a single `{ zipBuffer, manifest, filename }` triple. The ZIP
10
+ * contains `manifest.json` at the root plus the 5 R/W domain artefacts +
11
+ * CLAUDE.md + BMad core-config (when opted in). Each text payload is
12
+ * filtered through the chosen secrets policy via the helpers absorbed from
13
+ * Task 2 (`applyPolicyToValue` / `applyPolicyToText`). For the
14
+ * `included-explicit` policy the filename is force-suffixed with
15
+ * `-WITH-SECRETS` and the caller must supply
16
+ * `acknowledgedSecretInclusion: true` or the call throws AC2.d-2.
17
+ *
18
+ * - `import({ projectSlug, zipBuffer, dryRun, itemActions? })` parses the ZIP,
19
+ * runs Zod validation of `manifest.json`, computes a per-item preview
20
+ * (`new`/`overwrite`/`same`/`conflict`) by comparing each bundle item to
21
+ * the current project state, and — when `dryRun === false` — applies the
22
+ * selected `itemActions` inside a single in-memory transaction. Pre-state
23
+ * snapshots are captured up-front so a mid-flight failure can be reversed
24
+ * in reverse order (AC1 + A.3 rollback semantics).
25
+ *
26
+ * Single sources of truth this service depends on (no duplicate logic here):
27
+ * - `secretHeuristic.ts` (Story 30.1) → pattern detection
28
+ * - `applySecretsPolicy.ts` (Task 2) → 3-policy payload rewriting
29
+ * - `secretPlaceholderNamer.ts` (Task 2) → ENV-ref naming for placeholder mode
30
+ * - `harnessBundleSchema.ts` (Task 4) → Zod validation of manifest.json
31
+ * - `assertSafeBundlePath.ts` (Task A.5) → ZIP-slip guard for every entry
32
+ *
33
+ * The bundle service intentionally does file I/O via raw `fs` (with a
34
+ * `fileWatcherService.noteLocalWrite` call after each write) rather than
35
+ * routing through the per-domain services. Reasons:
36
+ * 1. The per-domain services have card-shaped (un-grouped) input/output —
37
+ * a bundle round-trip needs file-shaped I/O.
38
+ * 2. The per-domain services contain Story-30.1 share-scope guards that
39
+ * block plaintext secrets on shared paths; the bundle import path lets
40
+ * `included-explicit` bundles land plaintext into the project (that is
41
+ * the entire point of the policy) so going through the share-scope
42
+ * guard would be wrong.
43
+ * 3. The `pendingLocalWrites` echo-suppression window is wired directly on
44
+ * `fileWatcherService` so calling `noteLocalWrite` is sufficient — see
45
+ * `harnessService.write` which does the same pair (`fs.writeFile` +
46
+ * `noteLocalWrite`) under the hood.
47
+ */
48
+ import fs from 'fs/promises';
49
+ import os from 'os';
50
+ import path from 'path';
51
+ import { randomUUID } from 'crypto';
52
+ import JSZip from 'jszip';
53
+ import { HARNESS_BUNDLE_VERSION, } from '@hammoc/shared';
54
+ import { projectService } from './projectService.js';
55
+ import { fileWatcherService } from './fileWatcherService.js';
56
+ import { harnessPluginService } from './harnessPluginService.js';
57
+ import { harnessShareScopeService } from './harnessShareScopeService.js';
58
+ import { applyPolicyToText, applyPolicyToValue } from '../utils/applySecretsPolicy.js';
59
+ import { bundleManifestSchema, loosenedManifestSchema, } from '../utils/harnessBundleSchema.js';
60
+ import { assertSafeBundlePath } from '../utils/assertSafeBundlePath.js';
61
+ const KNOWN_BUNDLE_SECTIONS = new Set([
62
+ 'claude-md',
63
+ 'skills',
64
+ 'commands',
65
+ 'agents',
66
+ 'hooks',
67
+ 'mcp',
68
+ 'bmad',
69
+ ]);
70
+ /** TTL for stashed import ZIPs (preview → apply window). */
71
+ const IMPORT_BUNDLE_TTL_MS = 30 * 60 * 1000;
72
+ /** Hammoc package.json version — loaded once at module load time. */
73
+ async function readHammocVersion() {
74
+ try {
75
+ // packages/server/dist/services/harnessBundleService.js (prod) or
76
+ // packages/server/src/services/harnessBundleService.ts (test) — walk up
77
+ // until we find the repo root package.json.
78
+ const candidates = [
79
+ // monorepo root after compile
80
+ path.resolve(process.cwd(), 'package.json'),
81
+ // packages/server during vitest
82
+ path.resolve(process.cwd(), '..', '..', 'package.json'),
83
+ ];
84
+ for (const p of candidates) {
85
+ try {
86
+ const txt = await fs.readFile(p, 'utf-8');
87
+ const obj = JSON.parse(txt);
88
+ if (typeof obj.version === 'string' && obj.name === 'hammoc') {
89
+ return obj.version;
90
+ }
91
+ }
92
+ catch {
93
+ // try next candidate
94
+ }
95
+ }
96
+ }
97
+ catch {
98
+ // fall through
99
+ }
100
+ return '0.0.0';
101
+ }
102
+ /**
103
+ * Recursively read every regular file under `dirRoot`. Returns POSIX-style
104
+ * relative paths (relative to `dirRoot`) suitable for ZIP entries.
105
+ */
106
+ async function walkFiles(dirRoot) {
107
+ const out = [];
108
+ async function recurse(current, prefix) {
109
+ let entries;
110
+ try {
111
+ entries = await fs.readdir(current, { withFileTypes: true });
112
+ }
113
+ catch {
114
+ return;
115
+ }
116
+ for (const ent of entries) {
117
+ const abs = path.join(current, ent.name);
118
+ const rel = prefix.length === 0 ? ent.name : `${prefix}/${ent.name}`;
119
+ if (ent.isDirectory()) {
120
+ await recurse(abs, rel);
121
+ }
122
+ else if (ent.isFile()) {
123
+ let size = 0;
124
+ try {
125
+ size = (await fs.stat(abs)).size;
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ out.push({ relativePath: rel, absolutePath: abs, size });
131
+ }
132
+ }
133
+ }
134
+ await recurse(dirRoot, '');
135
+ return out;
136
+ }
137
+ async function pathExists(p) {
138
+ try {
139
+ await fs.stat(p);
140
+ return true;
141
+ }
142
+ catch {
143
+ return false;
144
+ }
145
+ }
146
+ async function readTextIfPresent(absolutePath) {
147
+ try {
148
+ return await fs.readFile(absolutePath, 'utf-8');
149
+ }
150
+ catch (err) {
151
+ if (err.code === 'ENOENT')
152
+ return null;
153
+ throw err;
154
+ }
155
+ }
156
+ async function readJsonIfPresent(absolutePath) {
157
+ const text = await readTextIfPresent(absolutePath);
158
+ if (text === null)
159
+ return null;
160
+ const trimmed = text.trim();
161
+ if (!trimmed)
162
+ return null;
163
+ try {
164
+ return JSON.parse(trimmed);
165
+ }
166
+ catch {
167
+ return null;
168
+ }
169
+ }
170
+ async function snapshot(absolutePath) {
171
+ try {
172
+ const stat = await fs.stat(absolutePath);
173
+ const buf = await fs.readFile(absolutePath);
174
+ return { absolutePath, content: buf, mtime: stat.mtime.toISOString() };
175
+ }
176
+ catch (err) {
177
+ if (err.code === 'ENOENT') {
178
+ return { absolutePath, content: null, mtime: '' };
179
+ }
180
+ throw err;
181
+ }
182
+ }
183
+ async function restoreSnapshot(s) {
184
+ if (s.content === null) {
185
+ try {
186
+ await fs.unlink(s.absolutePath);
187
+ }
188
+ catch {
189
+ // already gone — fine
190
+ }
191
+ return;
192
+ }
193
+ await fs.mkdir(path.dirname(s.absolutePath), { recursive: true });
194
+ await fs.writeFile(s.absolutePath, s.content);
195
+ fileWatcherService.noteLocalWrite(s.absolutePath);
196
+ }
197
+ async function writeBundleFile(absolutePath, content) {
198
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
199
+ await fs.writeFile(absolutePath, content);
200
+ fileWatcherService.noteLocalWrite(absolutePath);
201
+ }
202
+ /** Strip a leading `mcpServers` field so `applyPolicyToValue` can walk it. */
203
+ function pickMcpServers(raw) {
204
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
205
+ return {};
206
+ const v = raw.mcpServers;
207
+ if (!v || typeof v !== 'object' || Array.isArray(v))
208
+ return {};
209
+ return v;
210
+ }
211
+ /** Strip a leading `hooks` field so `applyPolicyToValue` can walk it. */
212
+ function pickHooks(raw) {
213
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
214
+ return {};
215
+ const v = raw.hooks;
216
+ if (!v || typeof v !== 'object' || Array.isArray(v))
217
+ return {};
218
+ return v;
219
+ }
220
+ class HarnessBundleService {
221
+ /**
222
+ * Token → bundle map. Preview stashes the ZIP keyed by a UUID; apply
223
+ * resolves the token to retrieve the same buffer the user previewed. Stale
224
+ * entries past the TTL are GC'd lazily on every preview/apply call.
225
+ */
226
+ bundleTokens = new Map();
227
+ // ==================== EXPORT ============================================
228
+ async export(input) {
229
+ const { projectSlug, includes, secretsPolicy } = input;
230
+ if (secretsPolicy === 'included-explicit' && input.acknowledgedSecretInclusion !== true) {
231
+ const err = new Error('`acknowledgedSecretInclusion: true` is required when secretsPolicy === "included-explicit"');
232
+ err.code = 'HARNESS_SECRET_ACK_MISSING';
233
+ throw err;
234
+ }
235
+ const projectRoot = await projectService.resolveOriginalPath(projectSlug);
236
+ const includeSet = new Set(includes);
237
+ // Collect every section's items + payloads in parallel — they touch
238
+ // disjoint subtrees so there is no need to serialize.
239
+ const collections = await Promise.all([
240
+ includeSet.has('claude-md')
241
+ ? this.collectClaudeMd(projectRoot, projectSlug, secretsPolicy)
242
+ : EMPTY_COLLECT,
243
+ includeSet.has('skills')
244
+ ? this.collectSkills(projectRoot, projectSlug, secretsPolicy)
245
+ : EMPTY_COLLECT,
246
+ includeSet.has('commands')
247
+ ? this.collectCommands(projectRoot, projectSlug, secretsPolicy)
248
+ : EMPTY_COLLECT,
249
+ includeSet.has('agents')
250
+ ? this.collectAgents(projectRoot, projectSlug, secretsPolicy)
251
+ : EMPTY_COLLECT,
252
+ includeSet.has('hooks')
253
+ ? this.collectHooks(projectRoot, projectSlug, secretsPolicy)
254
+ : EMPTY_COLLECT,
255
+ includeSet.has('mcp')
256
+ ? this.collectMcp(projectRoot, projectSlug, secretsPolicy)
257
+ : EMPTY_COLLECT,
258
+ includeSet.has('bmad')
259
+ ? this.collectBmad(projectRoot, projectSlug, secretsPolicy)
260
+ : EMPTY_COLLECT,
261
+ ]);
262
+ const items = [];
263
+ const payloads = [];
264
+ let secretsRemovedCount = 0;
265
+ let secretsReplacedCount = 0;
266
+ for (const c of collections) {
267
+ items.push(...c.items);
268
+ payloads.push(...c.payloads);
269
+ secretsRemovedCount += c.secretsRemovedCount;
270
+ secretsReplacedCount += c.secretsReplacedCount;
271
+ }
272
+ const pluginDependencies = await this.collectPluginDependencies(projectSlug);
273
+ const manifest = {
274
+ bundleVersion: HARNESS_BUNDLE_VERSION,
275
+ hammocVersion: await readHammocVersion(),
276
+ claudeCodeSpecVersion: null,
277
+ createdAt: new Date().toISOString(),
278
+ sourceProjectSlug: projectSlug,
279
+ includes: includes.slice(),
280
+ secretsPolicy,
281
+ pluginDependencies,
282
+ items,
283
+ };
284
+ const zip = new JSZip();
285
+ zip.file('manifest.json', JSON.stringify(manifest, null, 2));
286
+ for (const p of payloads) {
287
+ zip.file(p.relativePath, p.content);
288
+ }
289
+ const zipBuffer = await zip.generateAsync({ type: 'nodebuffer' });
290
+ const timestamp = manifest.createdAt.replace(/[:.]/g, '-');
291
+ let filename = `hammoc-harness-${projectSlug}-${timestamp}.zip`;
292
+ if (secretsPolicy === 'included-explicit') {
293
+ // AC2.d-1 — server forces the WITH-SECRETS suffix; the caller cannot
294
+ // override this filename. We strip the trailing `.zip`, append, then
295
+ // re-add the extension. This makes the suffix visible in every
296
+ // download manager / file viewer.
297
+ filename = `hammoc-harness-${projectSlug}-${timestamp}-WITH-SECRETS.zip`;
298
+ }
299
+ return {
300
+ zipBuffer,
301
+ manifest,
302
+ filename,
303
+ secretsRemovedCount,
304
+ secretsReplacedCount,
305
+ hadPlaintextSecrets: secretsPolicy === 'included-explicit',
306
+ };
307
+ }
308
+ // --- Export domain collectors --------------------------------------------
309
+ async collectClaudeMd(projectRoot, projectSlug, policy) {
310
+ const filePath = path.join(projectRoot, 'CLAUDE.md');
311
+ const text = await readTextIfPresent(filePath);
312
+ if (text === null)
313
+ return EMPTY_COLLECT;
314
+ const filtered = applyPolicyToText({
315
+ policy,
316
+ domain: 'claude-md',
317
+ cardName: 'CLAUDE.md',
318
+ text,
319
+ });
320
+ const shareScope = await this.resolveShareScope(projectSlug, 'CLAUDE.md');
321
+ return {
322
+ items: [
323
+ {
324
+ domain: 'claude-md',
325
+ identity: 'CLAUDE.md',
326
+ relativePath: 'CLAUDE.md',
327
+ sourceShareScope: shareScope,
328
+ },
329
+ ],
330
+ payloads: [{ relativePath: 'CLAUDE.md', content: filtered.text }],
331
+ secretsRemovedCount: filtered.removedCount,
332
+ secretsReplacedCount: filtered.replacedCount,
333
+ };
334
+ }
335
+ async collectSkills(projectRoot, projectSlug, policy) {
336
+ const skillsRoot = path.join(projectRoot, '.claude', 'skills');
337
+ if (!(await pathExists(skillsRoot)))
338
+ return EMPTY_COLLECT;
339
+ const items = [];
340
+ const payloads = [];
341
+ let removed = 0;
342
+ let replaced = 0;
343
+ let skillDirs;
344
+ try {
345
+ skillDirs = await fs.readdir(skillsRoot, { withFileTypes: true });
346
+ }
347
+ catch {
348
+ return EMPTY_COLLECT;
349
+ }
350
+ for (const dir of skillDirs) {
351
+ if (!dir.isDirectory())
352
+ continue;
353
+ const skillRoot = path.join(skillsRoot, dir.name);
354
+ const skillMd = path.join(skillRoot, 'SKILL.md');
355
+ const skillMdText = await readTextIfPresent(skillMd);
356
+ if (skillMdText === null)
357
+ continue; // not a real skill — silently skip
358
+ const filtered = applyPolicyToText({
359
+ policy,
360
+ domain: 'skill',
361
+ cardName: dir.name,
362
+ text: skillMdText,
363
+ });
364
+ removed += filtered.removedCount;
365
+ replaced += filtered.replacedCount;
366
+ payloads.push({
367
+ relativePath: `skills/${dir.name}/SKILL.md`,
368
+ content: filtered.text,
369
+ });
370
+ // Bundle directories (`assets/`, `references/`, `examples/`, `scripts/`).
371
+ // Copy every file under the skill root EXCEPT SKILL.md (already written).
372
+ const walked = await walkFiles(skillRoot);
373
+ for (const f of walked) {
374
+ if (f.relativePath === 'SKILL.md')
375
+ continue;
376
+ const buf = await fs.readFile(f.absolutePath);
377
+ // Heuristic: text-like extensions go through the policy walker;
378
+ // everything else is shipped as raw Buffer.
379
+ const ext = path.extname(f.relativePath).toLowerCase();
380
+ const isText = ['.md', '.txt', '.json', '.yaml', '.yml', '.py', '.ts', '.js', '.sh'].includes(ext);
381
+ if (isText) {
382
+ const filteredText = applyPolicyToText({
383
+ policy,
384
+ domain: 'skill',
385
+ cardName: `${dir.name}/${f.relativePath}`,
386
+ text: buf.toString('utf-8'),
387
+ });
388
+ removed += filteredText.removedCount;
389
+ replaced += filteredText.replacedCount;
390
+ payloads.push({
391
+ relativePath: `skills/${dir.name}/${f.relativePath}`,
392
+ content: filteredText.text,
393
+ });
394
+ }
395
+ else {
396
+ payloads.push({
397
+ relativePath: `skills/${dir.name}/${f.relativePath}`,
398
+ content: buf,
399
+ });
400
+ }
401
+ }
402
+ const shareScope = await this.resolveShareScope(projectSlug, `.claude/skills/${dir.name}/SKILL.md`);
403
+ items.push({
404
+ domain: 'skill',
405
+ identity: dir.name,
406
+ relativePath: `skills/${dir.name}/SKILL.md`,
407
+ sourceShareScope: shareScope,
408
+ });
409
+ }
410
+ return { items, payloads, secretsRemovedCount: removed, secretsReplacedCount: replaced };
411
+ }
412
+ async collectCommands(projectRoot, projectSlug, policy) {
413
+ const commandsRoot = path.join(projectRoot, '.claude', 'commands');
414
+ if (!(await pathExists(commandsRoot)))
415
+ return EMPTY_COLLECT;
416
+ const items = [];
417
+ const payloads = [];
418
+ let removed = 0;
419
+ let replaced = 0;
420
+ const walked = await walkFiles(commandsRoot);
421
+ for (const f of walked) {
422
+ if (!f.relativePath.toLowerCase().endsWith('.md'))
423
+ continue;
424
+ const text = await fs.readFile(f.absolutePath, 'utf-8');
425
+ // Slash-name: replace OS separator with `:` and strip `.md`. e.g.
426
+ // `BMad/agents/sm.md` → `/BMad:agents:sm`.
427
+ const stem = f.relativePath.replace(/\.md$/i, '');
428
+ const slashName = `/${stem.split(/[\\/]/).join(':')}`;
429
+ const filtered = applyPolicyToText({
430
+ policy,
431
+ domain: 'command',
432
+ cardName: stem,
433
+ text,
434
+ });
435
+ removed += filtered.removedCount;
436
+ replaced += filtered.replacedCount;
437
+ payloads.push({
438
+ relativePath: `commands/${f.relativePath}`,
439
+ content: filtered.text,
440
+ });
441
+ const shareScope = await this.resolveShareScope(projectSlug, `.claude/commands/${f.relativePath}`);
442
+ items.push({
443
+ domain: 'command',
444
+ identity: slashName,
445
+ relativePath: `commands/${f.relativePath}`,
446
+ sourceShareScope: shareScope,
447
+ });
448
+ }
449
+ return { items, payloads, secretsRemovedCount: removed, secretsReplacedCount: replaced };
450
+ }
451
+ async collectAgents(projectRoot, projectSlug, policy) {
452
+ const agentsRoot = path.join(projectRoot, '.claude', 'agents');
453
+ if (!(await pathExists(agentsRoot)))
454
+ return EMPTY_COLLECT;
455
+ const items = [];
456
+ const payloads = [];
457
+ let removed = 0;
458
+ let replaced = 0;
459
+ let entries;
460
+ try {
461
+ entries = await fs.readdir(agentsRoot, { withFileTypes: true });
462
+ }
463
+ catch {
464
+ return EMPTY_COLLECT;
465
+ }
466
+ for (const ent of entries) {
467
+ if (!ent.isFile())
468
+ continue;
469
+ if (!ent.name.toLowerCase().endsWith('.md'))
470
+ continue;
471
+ const abs = path.join(agentsRoot, ent.name);
472
+ const text = await fs.readFile(abs, 'utf-8');
473
+ const name = ent.name.replace(/\.md$/i, '');
474
+ const filtered = applyPolicyToText({
475
+ policy,
476
+ domain: 'agent',
477
+ cardName: name,
478
+ text,
479
+ });
480
+ removed += filtered.removedCount;
481
+ replaced += filtered.replacedCount;
482
+ payloads.push({
483
+ relativePath: `agents/${ent.name}`,
484
+ content: filtered.text,
485
+ });
486
+ const shareScope = await this.resolveShareScope(projectSlug, `.claude/agents/${ent.name}`);
487
+ items.push({
488
+ domain: 'agent',
489
+ identity: name,
490
+ relativePath: `agents/${ent.name}`,
491
+ sourceShareScope: shareScope,
492
+ });
493
+ }
494
+ return { items, payloads, secretsRemovedCount: removed, secretsReplacedCount: replaced };
495
+ }
496
+ async collectHooks(projectRoot, projectSlug, policy) {
497
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
498
+ const raw = await readJsonIfPresent(settingsPath);
499
+ if (raw === null)
500
+ return EMPTY_COLLECT;
501
+ const hooksRaw = pickHooks(raw);
502
+ const filtered = applyPolicyToValue({
503
+ policy,
504
+ domain: 'hook',
505
+ cardName: 'hooks',
506
+ value: hooksRaw,
507
+ });
508
+ const filteredHooks = filtered.value ?? {};
509
+ const items = [];
510
+ // Each hook is one BundleItem — identity is `event:groupIndex:hookIndex`
511
+ // so that round-trip status comparison can find the matching slot in the
512
+ // target project's settings.json without ambiguity.
513
+ for (const [event, groupsUnknown] of Object.entries(filteredHooks)) {
514
+ if (!Array.isArray(groupsUnknown))
515
+ continue;
516
+ groupsUnknown.forEach((group, gi) => {
517
+ if (!group || typeof group !== 'object')
518
+ return;
519
+ const hooks = group.hooks;
520
+ if (!Array.isArray(hooks))
521
+ return;
522
+ hooks.forEach((_h, hi) => {
523
+ items.push({
524
+ domain: 'hook',
525
+ identity: `${event}:${gi}:${hi}`,
526
+ relativePath: 'hooks-fragment.json',
527
+ sourceShareScope: 'shared',
528
+ });
529
+ });
530
+ });
531
+ }
532
+ // Also include the full filtered settings.json so a downstream tool can
533
+ // reproduce the entire enabledPlugins + statusLine state if it wants. We
534
+ // keep this best-effort — secrets are filtered here too.
535
+ const filteredSettings = applyPolicyToValue({
536
+ policy,
537
+ domain: 'hook',
538
+ cardName: 'settings',
539
+ value: raw,
540
+ });
541
+ const payloads = [
542
+ {
543
+ relativePath: 'hooks-fragment.json',
544
+ content: JSON.stringify({ hooks: filteredHooks }, null, 2),
545
+ },
546
+ {
547
+ relativePath: 'settings.json',
548
+ content: JSON.stringify(filteredSettings.value, null, 2),
549
+ },
550
+ ];
551
+ return {
552
+ items,
553
+ payloads,
554
+ secretsRemovedCount: filtered.removedCount + filteredSettings.removedCount,
555
+ secretsReplacedCount: filtered.replacedCount + filteredSettings.replacedCount,
556
+ };
557
+ }
558
+ async collectMcp(projectRoot, projectSlug, policy) {
559
+ const mcpPath = path.join(projectRoot, '.mcp.json');
560
+ const raw = await readJsonIfPresent(mcpPath);
561
+ if (raw === null)
562
+ return EMPTY_COLLECT;
563
+ const mcpServers = pickMcpServers(raw);
564
+ const items = [];
565
+ let removed = 0;
566
+ let replaced = 0;
567
+ const filteredServers = {};
568
+ for (const [name, config] of Object.entries(mcpServers)) {
569
+ const filtered = applyPolicyToValue({
570
+ policy,
571
+ domain: 'mcp',
572
+ cardName: name,
573
+ value: config,
574
+ });
575
+ removed += filtered.removedCount;
576
+ replaced += filtered.replacedCount;
577
+ if (filtered.value !== undefined) {
578
+ filteredServers[name] = filtered.value;
579
+ }
580
+ const shareScope = await this.resolveShareScope(projectSlug, '.mcp.json');
581
+ items.push({
582
+ domain: 'mcp',
583
+ identity: name,
584
+ relativePath: '.mcp.json',
585
+ sourceShareScope: shareScope,
586
+ });
587
+ }
588
+ return {
589
+ items,
590
+ payloads: [
591
+ {
592
+ relativePath: '.mcp.json',
593
+ content: JSON.stringify({ mcpServers: filteredServers }, null, 2),
594
+ },
595
+ ],
596
+ secretsRemovedCount: removed,
597
+ secretsReplacedCount: replaced,
598
+ };
599
+ }
600
+ async collectBmad(projectRoot, projectSlug, policy) {
601
+ const filePath = path.join(projectRoot, '.bmad-core', 'core-config.yaml');
602
+ const text = await readTextIfPresent(filePath);
603
+ if (text === null)
604
+ return EMPTY_COLLECT;
605
+ const filtered = applyPolicyToText({
606
+ policy,
607
+ domain: 'bmad',
608
+ cardName: 'core-config',
609
+ text,
610
+ });
611
+ const shareScope = await this.resolveShareScope(projectSlug, '.bmad-core/core-config.yaml');
612
+ return {
613
+ items: [
614
+ {
615
+ domain: 'bmad',
616
+ identity: 'core-config.yaml',
617
+ relativePath: 'bmad-core-config.yaml',
618
+ sourceShareScope: shareScope,
619
+ },
620
+ ],
621
+ payloads: [{ relativePath: 'bmad-core-config.yaml', content: filtered.text }],
622
+ secretsRemovedCount: filtered.removedCount,
623
+ secretsReplacedCount: filtered.replacedCount,
624
+ };
625
+ }
626
+ /**
627
+ * Active plugin dependencies — read `installed_plugins.json` + `enabledPlugins`
628
+ * and emit only the entries currently enabled.
629
+ */
630
+ async collectPluginDependencies(projectSlug) {
631
+ try {
632
+ const res = await harnessPluginService.listCards(projectSlug);
633
+ const out = [];
634
+ for (const card of res.cards) {
635
+ if (!card.enabled)
636
+ continue;
637
+ out.push({
638
+ name: card.name,
639
+ marketplace: card.marketplace,
640
+ version: card.version || undefined,
641
+ });
642
+ }
643
+ return out;
644
+ }
645
+ catch {
646
+ // Plugin catalog read failure is non-fatal — return empty list. The
647
+ // export still goes through (we never refuse to export a project just
648
+ // because the plugin catalog is corrupted).
649
+ return [];
650
+ }
651
+ }
652
+ /**
653
+ * Resolve a single path's share-scope verdict for manifest bookkeeping.
654
+ * Best-effort — if the share-scope service throws (project not found, etc.)
655
+ * we fall back to `'shared'` since we know the file already exists inside
656
+ * the project tree.
657
+ */
658
+ async resolveShareScope(projectSlug, relativePath) {
659
+ try {
660
+ const res = await harnessShareScopeService.evaluate({
661
+ projectSlug,
662
+ relativePaths: [relativePath],
663
+ });
664
+ const scope = res.cards[relativePath];
665
+ if (scope === 'fullyIgnored')
666
+ return 'fullyIgnored';
667
+ if (scope === 'local')
668
+ return 'local';
669
+ return 'shared';
670
+ }
671
+ catch {
672
+ return 'shared';
673
+ }
674
+ }
675
+ // ==================== IMPORT ============================================
676
+ async import(input) {
677
+ const { projectSlug, zipBuffer, dryRun, itemActions } = input;
678
+ // 1. Open the zip and read manifest first — every other path branch on
679
+ // compatibility consults the manifest.
680
+ let zip;
681
+ try {
682
+ zip = await JSZip.loadAsync(zipBuffer);
683
+ }
684
+ catch (err) {
685
+ return emptyImportResult('malformed', {
686
+ jsonError: `failed to open ZIP: ${err.message}`,
687
+ });
688
+ }
689
+ // ZIP-slip guard runs on every entry up-front (including the implicit
690
+ // directory markers JSZip emits) so we never even peek at a traversal
691
+ // entry. Directory entries can carry traversal too — e.g. `/` (root) and
692
+ // `../etc/` markers from JSZip's normalization of a malicious source
693
+ // archive — so we do NOT exempt them. The guard throws
694
+ // `UnsafeBundlePathError` which the caller treats as a 400.
695
+ for (const entry of Object.keys(zip.files)) {
696
+ assertSafeBundlePath(entry);
697
+ }
698
+ const manifestFile = zip.files['manifest.json'];
699
+ if (!manifestFile) {
700
+ return emptyImportResult('malformed', { jsonError: 'manifest.json missing from bundle' });
701
+ }
702
+ const manifestText = await manifestFile.async('string');
703
+ let manifestRaw;
704
+ try {
705
+ manifestRaw = JSON.parse(manifestText);
706
+ }
707
+ catch (err) {
708
+ return emptyImportResult('malformed', { jsonError: err.message });
709
+ }
710
+ // 2. Loose check first — distinguish "future" / "invalid" from "malformed".
711
+ const loose = loosenedManifestSchema.safeParse(manifestRaw);
712
+ if (!loose.success) {
713
+ return emptyImportResult('malformed', {
714
+ issues: loose.error.issues.map((i) => ({ path: i.path, message: i.message })),
715
+ });
716
+ }
717
+ const declaredVersion = loose.data.bundleVersion;
718
+ if (declaredVersion === undefined || declaredVersion < 1) {
719
+ return emptyImportResult('invalid', { bundleVersion: declaredVersion });
720
+ }
721
+ if (declaredVersion > HARNESS_BUNDLE_VERSION) {
722
+ return emptyImportResult('future', { bundleVersion: declaredVersion });
723
+ }
724
+ // 3. Strict check — full Zod schema.
725
+ const strict = bundleManifestSchema.safeParse(manifestRaw);
726
+ if (!strict.success) {
727
+ return emptyImportResult('malformed', {
728
+ issues: strict.error.issues.map((i) => ({ path: i.path, message: i.message })),
729
+ });
730
+ }
731
+ const manifest = strict.data;
732
+ // 4. Compute preview rows
733
+ const projectRoot = await projectService.resolveOriginalPath(projectSlug);
734
+ const preview = await this.computePreview(projectRoot, manifest, zip);
735
+ if (dryRun) {
736
+ return {
737
+ manifest,
738
+ preview,
739
+ compatibility: 'compatible',
740
+ };
741
+ }
742
+ // 5. Apply.
743
+ const summary = await this.applyImport(projectRoot, manifest, zip, itemActions ?? {});
744
+ return {
745
+ manifest,
746
+ preview,
747
+ compatibility: 'compatible',
748
+ appliedSummary: summary,
749
+ };
750
+ }
751
+ async computePreview(projectRoot, manifest, zip) {
752
+ const items = [];
753
+ // Pre-compute known sections so unknownSections lists only the strangers.
754
+ const unknownSections = manifest.includes.filter((s) => !KNOWN_BUNDLE_SECTIONS.has(s));
755
+ // Drop items belonging to unknown sections from the preview rows
756
+ // (AC5.b — unknown sections do not appear in the conflict UI).
757
+ const knownItems = manifest.items.filter((it) => {
758
+ switch (it.domain) {
759
+ case 'claude-md':
760
+ return KNOWN_BUNDLE_SECTIONS.has('claude-md');
761
+ case 'skill':
762
+ return KNOWN_BUNDLE_SECTIONS.has('skills');
763
+ case 'mcp':
764
+ return KNOWN_BUNDLE_SECTIONS.has('mcp');
765
+ case 'hook':
766
+ return KNOWN_BUNDLE_SECTIONS.has('hooks');
767
+ case 'command':
768
+ return KNOWN_BUNDLE_SECTIONS.has('commands');
769
+ case 'agent':
770
+ return KNOWN_BUNDLE_SECTIONS.has('agents');
771
+ case 'bmad':
772
+ return KNOWN_BUNDLE_SECTIONS.has('bmad');
773
+ default:
774
+ return false;
775
+ }
776
+ });
777
+ for (const item of knownItems) {
778
+ const verdict = await this.statusForItem(projectRoot, item, zip, manifest);
779
+ items.push(verdict);
780
+ }
781
+ const missingPlugins = await this.computeMissingPlugins(manifest);
782
+ return { items, missingPlugins, unknownSections };
783
+ }
784
+ /**
785
+ * Compare one bundle item to its destination on disk and produce the
786
+ * preview row. Status semantics: `new` → no file at target, `same` → bytes
787
+ * match exactly, `overwrite` → file exists with different content,
788
+ * `conflict` → a directory sits where a file would land (or vice versa).
789
+ */
790
+ async statusForItem(projectRoot, item, zip, manifest) {
791
+ const targetPath = this.targetPathFor(projectRoot, item);
792
+ const targetRel = path.relative(projectRoot, targetPath).split(path.sep).join('/');
793
+ let status = 'new';
794
+ try {
795
+ const stat = await fs.stat(targetPath);
796
+ if (stat.isDirectory()) {
797
+ status = 'conflict';
798
+ }
799
+ else {
800
+ const bundleEntry = zip.files[item.relativePath];
801
+ const targetBytes = await fs.readFile(targetPath, 'utf-8');
802
+ if (bundleEntry) {
803
+ const bundleText = await bundleEntry.async('string');
804
+ status = sameContent(item, bundleText, targetBytes, manifest) ? 'same' : 'overwrite';
805
+ }
806
+ else {
807
+ // The bundle declared this item but the payload entry is missing —
808
+ // happens for hook items (multiple hooks share `hooks-fragment.json`)
809
+ // and is normal. Compare via a per-hook lookup for those.
810
+ if (item.domain === 'hook') {
811
+ status = await this.hookStatus(projectRoot, item, zip);
812
+ }
813
+ else {
814
+ status = 'overwrite';
815
+ }
816
+ }
817
+ }
818
+ }
819
+ catch (err) {
820
+ if (err.code !== 'ENOENT') {
821
+ // Permission-denied / I/O — treat as conflict so the user sees it.
822
+ status = 'conflict';
823
+ }
824
+ }
825
+ const defaultAction = status === 'same' ? 'skip' : status === 'new' ? 'overwrite' : 'overwrite';
826
+ return {
827
+ domain: item.domain,
828
+ identity: item.identity,
829
+ status,
830
+ defaultAction,
831
+ targetPath: targetRel,
832
+ };
833
+ }
834
+ async hookStatus(projectRoot, item, zip) {
835
+ const fragmentFile = zip.files['hooks-fragment.json'];
836
+ if (!fragmentFile)
837
+ return 'overwrite';
838
+ const text = await fragmentFile.async('string');
839
+ let parsed;
840
+ try {
841
+ parsed = JSON.parse(text);
842
+ }
843
+ catch {
844
+ return 'overwrite';
845
+ }
846
+ const bundleHooks = pickHooks(parsed);
847
+ const [event, gi, hi] = item.identity.split(':');
848
+ const groups = bundleHooks[event];
849
+ if (!Array.isArray(groups))
850
+ return 'overwrite';
851
+ const group = groups[Number(gi)];
852
+ const bundleHook = group?.hooks?.[Number(hi)];
853
+ if (!bundleHook)
854
+ return 'overwrite';
855
+ // Compare against the target's settings.json hook at the same slot.
856
+ const targetSettings = await readJsonIfPresent(path.join(projectRoot, '.claude', 'settings.json'));
857
+ if (!targetSettings)
858
+ return 'new';
859
+ const targetHooks = pickHooks(targetSettings);
860
+ const tGroups = targetHooks[event];
861
+ if (!Array.isArray(tGroups))
862
+ return 'new';
863
+ const tGroup = tGroups[Number(gi)];
864
+ const tHook = tGroup?.hooks?.[Number(hi)];
865
+ if (!tHook)
866
+ return 'new';
867
+ return JSON.stringify(tHook) === JSON.stringify(bundleHook) ? 'same' : 'overwrite';
868
+ }
869
+ /** Resolve where one bundle item should be written on the target project. */
870
+ targetPathFor(projectRoot, item) {
871
+ switch (item.domain) {
872
+ case 'claude-md':
873
+ return path.join(projectRoot, 'CLAUDE.md');
874
+ case 'skill':
875
+ return path.join(projectRoot, '.claude', 'skills', item.identity, 'SKILL.md');
876
+ case 'command': {
877
+ // identity is the slash-name (`/foo:bar:baz`) — convert back to a path.
878
+ const stem = item.identity.replace(/^\//, '').split(':').join('/');
879
+ return path.join(projectRoot, '.claude', 'commands', `${stem}.md`);
880
+ }
881
+ case 'agent':
882
+ return path.join(projectRoot, '.claude', 'agents', `${item.identity}.md`);
883
+ case 'mcp':
884
+ return path.join(projectRoot, '.mcp.json');
885
+ case 'hook':
886
+ return path.join(projectRoot, '.claude', 'settings.json');
887
+ case 'bmad':
888
+ return path.join(projectRoot, '.bmad-core', 'core-config.yaml');
889
+ default: {
890
+ const _exhaustive = item.domain;
891
+ throw new Error(`unreachable: ${_exhaustive}`);
892
+ }
893
+ }
894
+ }
895
+ async computeMissingPlugins(manifest) {
896
+ if (manifest.pluginDependencies.length === 0)
897
+ return [];
898
+ try {
899
+ // `harnessPluginService.listCards` enumerates the USER-scope catalog
900
+ // (~/.claude/plugins/installed_plugins.json), so the slug argument only
901
+ // selects the current-project context for unrelated session metadata —
902
+ // it does NOT scope the returned plugin set. Passing `sourceProjectSlug`
903
+ // here looks like "source project's catalog" but is functionally
904
+ // irrelevant to the comparison; the keyspace below (`name@marketplace`)
905
+ // is process-wide. Keeping the slug pass-through for parity with other
906
+ // callers; the semantics depend solely on user-scope state.
907
+ const res = await harnessPluginService.listCards(manifest.sourceProjectSlug);
908
+ const installedKeys = new Set(res.cards.map((c) => `${c.name}@${c.marketplace}`));
909
+ return manifest.pluginDependencies.filter((ref) => !installedKeys.has(`${ref.name}@${ref.marketplace}`));
910
+ }
911
+ catch {
912
+ return manifest.pluginDependencies.slice();
913
+ }
914
+ }
915
+ // --- Apply ---------------------------------------------------------------
916
+ async applyImport(projectRoot, manifest, zip, itemActions) {
917
+ const snapshots = [];
918
+ const results = [];
919
+ let applied = 0;
920
+ let skipped = 0;
921
+ const renamed = 0;
922
+ const secretsRemovedCount = 0;
923
+ const secretsReplacedCount = 0;
924
+ // First: snapshot every target path that any action might touch. For mcp
925
+ // and hook items the same target file is shared by N items — we only
926
+ // snapshot once.
927
+ const seenTargets = new Set();
928
+ for (const item of manifest.items) {
929
+ const action = itemActions[`${item.domain}:${item.identity}`] ?? 'skip';
930
+ if (action === 'skip')
931
+ continue;
932
+ const target = this.targetPathFor(projectRoot, item);
933
+ if (seenTargets.has(target))
934
+ continue;
935
+ seenTargets.add(target);
936
+ snapshots.push(await snapshot(target));
937
+ }
938
+ try {
939
+ // Group items by target file so multi-item single-file domains (mcp +
940
+ // hook) are written exactly once with the merged payload.
941
+ const mcpMerge = {};
942
+ const hookEvents = {};
943
+ // Read existing destination files so a partial overwrite (e.g. just one
944
+ // mcp server) keeps the rest of the file intact.
945
+ const existingMcp = await readJsonIfPresent(path.join(projectRoot, '.mcp.json'));
946
+ const existingMcpServers = pickMcpServers(existingMcp);
947
+ Object.assign(mcpMerge, existingMcpServers);
948
+ const existingSettings = await readJsonIfPresent(path.join(projectRoot, '.claude', 'settings.json'));
949
+ const existingHooks = pickHooks(existingSettings);
950
+ Object.assign(hookEvents, existingHooks);
951
+ // Per-domain dispatch.
952
+ for (const item of manifest.items) {
953
+ const key = `${item.domain}:${item.identity}`;
954
+ const action = itemActions[key] ?? 'skip';
955
+ if (action === 'skip') {
956
+ skipped += 1;
957
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
958
+ continue;
959
+ }
960
+ switch (item.domain) {
961
+ case 'claude-md': {
962
+ const entry = zip.files[item.relativePath];
963
+ if (!entry) {
964
+ skipped += 1;
965
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
966
+ break;
967
+ }
968
+ const text = await entry.async('string');
969
+ const targetPath = this.targetPathFor(projectRoot, item);
970
+ if (action === 'appendSection') {
971
+ const existing = (await readTextIfPresent(targetPath)) ?? '';
972
+ const { appendMarkdownSections, splitMarkdownByH2 } = await import('@hammoc/shared');
973
+ const next = appendMarkdownSections(existing, splitMarkdownByH2(text));
974
+ await writeBundleFile(targetPath, next);
975
+ }
976
+ else {
977
+ await writeBundleFile(targetPath, text);
978
+ }
979
+ applied += 1;
980
+ results.push({
981
+ domain: item.domain,
982
+ identity: item.identity,
983
+ action,
984
+ status: 'applied',
985
+ finalPath: path.relative(projectRoot, targetPath).split(path.sep).join('/'),
986
+ });
987
+ break;
988
+ }
989
+ case 'bmad': {
990
+ const entry = zip.files[item.relativePath];
991
+ if (!entry) {
992
+ skipped += 1;
993
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
994
+ break;
995
+ }
996
+ const text = await entry.async('string');
997
+ const targetPath = this.targetPathFor(projectRoot, item);
998
+ await writeBundleFile(targetPath, text);
999
+ applied += 1;
1000
+ results.push({
1001
+ domain: item.domain,
1002
+ identity: item.identity,
1003
+ action,
1004
+ status: 'applied',
1005
+ finalPath: path.relative(projectRoot, targetPath).split(path.sep).join('/'),
1006
+ });
1007
+ break;
1008
+ }
1009
+ case 'skill': {
1010
+ const skillRoot = path.join(projectRoot, '.claude', 'skills', item.identity);
1011
+ // Re-derive all skill files from the ZIP under `skills/<name>/`.
1012
+ const prefix = `skills/${item.identity}/`;
1013
+ for (const entryName of Object.keys(zip.files)) {
1014
+ if (!entryName.startsWith(prefix))
1015
+ continue;
1016
+ const f = zip.files[entryName];
1017
+ if (f.dir)
1018
+ continue;
1019
+ const rel = entryName.slice(prefix.length);
1020
+ const dest = path.join(skillRoot, rel);
1021
+ // Capture pre-state of nested files so rollback can restore them.
1022
+ snapshots.push(await snapshot(dest));
1023
+ const content = await f.async('nodebuffer');
1024
+ await writeBundleFile(dest, content);
1025
+ }
1026
+ applied += 1;
1027
+ results.push({
1028
+ domain: item.domain,
1029
+ identity: item.identity,
1030
+ action,
1031
+ status: 'applied',
1032
+ finalPath: path
1033
+ .relative(projectRoot, path.join(skillRoot, 'SKILL.md'))
1034
+ .split(path.sep)
1035
+ .join('/'),
1036
+ });
1037
+ break;
1038
+ }
1039
+ case 'command': {
1040
+ const entry = zip.files[item.relativePath];
1041
+ if (!entry) {
1042
+ skipped += 1;
1043
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1044
+ break;
1045
+ }
1046
+ const text = await entry.async('string');
1047
+ const targetPath = this.targetPathFor(projectRoot, item);
1048
+ await writeBundleFile(targetPath, text);
1049
+ applied += 1;
1050
+ results.push({
1051
+ domain: item.domain,
1052
+ identity: item.identity,
1053
+ action,
1054
+ status: 'applied',
1055
+ finalPath: path.relative(projectRoot, targetPath).split(path.sep).join('/'),
1056
+ });
1057
+ break;
1058
+ }
1059
+ case 'agent': {
1060
+ const entry = zip.files[item.relativePath];
1061
+ if (!entry) {
1062
+ skipped += 1;
1063
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1064
+ break;
1065
+ }
1066
+ const text = await entry.async('string');
1067
+ const targetPath = this.targetPathFor(projectRoot, item);
1068
+ await writeBundleFile(targetPath, text);
1069
+ applied += 1;
1070
+ results.push({
1071
+ domain: item.domain,
1072
+ identity: item.identity,
1073
+ action,
1074
+ status: 'applied',
1075
+ finalPath: path.relative(projectRoot, targetPath).split(path.sep).join('/'),
1076
+ });
1077
+ break;
1078
+ }
1079
+ case 'mcp': {
1080
+ // Read the bundle's `.mcp.json` once and merge each chosen entry
1081
+ // into the in-memory `mcpMerge` map. We flush after the loop.
1082
+ const entry = zip.files['.mcp.json'];
1083
+ if (!entry) {
1084
+ skipped += 1;
1085
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1086
+ break;
1087
+ }
1088
+ const text = await entry.async('string');
1089
+ let bundleObj;
1090
+ try {
1091
+ bundleObj = JSON.parse(text);
1092
+ }
1093
+ catch {
1094
+ skipped += 1;
1095
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1096
+ break;
1097
+ }
1098
+ const bundleServers = pickMcpServers(bundleObj);
1099
+ if (item.identity in bundleServers) {
1100
+ mcpMerge[item.identity] = bundleServers[item.identity];
1101
+ applied += 1;
1102
+ results.push({
1103
+ domain: item.domain,
1104
+ identity: item.identity,
1105
+ action,
1106
+ status: 'applied',
1107
+ finalPath: '.mcp.json',
1108
+ });
1109
+ }
1110
+ else {
1111
+ skipped += 1;
1112
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1113
+ }
1114
+ break;
1115
+ }
1116
+ case 'hook': {
1117
+ const entry = zip.files['hooks-fragment.json'];
1118
+ if (!entry) {
1119
+ skipped += 1;
1120
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1121
+ break;
1122
+ }
1123
+ const text = await entry.async('string');
1124
+ let bundleObj;
1125
+ try {
1126
+ bundleObj = JSON.parse(text);
1127
+ }
1128
+ catch {
1129
+ skipped += 1;
1130
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1131
+ break;
1132
+ }
1133
+ const bundleHooks = pickHooks(bundleObj);
1134
+ const [event, giStr, hiStr] = item.identity.split(':');
1135
+ const gi = Number(giStr);
1136
+ const hi = Number(hiStr);
1137
+ const bundleGroups = bundleHooks[event] || [];
1138
+ const bundleGroup = bundleGroups[gi];
1139
+ const bundleHook = bundleGroup?.hooks?.[hi];
1140
+ if (!bundleHook) {
1141
+ skipped += 1;
1142
+ results.push({ domain: item.domain, identity: item.identity, action, status: 'skipped' });
1143
+ break;
1144
+ }
1145
+ // Append the hook to the target's event slot. We keep this
1146
+ // additive (never delete existing target hooks) so an import
1147
+ // never silently destroys local hooks.
1148
+ //
1149
+ // Policy (DESIGN-001 — intentional, not a bug):
1150
+ // This branch is "additive append". We do NOT dedup by
1151
+ // (event, gi, hi) slot here. The preview layer normally
1152
+ // masks duplicate re-imports: if the same hook already
1153
+ // exists at the same slot, statusForItem reports `same`
1154
+ // and the default action becomes `skip`, so this code
1155
+ // path is not reached. The only way a duplicate group
1156
+ // reaches this push is when the UI explicitly upgrades
1157
+ // the action to `overwrite` for a `same` row — which is
1158
+ // treated as a deliberate "register again" intent (e.g.
1159
+ // the user wants two parallel hook registrations).
1160
+ //
1161
+ // If future requirements need slot-unique semantics, add
1162
+ // a dedup guard here keyed on
1163
+ // (event, bundleGroup?.matcher, JSON.stringify(bundleHook))
1164
+ // BEFORE pushing — but the current contract is "append".
1165
+ const targetGroups = hookEvents[event] || [];
1166
+ const groupsArr = Array.isArray(targetGroups) ? [...targetGroups] : [];
1167
+ groupsArr.push({
1168
+ matcher: bundleGroup?.matcher,
1169
+ hooks: [bundleHook],
1170
+ });
1171
+ hookEvents[event] = groupsArr;
1172
+ applied += 1;
1173
+ results.push({
1174
+ domain: item.domain,
1175
+ identity: item.identity,
1176
+ action,
1177
+ status: 'applied',
1178
+ finalPath: '.claude/settings.json',
1179
+ });
1180
+ break;
1181
+ }
1182
+ default: {
1183
+ const _exhaustive = item.domain;
1184
+ throw new Error(`unreachable: ${_exhaustive}`);
1185
+ }
1186
+ }
1187
+ }
1188
+ // Flush mcp / hook merges.
1189
+ const touchedMcp = manifest.items.some((i) => i.domain === 'mcp' && (itemActions[`mcp:${i.identity}`] ?? 'skip') !== 'skip');
1190
+ if (touchedMcp) {
1191
+ const target = path.join(projectRoot, '.mcp.json');
1192
+ await writeBundleFile(target, JSON.stringify({ ...(existingMcp ?? {}), mcpServers: mcpMerge }, null, 2));
1193
+ }
1194
+ const touchedHook = manifest.items.some((i) => i.domain === 'hook' && (itemActions[`hook:${i.identity}`] ?? 'skip') !== 'skip');
1195
+ if (touchedHook) {
1196
+ const target = path.join(projectRoot, '.claude', 'settings.json');
1197
+ const nextSettings = {
1198
+ ...(existingSettings ?? {}),
1199
+ hooks: hookEvents,
1200
+ };
1201
+ await writeBundleFile(target, JSON.stringify(nextSettings, null, 2));
1202
+ }
1203
+ // Translate the source bundle's secret counters by inspecting
1204
+ // `manifest.secretsPolicy` so the response surfaces the user-visible
1205
+ // totals (helper module already pre-counted at export time, but
1206
+ // recomputing here keeps a single source of truth on the import side).
1207
+ if (manifest.secretsPolicy === 'included-explicit') {
1208
+ // Plaintext bundles — no removed/replaced counters apply.
1209
+ }
1210
+ return {
1211
+ applied,
1212
+ skipped,
1213
+ renamed,
1214
+ results,
1215
+ secretsRemovedCount,
1216
+ secretsReplacedCount,
1217
+ hadPlaintextSecrets: manifest.secretsPolicy === 'included-explicit',
1218
+ };
1219
+ }
1220
+ catch (err) {
1221
+ // Roll back in reverse snapshot order so children land before parents
1222
+ // when both were created during the transaction.
1223
+ for (let i = snapshots.length - 1; i >= 0; i -= 1) {
1224
+ try {
1225
+ await restoreSnapshot(snapshots[i]);
1226
+ }
1227
+ catch {
1228
+ // best-effort — continue rolling back the rest
1229
+ }
1230
+ }
1231
+ throw err;
1232
+ }
1233
+ }
1234
+ // -------------------- Token store (preview → apply window) -------------
1235
+ /**
1236
+ * Stash a parsed bundle + its source ZIP behind a one-time token. The token
1237
+ * is what the apply call must echo. Tokens are GC'd lazily once they pass
1238
+ * the TTL — a 30-minute window mirrors the export dialog session length.
1239
+ */
1240
+ storeBundle(projectSlug, zipBuffer, manifest) {
1241
+ this.gcTokenStore();
1242
+ const token = randomUUID();
1243
+ this.bundleTokens.set(token, {
1244
+ projectSlug,
1245
+ zipBuffer,
1246
+ manifest,
1247
+ insertedAt: Date.now(),
1248
+ });
1249
+ return token;
1250
+ }
1251
+ consumeBundle(token) {
1252
+ this.gcTokenStore();
1253
+ const entry = this.bundleTokens.get(token);
1254
+ if (!entry)
1255
+ return null;
1256
+ return entry;
1257
+ }
1258
+ releaseBundle(token) {
1259
+ this.bundleTokens.delete(token);
1260
+ }
1261
+ gcTokenStore() {
1262
+ const now = Date.now();
1263
+ for (const [token, entry] of this.bundleTokens) {
1264
+ if (now - entry.insertedAt > IMPORT_BUNDLE_TTL_MS) {
1265
+ this.bundleTokens.delete(token);
1266
+ }
1267
+ }
1268
+ }
1269
+ }
1270
+ const EMPTY_COLLECT = {
1271
+ items: [],
1272
+ payloads: [],
1273
+ secretsRemovedCount: 0,
1274
+ secretsReplacedCount: 0,
1275
+ };
1276
+ /**
1277
+ * Compare a bundle entry's content with the on-disk file at the target.
1278
+ * For `mcp`/`hook` items the per-item comparison is more granular than
1279
+ * file-byte equality (the bundle file is the union of all items in that
1280
+ * domain) so we delegate to the domain-specific status helpers in
1281
+ * `statusForItem` / `hookStatus`. For every other item this byte compare is
1282
+ * sufficient.
1283
+ */
1284
+ function sameContent(item, bundleText, targetText, _manifest) {
1285
+ if (item.domain === 'mcp' || item.domain === 'hook') {
1286
+ // The whole `.mcp.json` / `hooks-fragment.json` rarely matches byte-for-
1287
+ // byte because the target may contain other items. Treat as overwrite.
1288
+ return false;
1289
+ }
1290
+ return bundleText === targetText;
1291
+ }
1292
+ function emptyImportResult(compatibility, detail) {
1293
+ const stubManifest = {
1294
+ bundleVersion: HARNESS_BUNDLE_VERSION,
1295
+ hammocVersion: '',
1296
+ claudeCodeSpecVersion: null,
1297
+ createdAt: '',
1298
+ sourceProjectSlug: '',
1299
+ includes: [],
1300
+ secretsPolicy: 'excluded',
1301
+ pluginDependencies: [],
1302
+ items: [],
1303
+ };
1304
+ return {
1305
+ manifest: stubManifest,
1306
+ preview: { items: [], missingPlugins: [], unknownSections: [] },
1307
+ compatibility,
1308
+ compatibilityDetail: detail,
1309
+ };
1310
+ }
1311
+ export const harnessBundleService = new HarnessBundleService();
1312
+ export { HarnessBundleService };
1313
+ // Re-export so callers do not have to grep through the utils folder.
1314
+ export { UnsafeBundlePathError } from '../utils/assertSafeBundlePath.js';
1315
+ // Reference os module to silence unused-import warnings when the cache-token
1316
+ // store is the only consumer.
1317
+ void os;
1318
+ //# sourceMappingURL=harnessBundleService.js.map