@mettlecast/domain-cli 0.2.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 (301) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +133 -0
  3. package/dist/builder/build-registry.d.ts +20 -0
  4. package/dist/builder/build-registry.js +178 -0
  5. package/dist/builder/build-types.d.ts +10 -0
  6. package/dist/builder/build-types.js +32 -0
  7. package/dist/builder/load-domain-module-config.d.ts +12 -0
  8. package/dist/builder/load-domain-module-config.js +34 -0
  9. package/dist/builder/load-module.d.ts +26 -0
  10. package/dist/builder/load-module.js +114 -0
  11. package/dist/builder/zod-to-json.d.ts +10 -0
  12. package/dist/builder/zod-to-json.js +15 -0
  13. package/dist/cli.d.ts +2 -0
  14. package/dist/cli.js +338 -0
  15. package/dist/commands/add-api.d.ts +32 -0
  16. package/dist/commands/add-api.js +106 -0
  17. package/dist/commands/add-component.d.ts +15 -0
  18. package/dist/commands/add-component.js +78 -0
  19. package/dist/commands/add-domain.d.ts +21 -0
  20. package/dist/commands/add-domain.js +71 -0
  21. package/dist/commands/add-flow.d.ts +14 -0
  22. package/dist/commands/add-flow.js +108 -0
  23. package/dist/commands/add-hook.d.ts +15 -0
  24. package/dist/commands/add-hook.js +65 -0
  25. package/dist/commands/add-migration.d.ts +26 -0
  26. package/dist/commands/add-migration.js +93 -0
  27. package/dist/commands/add-module.d.ts +7 -0
  28. package/dist/commands/add-module.js +261 -0
  29. package/dist/commands/add-page.d.ts +13 -0
  30. package/dist/commands/add-page.js +170 -0
  31. package/dist/commands/add-seed-page.d.ts +5 -0
  32. package/dist/commands/add-seed-page.js +251 -0
  33. package/dist/commands/add-subscriber.d.ts +30 -0
  34. package/dist/commands/add-subscriber.js +87 -0
  35. package/dist/commands/add-test.d.ts +22 -0
  36. package/dist/commands/add-test.js +285 -0
  37. package/dist/commands/audit.d.ts +39 -0
  38. package/dist/commands/audit.js +171 -0
  39. package/dist/commands/build-catalog.d.ts +68 -0
  40. package/dist/commands/build-catalog.js +105 -0
  41. package/dist/commands/build-flows.d.ts +26 -0
  42. package/dist/commands/build-flows.js +174 -0
  43. package/dist/commands/build.d.ts +17 -0
  44. package/dist/commands/build.js +39 -0
  45. package/dist/commands/check-hashes.d.ts +38 -0
  46. package/dist/commands/check-hashes.js +97 -0
  47. package/dist/commands/create-project.d.ts +5 -0
  48. package/dist/commands/create-project.js +272 -0
  49. package/dist/commands/dev.d.ts +15 -0
  50. package/dist/commands/dev.js +27 -0
  51. package/dist/commands/doctor.d.ts +48 -0
  52. package/dist/commands/doctor.js +1301 -0
  53. package/dist/commands/explain.d.ts +34 -0
  54. package/dist/commands/explain.js +167 -0
  55. package/dist/commands/power-tune.d.ts +1 -0
  56. package/dist/commands/power-tune.js +87 -0
  57. package/dist/commands/reseed-page.d.ts +6 -0
  58. package/dist/commands/reseed-page.js +297 -0
  59. package/dist/commands/show-dns.d.ts +15 -0
  60. package/dist/commands/show-dns.js +101 -0
  61. package/dist/commands/show.d.ts +46 -0
  62. package/dist/commands/show.js +135 -0
  63. package/dist/commands/test.d.ts +18 -0
  64. package/dist/commands/test.js +56 -0
  65. package/dist/commands/upgrade-backend.d.ts +9 -0
  66. package/dist/commands/upgrade-backend.js +46 -0
  67. package/dist/commands/upgrade-event.d.ts +22 -0
  68. package/dist/commands/upgrade-event.js +111 -0
  69. package/dist/commands/upgrade-frontend.d.ts +5 -0
  70. package/dist/commands/upgrade-frontend.js +99 -0
  71. package/dist/commands/upgrade.d.ts +7 -0
  72. package/dist/commands/upgrade.js +495 -0
  73. package/dist/commands/validate.d.ts +30 -0
  74. package/dist/commands/validate.js +148 -0
  75. package/dist/runtime-stubs/hydrate-local-ctx.d.ts +49 -0
  76. package/dist/runtime-stubs/hydrate-local-ctx.js +45 -0
  77. package/dist/runtime-stubs/load-secrets.d.ts +11 -0
  78. package/dist/runtime-stubs/load-secrets.js +37 -0
  79. package/dist/server/api-server.d.ts +21 -0
  80. package/dist/server/api-server.js +14 -0
  81. package/dist/server/mount-routes.d.ts +19 -0
  82. package/dist/server/mount-routes.js +42 -0
  83. package/dist/templates/api-skeleton.d.ts +18 -0
  84. package/dist/templates/api-skeleton.js +69 -0
  85. package/dist/templates/claude-md.d.ts +4 -0
  86. package/dist/templates/claude-md.js +7 -0
  87. package/dist/templates/dashboard-pages/index.d.ts +101 -0
  88. package/dist/templates/dashboard-pages/index.js +22 -0
  89. package/dist/templates/domain-config.d.ts +10 -0
  90. package/dist/templates/domain-config.js +19 -0
  91. package/dist/templates/events-skeleton.d.ts +9 -0
  92. package/dist/templates/events-skeleton.js +23 -0
  93. package/dist/templates/patterns/api/create-with-event.d.ts +5 -0
  94. package/dist/templates/patterns/api/create-with-event.js +69 -0
  95. package/dist/templates/patterns/api/idempotent-mutation.d.ts +5 -0
  96. package/dist/templates/patterns/api/idempotent-mutation.js +99 -0
  97. package/dist/templates/patterns/api/paginated-list.d.ts +5 -0
  98. package/dist/templates/patterns/api/paginated-list.js +71 -0
  99. package/dist/templates/patterns/api/simple-crud.d.ts +6 -0
  100. package/dist/templates/patterns/api/simple-crud.js +95 -0
  101. package/dist/templates/patterns/api/system-admin.d.ts +5 -0
  102. package/dist/templates/patterns/api/system-admin.js +98 -0
  103. package/dist/templates/patterns/api/webhook-receiver-style.d.ts +5 -0
  104. package/dist/templates/patterns/api/webhook-receiver-style.js +125 -0
  105. package/dist/templates/patterns/component/index.d.ts +25 -0
  106. package/dist/templates/patterns/component/index.js +814 -0
  107. package/dist/templates/patterns/flow/autonomous-remediation.d.ts +5 -0
  108. package/dist/templates/patterns/flow/autonomous-remediation.js +98 -0
  109. package/dist/templates/patterns/flow/fan-out-reaction.d.ts +5 -0
  110. package/dist/templates/patterns/flow/fan-out-reaction.js +53 -0
  111. package/dist/templates/patterns/flow/human-in-loop.d.ts +5 -0
  112. package/dist/templates/patterns/flow/human-in-loop.js +90 -0
  113. package/dist/templates/patterns/flow/multi-step-orchestration.d.ts +5 -0
  114. package/dist/templates/patterns/flow/multi-step-orchestration.js +67 -0
  115. package/dist/templates/patterns/hook/index.d.ts +7 -0
  116. package/dist/templates/patterns/hook/index.js +378 -0
  117. package/dist/templates/patterns/migration/column-add.d.ts +5 -0
  118. package/dist/templates/patterns/migration/column-add.js +52 -0
  119. package/dist/templates/patterns/migration/event-store.d.ts +6 -0
  120. package/dist/templates/patterns/migration/event-store.js +97 -0
  121. package/dist/templates/patterns/migration/index.d.ts +5 -0
  122. package/dist/templates/patterns/migration/index.js +52 -0
  123. package/dist/templates/patterns/migration/rls-policy.d.ts +5 -0
  124. package/dist/templates/patterns/migration/rls-policy.js +78 -0
  125. package/dist/templates/patterns/migration/tenant-scoped-table.d.ts +6 -0
  126. package/dist/templates/patterns/migration/tenant-scoped-table.js +81 -0
  127. package/dist/templates/patterns/page/dashboard.d.ts +4 -0
  128. package/dist/templates/patterns/page/dashboard.js +164 -0
  129. package/dist/templates/patterns/page/detail.d.ts +4 -0
  130. package/dist/templates/patterns/page/detail.js +162 -0
  131. package/dist/templates/patterns/page/form.d.ts +4 -0
  132. package/dist/templates/patterns/page/form.js +192 -0
  133. package/dist/templates/patterns/page/index.d.ts +8 -0
  134. package/dist/templates/patterns/page/index.js +14 -0
  135. package/dist/templates/patterns/page/list.d.ts +4 -0
  136. package/dist/templates/patterns/page/list.js +138 -0
  137. package/dist/templates/patterns/page/settings.d.ts +4 -0
  138. package/dist/templates/patterns/page/settings.js +318 -0
  139. package/dist/templates/patterns/page/wizard.d.ts +4 -0
  140. package/dist/templates/patterns/page/wizard.js +293 -0
  141. package/dist/templates/patterns/subscriber/audit-relay.d.ts +5 -0
  142. package/dist/templates/patterns/subscriber/audit-relay.js +51 -0
  143. package/dist/templates/patterns/subscriber/cascade-deletion.d.ts +5 -0
  144. package/dist/templates/patterns/subscriber/cascade-deletion.js +60 -0
  145. package/dist/templates/patterns/subscriber/single-step-projection.d.ts +5 -0
  146. package/dist/templates/patterns/subscriber/single-step-projection.js +36 -0
  147. package/dist/templates/readme.d.ts +4 -0
  148. package/dist/templates/readme.js +7 -0
  149. package/dist/templates/subscriber-skeleton.d.ts +11 -0
  150. package/dist/templates/subscriber-skeleton.js +23 -0
  151. package/dist/utils/checksum.d.ts +18 -0
  152. package/dist/utils/checksum.js +39 -0
  153. package/dist/utils/file-helpers.d.ts +35 -0
  154. package/dist/utils/file-helpers.js +59 -0
  155. package/dist/utils/header-inject.d.ts +15 -0
  156. package/dist/utils/header-inject.js +93 -0
  157. package/dist/utils/install-file.d.ts +17 -0
  158. package/dist/utils/install-file.js +78 -0
  159. package/dist/utils/logger.d.ts +12 -0
  160. package/dist/utils/logger.js +24 -0
  161. package/dist/utils/manifest.d.ts +28 -0
  162. package/dist/utils/manifest.js +93 -0
  163. package/dist/utils/s3-fetch.d.ts +25 -0
  164. package/dist/utils/s3-fetch.js +52 -0
  165. package/dist/utils/scaffold-config.d.ts +95 -0
  166. package/dist/utils/scaffold-config.js +53 -0
  167. package/dist/utils/token-substitution.d.ts +35 -0
  168. package/dist/utils/token-substitution.js +52 -0
  169. package/dist/utils/zod-extractor.d.ts +10 -0
  170. package/dist/utils/zod-extractor.js +28 -0
  171. package/package.json +43 -0
  172. package/src/__tests__/build-registry.test.ts +115 -0
  173. package/src/__tests__/build-types.test.ts +100 -0
  174. package/src/__tests__/builder/walkDomainDir.test.ts +140 -0
  175. package/src/__tests__/commands/add-domain.test.ts +174 -0
  176. package/src/__tests__/commands/add-flow.test.ts +69 -0
  177. package/src/__tests__/commands/add-module.test.ts +207 -0
  178. package/src/__tests__/commands/build-flows.test.ts +154 -0
  179. package/src/__tests__/commands/check-hashes.test.ts +142 -0
  180. package/src/__tests__/commands/create-project.test.ts +712 -0
  181. package/src/__tests__/commands/upgrade.test.ts +762 -0
  182. package/src/__tests__/doctor.test.ts +291 -0
  183. package/src/__tests__/hydrate-local-ctx.test.ts +71 -0
  184. package/src/__tests__/load-domain-module-config.test.ts +10 -0
  185. package/src/__tests__/mount-routes.test.ts +100 -0
  186. package/src/__tests__/scaffold-src/part-a-layout.test.ts +46 -0
  187. package/src/__tests__/scripts/package-scaffold.test.ts +45 -0
  188. package/src/__tests__/utils/checksum.test.ts +90 -0
  189. package/src/__tests__/utils/header-inject.test.ts +89 -0
  190. package/src/__tests__/utils/install-file.test.ts +207 -0
  191. package/src/__tests__/utils/manifest.test.ts +229 -0
  192. package/src/__tests__/utils/token-substitution.test.ts +134 -0
  193. package/src/__tests__/validate.test.ts +18 -0
  194. package/src/builder/build-registry.ts +237 -0
  195. package/src/builder/build-types.ts +36 -0
  196. package/src/builder/load-domain-module-config.ts +43 -0
  197. package/src/builder/load-module.ts +133 -0
  198. package/src/builder/zod-to-json.ts +16 -0
  199. package/src/cli.ts +387 -0
  200. package/src/commands/add-api.ts +145 -0
  201. package/src/commands/add-component.ts +116 -0
  202. package/src/commands/add-domain.ts +100 -0
  203. package/src/commands/add-flow.ts +136 -0
  204. package/src/commands/add-hook.ts +100 -0
  205. package/src/commands/add-migration.ts +129 -0
  206. package/src/commands/add-module.ts +347 -0
  207. package/src/commands/add-page.ts +242 -0
  208. package/src/commands/add-seed-page.ts +338 -0
  209. package/src/commands/add-subscriber.ts +123 -0
  210. package/src/commands/add-test.ts +328 -0
  211. package/src/commands/audit.ts +217 -0
  212. package/src/commands/build-catalog.ts +181 -0
  213. package/src/commands/build-flows.ts +198 -0
  214. package/src/commands/build.ts +59 -0
  215. package/src/commands/check-hashes.ts +150 -0
  216. package/src/commands/create-project.ts +367 -0
  217. package/src/commands/dev.ts +47 -0
  218. package/src/commands/doctor.ts +1362 -0
  219. package/src/commands/explain.ts +209 -0
  220. package/src/commands/power-tune.ts +112 -0
  221. package/src/commands/reseed-page.ts +410 -0
  222. package/src/commands/show-dns.ts +139 -0
  223. package/src/commands/show.ts +179 -0
  224. package/src/commands/test.ts +91 -0
  225. package/src/commands/upgrade-backend.ts +72 -0
  226. package/src/commands/upgrade-event.ts +175 -0
  227. package/src/commands/upgrade-frontend.ts +135 -0
  228. package/src/commands/upgrade.ts +668 -0
  229. package/src/commands/validate.ts +208 -0
  230. package/src/runtime-stubs/hydrate-local-ctx.ts +102 -0
  231. package/src/runtime-stubs/load-secrets.ts +40 -0
  232. package/src/server/api-server.ts +34 -0
  233. package/src/server/mount-routes.ts +68 -0
  234. package/src/templates/api-skeleton.ts +72 -0
  235. package/src/templates/claude-md.ts +7 -0
  236. package/src/templates/dashboard-pages/account/api-keys.tsx +107 -0
  237. package/src/templates/dashboard-pages/account/audit-log.tsx +60 -0
  238. package/src/templates/dashboard-pages/account/index.ts +5 -0
  239. package/src/templates/dashboard-pages/account/members.tsx +107 -0
  240. package/src/templates/dashboard-pages/account/profile.tsx +53 -0
  241. package/src/templates/dashboard-pages/account/workspace-settings.tsx +64 -0
  242. package/src/templates/dashboard-pages/auth/accept-invitation.tsx +62 -0
  243. package/src/templates/dashboard-pages/auth/choose-org.tsx +66 -0
  244. package/src/templates/dashboard-pages/auth/forgot-password.tsx +69 -0
  245. package/src/templates/dashboard-pages/auth/index.ts +10 -0
  246. package/src/templates/dashboard-pages/auth/login.tsx +75 -0
  247. package/src/templates/dashboard-pages/auth/mfa-setup.tsx +56 -0
  248. package/src/templates/dashboard-pages/auth/mfa-verify.tsx +49 -0
  249. package/src/templates/dashboard-pages/auth/reset-password.tsx +64 -0
  250. package/src/templates/dashboard-pages/auth/sign-out.tsx +20 -0
  251. package/src/templates/dashboard-pages/auth/signup.tsx +71 -0
  252. package/src/templates/dashboard-pages/auth/verify-email.tsx +53 -0
  253. package/src/templates/dashboard-pages/index.ts +22 -0
  254. package/src/templates/dashboard-pages/shell/dashboard-home.tsx +41 -0
  255. package/src/templates/dashboard-pages/shell/forbidden.tsx +46 -0
  256. package/src/templates/dashboard-pages/shell/index.ts +5 -0
  257. package/src/templates/dashboard-pages/shell/maintenance.tsx +27 -0
  258. package/src/templates/dashboard-pages/shell/not-found.tsx +34 -0
  259. package/src/templates/dashboard-pages/shell/server-error.tsx +44 -0
  260. package/src/templates/domain-config.ts +20 -0
  261. package/src/templates/events-skeleton.ts +24 -0
  262. package/src/templates/patterns/api/create-with-event.ts +72 -0
  263. package/src/templates/patterns/api/idempotent-mutation.ts +102 -0
  264. package/src/templates/patterns/api/paginated-list.ts +74 -0
  265. package/src/templates/patterns/api/simple-crud.ts +100 -0
  266. package/src/templates/patterns/api/system-admin.ts +101 -0
  267. package/src/templates/patterns/api/webhook-receiver-style.ts +128 -0
  268. package/src/templates/patterns/component/index.ts +833 -0
  269. package/src/templates/patterns/flow/autonomous-remediation.ts +101 -0
  270. package/src/templates/patterns/flow/fan-out-reaction.ts +56 -0
  271. package/src/templates/patterns/flow/human-in-loop.ts +93 -0
  272. package/src/templates/patterns/flow/multi-step-orchestration.ts +70 -0
  273. package/src/templates/patterns/hook/index.ts +382 -0
  274. package/src/templates/patterns/migration/column-add.ts +54 -0
  275. package/src/templates/patterns/migration/event-store.ts +99 -0
  276. package/src/templates/patterns/migration/index.ts +54 -0
  277. package/src/templates/patterns/migration/rls-policy.ts +80 -0
  278. package/src/templates/patterns/migration/tenant-scoped-table.ts +83 -0
  279. package/src/templates/patterns/page/dashboard.ts +164 -0
  280. package/src/templates/patterns/page/detail.ts +162 -0
  281. package/src/templates/patterns/page/form.ts +192 -0
  282. package/src/templates/patterns/page/index.ts +17 -0
  283. package/src/templates/patterns/page/list.ts +138 -0
  284. package/src/templates/patterns/page/settings.ts +318 -0
  285. package/src/templates/patterns/page/wizard.ts +293 -0
  286. package/src/templates/patterns/subscriber/audit-relay.ts +54 -0
  287. package/src/templates/patterns/subscriber/cascade-deletion.ts +63 -0
  288. package/src/templates/patterns/subscriber/single-step-projection.ts +39 -0
  289. package/src/templates/readme.ts +7 -0
  290. package/src/templates/subscriber-skeleton.ts +28 -0
  291. package/src/utils/checksum.ts +43 -0
  292. package/src/utils/file-helpers.ts +91 -0
  293. package/src/utils/header-inject.ts +126 -0
  294. package/src/utils/install-file.ts +99 -0
  295. package/src/utils/logger.ts +26 -0
  296. package/src/utils/manifest.ts +137 -0
  297. package/src/utils/s3-fetch.ts +91 -0
  298. package/src/utils/scaffold-config.ts +172 -0
  299. package/src/utils/token-substitution.ts +80 -0
  300. package/src/utils/zod-extractor.ts +31 -0
  301. package/tsconfig.json +13 -0
@@ -0,0 +1,762 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, rmdir, writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { gzipSync } from 'node:zlib';
6
+
7
+ vi.mock('../../utils/s3-fetch.js', () => ({
8
+ fetchVersionsJson: vi.fn(),
9
+ fetchModulesJson: vi.fn(),
10
+ fetchModuleTarball: vi.fn(),
11
+ fetchScaffoldFile: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('../../utils/manifest.js', async () => {
15
+ const actual = await vi.importActual<typeof import('../../utils/manifest.js')>('../../utils/manifest.js');
16
+ return {
17
+ readManifest: vi.fn(),
18
+ writeManifest: vi.fn(),
19
+ upsertManifestFile: vi.fn(),
20
+ removeManifestFile: vi.fn(),
21
+ inferPolicyFromPath: actual.inferPolicyFromPath,
22
+ };
23
+ });
24
+
25
+ vi.mock('../../utils/scaffold-config.js', () => ({
26
+ readScaffoldConfig: vi.fn(),
27
+ writeScaffoldConfig: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('node:child_process', () => ({
31
+ execSync: vi.fn((cmd: string) => {
32
+ // Mock git operations without throwing
33
+ if (cmd.includes('git remote')) return 'https://github.com/test/repo.git';
34
+ if (cmd.includes('git status')) return '';
35
+ return '';
36
+ }),
37
+ }));
38
+
39
+ import { runUpgrade } from '../../commands/upgrade.js';
40
+ import { fetchVersionsJson, fetchModulesJson, fetchModuleTarball } from '../../utils/s3-fetch.js';
41
+ import { readManifest, writeManifest, upsertManifestFile } from '../../utils/manifest.js';
42
+ import { readScaffoldConfig, writeScaffoldConfig } from '../../utils/scaffold-config.js';
43
+ import { computeChecksumString } from '../../utils/checksum.js';
44
+
45
+ const mockFetchVersionsJson = vi.mocked(fetchVersionsJson);
46
+ const mockFetchModulesJson = vi.mocked(fetchModulesJson);
47
+ const mockFetchModuleTarball = vi.mocked(fetchModuleTarball);
48
+ const mockReadManifest = vi.mocked(readManifest);
49
+ const mockWriteManifest = vi.mocked(writeManifest);
50
+ const mockUpsertManifestFile = vi.mocked(upsertManifestFile);
51
+ const mockReadScaffoldConfig = vi.mocked(readScaffoldConfig);
52
+ const mockWriteScaffoldConfig = vi.mocked(writeScaffoldConfig);
53
+
54
+ describe('upgrade command', () => {
55
+ let tmpDir: string;
56
+
57
+ beforeEach(async () => {
58
+ tmpDir = await mkdtemp(join(tmpdir(), 'tib-upgrade-test-'));
59
+ vi.clearAllMocks();
60
+ });
61
+
62
+ afterEach(async () => {
63
+ try {
64
+ await rmdir(tmpDir, { recursive: true });
65
+ } catch {
66
+ // Ignore cleanup errors
67
+ }
68
+ });
69
+
70
+ describe('already at latest version', () => {
71
+ it('exits 0 with no file changes when currentVersion === targetVersion', async () => {
72
+ const currentVersion = '1.0.0';
73
+ const targetVersion = '1.0.0';
74
+
75
+ mockReadManifest.mockResolvedValue({
76
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
77
+ scaffoldVersion: currentVersion,
78
+ createdAt: '2026-01-01T00:00:00Z',
79
+ updatedAt: '2026-01-01T00:00:00Z',
80
+ projectName: 'test-project',
81
+ awsRegion: 'eu-north-1',
82
+ enabledModules: ['core'],
83
+ files: [],
84
+ });
85
+
86
+ mockReadScaffoldConfig.mockResolvedValue({
87
+ scaffoldVersion: currentVersion,
88
+ enabledModules: ['core'],
89
+ projectName: 'test-project',
90
+ awsRegion: 'eu-north-1',
91
+ awsAccountId: '123456789012',
92
+ domainIds: [],
93
+ flowIds: [],
94
+ scaffoldBucket: 'mc-scaffold',
95
+ });
96
+
97
+ mockFetchVersionsJson.mockResolvedValue({
98
+ latest: targetVersion,
99
+ versions: [currentVersion, targetVersion],
100
+ });
101
+
102
+ // Should not throw and should return without further processing
103
+ await expect(runUpgrade(undefined, { dryRun: false, projectDir: tmpDir })).resolves.not.toThrow();
104
+
105
+ // Manifest should not be written
106
+ expect(mockWriteManifest).not.toHaveBeenCalled();
107
+ expect(mockWriteScaffoldConfig).not.toHaveBeenCalled();
108
+ });
109
+ });
110
+
111
+ describe('applying diff to non-drifted files', () => {
112
+ it('applies diff when file content is not drifted', async () => {
113
+ const oldChecksum = computeChecksumString('old content');
114
+ const newContent = 'new content';
115
+ const newChecksum = computeChecksumString(newContent);
116
+ const filePath = 'infra/modules/app.ts';
117
+
118
+ // Create the file with old content matching the manifest checksum
119
+ await mkdir(join(tmpDir, 'infra', 'modules'), { recursive: true });
120
+ await writeFile(join(tmpDir, filePath), 'old content', 'utf-8');
121
+
122
+ mockReadManifest.mockResolvedValue({
123
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
124
+ scaffoldVersion: '1.0.0',
125
+ createdAt: '2026-01-01T00:00:00Z',
126
+ updatedAt: '2026-01-01T00:00:00Z',
127
+ projectName: 'test-project',
128
+ awsRegion: 'eu-north-1',
129
+ enabledModules: ['core'],
130
+ files: [
131
+ {
132
+ path: filePath,
133
+ module: 'core',
134
+ moduleVersion: '1.0.0',
135
+ sha256: oldChecksum,
136
+ wasTemplate: false,
137
+ installedAt: '2026-01-01T00:00:00Z',
138
+ policy: 'tracked',
139
+ },
140
+ ],
141
+ });
142
+
143
+ mockReadScaffoldConfig.mockResolvedValue({
144
+ scaffoldVersion: '1.0.0',
145
+ enabledModules: ['core'],
146
+ projectName: 'test-project',
147
+ awsRegion: 'eu-north-1',
148
+ awsAccountId: '123456789012',
149
+ domainIds: [],
150
+ flowIds: [],
151
+ scaffoldBucket: 'mc-scaffold',
152
+ });
153
+
154
+ mockFetchVersionsJson.mockResolvedValue({
155
+ latest: '1.1.0',
156
+ versions: ['1.0.0', '1.1.0'],
157
+ });
158
+
159
+ mockFetchModulesJson.mockImplementation(async (bucket: string, version: string) => {
160
+ if (version === '1.0.0') {
161
+ return {
162
+ scaffoldVersion: '1.0.0',
163
+ releasedAt: '2026-01-01T00:00:00Z',
164
+ modules: [
165
+ {
166
+ id: 'core',
167
+ version: '1.0.0',
168
+ description: 'Core scaffold',
169
+ dependencies: [],
170
+ tarball: 'core-1.0.0.tar.gz',
171
+ sha256: 'old123',
172
+ },
173
+ ],
174
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
175
+ };
176
+ }
177
+ return {
178
+ scaffoldVersion: '1.1.0',
179
+ releasedAt: '2026-02-01T00:00:00Z',
180
+ modules: [
181
+ {
182
+ id: 'core',
183
+ version: '1.1.0',
184
+ description: 'Core scaffold',
185
+ dependencies: [],
186
+ tarball: 'core-1.1.0.tar.gz',
187
+ sha256: 'abc123',
188
+ },
189
+ ],
190
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
191
+ };
192
+ });
193
+
194
+ // Mock tarball with MANIFEST.json and the file
195
+ const tarballContent = createMockTarball([
196
+ { path: 'MANIFEST.json', content: JSON.stringify([
197
+ { path: filePath, sha256: newChecksum, isTemplate: false },
198
+ ]) },
199
+ { path: filePath, content: newContent },
200
+ ]);
201
+
202
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
203
+
204
+ await runUpgrade('1.1.0', { dryRun: false, projectDir: tmpDir });
205
+
206
+ // Assert file was updated
207
+ const fileContent = await readFile(join(tmpDir, filePath), 'utf-8');
208
+ expect(fileContent).toContain(newContent);
209
+
210
+ // Assert manifest was updated
211
+ expect(mockWriteManifest).toHaveBeenCalled();
212
+ expect(mockWriteScaffoldConfig).toHaveBeenCalled();
213
+ });
214
+ });
215
+
216
+ describe('drift detection and .tib-upgrade conflict files', () => {
217
+ it('writes .tib-upgrade file when file is drifted', async () => {
218
+ const manifestChecksum = computeChecksumString('original content');
219
+ const driftedContent = 'locally modified content by user';
220
+ const driftedChecksum = computeChecksumString(driftedContent);
221
+ const newContent = 'new content from upgrade';
222
+ const newChecksum = computeChecksumString(newContent);
223
+ const filePath = 'mc-deploy.yml';
224
+
225
+ // Create file with drifted (locally modified) content
226
+ await mkdir(join(tmpDir), { recursive: true });
227
+ await writeFile(join(tmpDir, filePath), driftedContent, 'utf-8');
228
+
229
+ mockReadManifest.mockResolvedValue({
230
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
231
+ scaffoldVersion: '1.0.0',
232
+ createdAt: '2026-01-01T00:00:00Z',
233
+ updatedAt: '2026-01-01T00:00:00Z',
234
+ projectName: 'test-project',
235
+ awsRegion: 'eu-north-1',
236
+ enabledModules: ['core'],
237
+ files: [
238
+ {
239
+ path: filePath,
240
+ module: 'core',
241
+ moduleVersion: '1.0.0',
242
+ sha256: manifestChecksum, // Original checksum in manifest
243
+ wasTemplate: false,
244
+ installedAt: '2026-01-01T00:00:00Z',
245
+ policy: 'tracked',
246
+ },
247
+ ],
248
+ });
249
+
250
+ mockReadScaffoldConfig.mockResolvedValue({
251
+ scaffoldVersion: '1.0.0',
252
+ enabledModules: ['core'],
253
+ projectName: 'test-project',
254
+ awsRegion: 'eu-north-1',
255
+ awsAccountId: '123456789012',
256
+ domainIds: [],
257
+ flowIds: [],
258
+ scaffoldBucket: 'mc-scaffold',
259
+ });
260
+
261
+ mockFetchVersionsJson.mockResolvedValue({
262
+ latest: '1.1.0',
263
+ versions: ['1.0.0', '1.1.0'],
264
+ });
265
+
266
+ mockFetchModulesJson.mockImplementation(async (bucket: string, version: string) => {
267
+ if (version === '1.0.0') {
268
+ return {
269
+ scaffoldVersion: '1.0.0',
270
+ releasedAt: '2026-01-01T00:00:00Z',
271
+ modules: [
272
+ {
273
+ id: 'core',
274
+ version: '1.0.0',
275
+ description: 'Core scaffold',
276
+ dependencies: [],
277
+ tarball: 'core-1.0.0.tar.gz',
278
+ sha256: 'old123',
279
+ },
280
+ ],
281
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
282
+ };
283
+ }
284
+ return {
285
+ scaffoldVersion: '1.1.0',
286
+ releasedAt: '2026-02-01T00:00:00Z',
287
+ modules: [
288
+ {
289
+ id: 'core',
290
+ version: '1.1.0',
291
+ description: 'Core scaffold',
292
+ dependencies: [],
293
+ tarball: 'core-1.1.0.tar.gz',
294
+ sha256: 'abc123',
295
+ },
296
+ ],
297
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
298
+ };
299
+ });
300
+
301
+ const tarballContent = createMockTarball([
302
+ { path: 'MANIFEST.json', content: JSON.stringify([
303
+ { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'tracked' },
304
+ ]) },
305
+ { path: filePath, content: newContent },
306
+ ]);
307
+
308
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
309
+
310
+ await runUpgrade('1.1.0', { dryRun: false, projectDir: tmpDir });
311
+
312
+ // Assert original file is NOT overwritten
313
+ const originalContent = await readFile(join(tmpDir, filePath), 'utf-8');
314
+ expect(originalContent).toBe(driftedContent);
315
+
316
+ // Assert .tib-upgrade file was created with new content
317
+ const conflictFile = await readFile(join(tmpDir, `${filePath}.tib-upgrade`), 'utf-8');
318
+ expect(conflictFile).toContain(newContent);
319
+ });
320
+ });
321
+
322
+ describe('dry-run mode', () => {
323
+ it('does not write files when --dry-run is set', async () => {
324
+ const oldChecksum = computeChecksumString('old content');
325
+ const newContent = 'new content';
326
+ const newChecksum = computeChecksumString(newContent);
327
+ const filePath = 'infra/modules/app.ts';
328
+
329
+ // Create the file with old content
330
+ await mkdir(join(tmpDir, 'infra', 'modules'), { recursive: true });
331
+ await writeFile(join(tmpDir, filePath), 'old content', 'utf-8');
332
+
333
+ mockReadManifest.mockResolvedValue({
334
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v1.json',
335
+ scaffoldVersion: '1.0.0',
336
+ createdAt: '2026-01-01T00:00:00Z',
337
+ updatedAt: '2026-01-01T00:00:00Z',
338
+ projectName: 'test-project',
339
+ awsRegion: 'eu-north-1',
340
+ enabledModules: ['core'],
341
+ files: [
342
+ {
343
+ path: filePath,
344
+ module: 'core',
345
+ moduleVersion: '1.0.0',
346
+ sha256: oldChecksum,
347
+ wasTemplate: false,
348
+ installedAt: '2026-01-01T00:00:00Z',
349
+ policy: 'owned',
350
+ },
351
+ ],
352
+ });
353
+
354
+ mockReadScaffoldConfig.mockResolvedValue({
355
+ scaffoldVersion: '1.0.0',
356
+ enabledModules: ['core'],
357
+ projectName: 'test-project',
358
+ awsRegion: 'eu-north-1',
359
+ awsAccountId: '123456789012',
360
+ domainIds: [],
361
+ flowIds: [],
362
+ scaffoldBucket: 'mc-scaffold',
363
+ });
364
+
365
+ mockFetchVersionsJson.mockResolvedValue({
366
+ latest: '1.1.0',
367
+ versions: ['1.0.0', '1.1.0'],
368
+ });
369
+
370
+ mockFetchModulesJson.mockImplementation(async (bucket: string, version: string) => {
371
+ if (version === '1.0.0') {
372
+ return {
373
+ scaffoldVersion: '1.0.0',
374
+ releasedAt: '2026-01-01T00:00:00Z',
375
+ modules: [
376
+ {
377
+ id: 'core',
378
+ version: '1.0.0',
379
+ description: 'Core scaffold',
380
+ dependencies: [],
381
+ tarball: 'core-1.0.0.tar.gz',
382
+ sha256: 'old123',
383
+ },
384
+ ],
385
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
386
+ };
387
+ }
388
+ return {
389
+ scaffoldVersion: '1.1.0',
390
+ releasedAt: '2026-02-01T00:00:00Z',
391
+ modules: [
392
+ {
393
+ id: 'core',
394
+ version: '1.1.0',
395
+ description: 'Core scaffold',
396
+ dependencies: [],
397
+ tarball: 'core-1.1.0.tar.gz',
398
+ sha256: 'abc123',
399
+ },
400
+ ],
401
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
402
+ };
403
+ });
404
+
405
+ const tarballContent = createMockTarball([
406
+ { path: 'MANIFEST.json', content: JSON.stringify([
407
+ { path: filePath, sha256: newChecksum, isTemplate: false },
408
+ ]) },
409
+ { path: filePath, content: newContent },
410
+ ]);
411
+
412
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
413
+
414
+ await runUpgrade('1.1.0', { dryRun: true, projectDir: tmpDir });
415
+
416
+ // Assert file was NOT updated
417
+ const fileContent = await readFile(join(tmpDir, filePath), 'utf-8');
418
+ expect(fileContent).toBe('old content');
419
+
420
+ // Assert manifest was NOT written
421
+ expect(mockWriteManifest).not.toHaveBeenCalled();
422
+ expect(mockWriteScaffoldConfig).not.toHaveBeenCalled();
423
+ });
424
+ });
425
+
426
+ describe('policy-based file handling', () => {
427
+ it('owned policy file always overwrites even when sha256 unchanged', async () => {
428
+ const content = 'content';
429
+ const checksum = computeChecksumString(content);
430
+ const filePath = 'infra/main.tf';
431
+
432
+ // Create file with exact same content as will be installed
433
+ await mkdir(join(tmpDir, 'infra'), { recursive: true });
434
+ await writeFile(join(tmpDir, filePath), content, 'utf-8');
435
+
436
+ mockReadManifest.mockResolvedValue({
437
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
438
+ scaffoldVersion: '1.0.0',
439
+ createdAt: '2026-01-01T00:00:00Z',
440
+ updatedAt: '2026-01-01T00:00:00Z',
441
+ projectName: 'test-project',
442
+ awsRegion: 'eu-north-1',
443
+ enabledModules: ['core'],
444
+ files: [
445
+ {
446
+ path: filePath,
447
+ module: 'core',
448
+ moduleVersion: '1.0.0',
449
+ sha256: checksum,
450
+ wasTemplate: false,
451
+ installedAt: '2026-01-01T00:00:00Z',
452
+ policy: 'owned',
453
+ },
454
+ ],
455
+ });
456
+
457
+ mockReadScaffoldConfig.mockResolvedValue({
458
+ scaffoldVersion: '1.0.0',
459
+ enabledModules: ['core'],
460
+ projectName: 'test-project',
461
+ awsRegion: 'eu-north-1',
462
+ awsAccountId: '123456789012',
463
+ domainIds: [],
464
+ flowIds: [],
465
+ scaffoldBucket: 'mc-scaffold',
466
+ });
467
+
468
+ mockFetchVersionsJson.mockResolvedValue({
469
+ latest: '1.1.0',
470
+ versions: ['1.0.0', '1.1.0'],
471
+ });
472
+
473
+ mockFetchModulesJson.mockImplementation(async (bucket: string, version: string) => {
474
+ if (version === '1.0.0') {
475
+ return {
476
+ scaffoldVersion: '1.0.0',
477
+ releasedAt: '2026-01-01T00:00:00Z',
478
+ modules: [
479
+ {
480
+ id: 'core',
481
+ version: '1.0.0',
482
+ description: 'Core scaffold',
483
+ dependencies: [],
484
+ tarball: 'core-1.0.0.tar.gz',
485
+ sha256: 'old123',
486
+ },
487
+ ],
488
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
489
+ };
490
+ }
491
+ return {
492
+ scaffoldVersion: '1.1.0',
493
+ releasedAt: '2026-02-01T00:00:00Z',
494
+ modules: [
495
+ {
496
+ id: 'core',
497
+ version: '1.1.0',
498
+ description: 'Core scaffold',
499
+ dependencies: [],
500
+ tarball: 'core-1.1.0.tar.gz',
501
+ sha256: 'abc123',
502
+ },
503
+ ],
504
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
505
+ };
506
+ });
507
+
508
+ const tarballContent = createMockTarball([
509
+ { path: 'MANIFEST.json', content: JSON.stringify([
510
+ { path: filePath, sha256: checksum, isTemplate: false, policy: 'owned' },
511
+ ]) },
512
+ { path: filePath, content },
513
+ ]);
514
+
515
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
516
+
517
+ await runUpgrade('1.1.0', { dryRun: false, projectDir: tmpDir });
518
+
519
+ // For owned policy, even with unchanged checksum, result should be 'updated'
520
+ expect(mockUpsertManifestFile).toHaveBeenCalled();
521
+ const calls = mockUpsertManifestFile.mock.calls;
522
+ const fileResultCall = calls.find((call) => call[1]?.path === filePath);
523
+ expect(fileResultCall).toBeDefined();
524
+ });
525
+
526
+ it('tracked policy file detects drift and writes conflict file', async () => {
527
+ const manifestChecksum = computeChecksumString('original content');
528
+ const driftedContent = 'user modified content';
529
+ const newContent = 'new content from upgrade';
530
+ const newChecksum = computeChecksumString(newContent);
531
+ const filePath = 'package.json';
532
+
533
+ // Create file with drifted content
534
+ await writeFile(join(tmpDir, filePath), driftedContent, 'utf-8');
535
+
536
+ mockReadManifest.mockResolvedValue({
537
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
538
+ scaffoldVersion: '1.0.0',
539
+ createdAt: '2026-01-01T00:00:00Z',
540
+ updatedAt: '2026-01-01T00:00:00Z',
541
+ projectName: 'test-project',
542
+ awsRegion: 'eu-north-1',
543
+ enabledModules: ['core'],
544
+ files: [
545
+ {
546
+ path: filePath,
547
+ module: 'core',
548
+ moduleVersion: '1.0.0',
549
+ sha256: manifestChecksum,
550
+ wasTemplate: false,
551
+ installedAt: '2026-01-01T00:00:00Z',
552
+ policy: 'tracked',
553
+ },
554
+ ],
555
+ });
556
+
557
+ mockReadScaffoldConfig.mockResolvedValue({
558
+ scaffoldVersion: '1.0.0',
559
+ enabledModules: ['core'],
560
+ projectName: 'test-project',
561
+ awsRegion: 'eu-north-1',
562
+ awsAccountId: '123456789012',
563
+ domainIds: [],
564
+ flowIds: [],
565
+ scaffoldBucket: 'mc-scaffold',
566
+ });
567
+
568
+ mockFetchVersionsJson.mockResolvedValue({
569
+ latest: '1.1.0',
570
+ versions: ['1.0.0', '1.1.0'],
571
+ });
572
+
573
+ mockFetchModulesJson.mockImplementation(async (bucket: string, version: string) => {
574
+ if (version === '1.0.0') {
575
+ return {
576
+ scaffoldVersion: '1.0.0',
577
+ releasedAt: '2026-01-01T00:00:00Z',
578
+ modules: [
579
+ {
580
+ id: 'core',
581
+ version: '1.0.0',
582
+ description: 'Core scaffold',
583
+ dependencies: [],
584
+ tarball: 'core-1.0.0.tar.gz',
585
+ sha256: 'old123',
586
+ },
587
+ ],
588
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
589
+ };
590
+ }
591
+ return {
592
+ scaffoldVersion: '1.1.0',
593
+ releasedAt: '2026-02-01T00:00:00Z',
594
+ modules: [
595
+ {
596
+ id: 'core',
597
+ version: '1.1.0',
598
+ description: 'Core scaffold',
599
+ dependencies: [],
600
+ tarball: 'core-1.1.0.tar.gz',
601
+ sha256: 'abc123',
602
+ },
603
+ ],
604
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
605
+ };
606
+ });
607
+
608
+ const tarballContent = createMockTarball([
609
+ { path: 'MANIFEST.json', content: JSON.stringify([
610
+ { path: filePath, sha256: newChecksum, isTemplate: false, policy: 'tracked' },
611
+ ]) },
612
+ { path: filePath, content: newContent },
613
+ ]);
614
+
615
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
616
+
617
+ await runUpgrade('1.1.0', { dryRun: false, projectDir: tmpDir });
618
+
619
+ // Original file should not be modified
620
+ const originalContent = await readFile(join(tmpDir, filePath), 'utf-8');
621
+ expect(originalContent).toBe(driftedContent);
622
+
623
+ // .tib-upgrade file should be created
624
+ const conflictFile = await readFile(join(tmpDir, `${filePath}.tib-upgrade`), 'utf-8');
625
+ expect(conflictFile).toContain(newContent);
626
+ });
627
+
628
+ it('seed policy file is skipped when already exists (not in --frontend-components mode)', async () => {
629
+ const existingContent = 'existing seed page';
630
+ const newContent = 'new seed page';
631
+ const filePath = 'frontend/src/pages/auth/login.tsx';
632
+
633
+ // Create existing seed file
634
+ await mkdir(join(tmpDir, 'frontend', 'src', 'pages', 'auth'), { recursive: true });
635
+ await writeFile(join(tmpDir, filePath), existingContent, 'utf-8');
636
+
637
+ mockReadManifest.mockResolvedValue({
638
+ $schema: 'https://mc-scaffold.s3.amazonaws.com/schema/manifest.v2.json',
639
+ scaffoldVersion: '1.0.0',
640
+ createdAt: '2026-01-01T00:00:00Z',
641
+ updatedAt: '2026-01-01T00:00:00Z',
642
+ projectName: 'test-project',
643
+ awsRegion: 'eu-north-1',
644
+ enabledModules: ['core', 'frontend'],
645
+ files: [
646
+ {
647
+ path: filePath,
648
+ module: 'frontend',
649
+ moduleVersion: '1.0.0',
650
+ sha256: computeChecksumString(existingContent),
651
+ wasTemplate: true,
652
+ installedAt: '2026-01-01T00:00:00Z',
653
+ policy: 'seed',
654
+ },
655
+ ],
656
+ });
657
+
658
+ mockReadScaffoldConfig.mockResolvedValue({
659
+ scaffoldVersion: '1.0.0',
660
+ enabledModules: ['core', 'frontend'],
661
+ projectName: 'test-project',
662
+ awsRegion: 'eu-north-1',
663
+ awsAccountId: '123456789012',
664
+ domainIds: [],
665
+ flowIds: [],
666
+ scaffoldBucket: 'mc-scaffold',
667
+ });
668
+
669
+ mockFetchVersionsJson.mockResolvedValue({
670
+ latest: '1.1.0',
671
+ versions: ['1.0.0', '1.1.0'],
672
+ });
673
+
674
+ const frontendModule = {
675
+ id: 'frontend',
676
+ version: '1.1.0',
677
+ description: 'Frontend scaffold',
678
+ dependencies: ['core'],
679
+ tarball: 'frontend-1.1.0.tar.gz',
680
+ sha256: 'def456',
681
+ };
682
+
683
+ mockFetchModulesJson.mockResolvedValue({
684
+ scaffoldVersion: '1.1.0',
685
+ releasedAt: '2026-02-01T00:00:00Z',
686
+ modules: [
687
+ {
688
+ id: 'core',
689
+ version: '1.1.0',
690
+ description: 'Core scaffold',
691
+ dependencies: [],
692
+ tarball: 'core-1.1.0.tar.gz',
693
+ sha256: 'abc123',
694
+ },
695
+ frontendModule,
696
+ ],
697
+ compatibility: { cliMinVersion: '0.1.0', nodeMinVersion: '20.0.0' },
698
+ });
699
+
700
+ const tarballContent = createMockTarball([
701
+ { path: 'MANIFEST.json', content: JSON.stringify([
702
+ { path: filePath, sha256: computeChecksumString(newContent), isTemplate: true, policy: 'seed' },
703
+ ]) },
704
+ { path: filePath, content: newContent },
705
+ ]);
706
+
707
+ mockFetchModuleTarball.mockResolvedValue(Buffer.from(tarballContent));
708
+
709
+ // NOT using --frontend-components, so seed file should be skipped
710
+ await runUpgrade('1.1.0', { dryRun: false, projectDir: tmpDir });
711
+
712
+ // Seed file should not be modified
713
+ const content = await readFile(join(tmpDir, filePath), 'utf-8');
714
+ expect(content).toBe(existingContent);
715
+ });
716
+ });
717
+ });
718
+
719
+ // Helper to create a minimal gzip-compressed tarball for testing
720
+ function createMockTarball(files: Array<{ path: string; content: string }>): Buffer {
721
+ const tarEntries: Buffer[] = [];
722
+
723
+ for (const file of files) {
724
+ const content = Buffer.from(file.content, 'utf-8');
725
+ const header = Buffer.alloc(512);
726
+
727
+ // Set filename (0-100)
728
+ const nameBuffer = Buffer.from(file.path, 'utf-8');
729
+ nameBuffer.copy(header, 0);
730
+
731
+ // Set file size (124-136) in octal
732
+ const size = content.length;
733
+ const sizeOctal = size.toString(8).padStart(11, '0');
734
+ Buffer.from(sizeOctal).copy(header, 124);
735
+
736
+ // Set type flag for regular file (156) = 0x30
737
+ header[156] = 0x30;
738
+
739
+ // Set checksum (148-156) to 8 spaces initially for calculation
740
+ header.fill(0x20, 148, 156);
741
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
742
+ const checksumOctal = checksum.toString(8).padStart(6, '0');
743
+ Buffer.from(checksumOctal).copy(header, 148);
744
+
745
+ tarEntries.push(header);
746
+ tarEntries.push(content);
747
+
748
+ // Pad to 512 byte boundary
749
+ const padding = (512 - (content.length % 512)) % 512;
750
+ if (padding > 0) {
751
+ tarEntries.push(Buffer.alloc(padding));
752
+ }
753
+ }
754
+
755
+ // Add end-of-archive marker (two 512-byte zero blocks)
756
+ tarEntries.push(Buffer.alloc(512));
757
+ tarEntries.push(Buffer.alloc(512));
758
+
759
+ const tarBuffer = Buffer.concat(tarEntries);
760
+ // Gzip compress the tarball
761
+ return gzipSync(tarBuffer);
762
+ }