@applica-software-guru/sdd-core 1.3.3 → 1.4.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 (53) hide show
  1. package/dist/errors.d.ts +7 -0
  2. package/dist/errors.d.ts.map +1 -1
  3. package/dist/errors.js +17 -1
  4. package/dist/errors.js.map +1 -1
  5. package/dist/index.d.ts +9 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +27 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/prompt/apply-prompt-generator.d.ts +2 -1
  10. package/dist/prompt/apply-prompt-generator.d.ts.map +1 -1
  11. package/dist/prompt/apply-prompt-generator.js +51 -2
  12. package/dist/prompt/apply-prompt-generator.js.map +1 -1
  13. package/dist/prompt/draft-prompt-generator.d.ts +8 -0
  14. package/dist/prompt/draft-prompt-generator.d.ts.map +1 -0
  15. package/dist/prompt/draft-prompt-generator.js +59 -0
  16. package/dist/prompt/draft-prompt-generator.js.map +1 -0
  17. package/dist/remote/api-client.d.ts +38 -0
  18. package/dist/remote/api-client.d.ts.map +1 -0
  19. package/dist/remote/api-client.js +101 -0
  20. package/dist/remote/api-client.js.map +1 -0
  21. package/dist/remote/state.d.ts +4 -0
  22. package/dist/remote/state.d.ts.map +1 -0
  23. package/dist/remote/state.js +35 -0
  24. package/dist/remote/state.js.map +1 -0
  25. package/dist/remote/sync-engine.d.ts +7 -0
  26. package/dist/remote/sync-engine.d.ts.map +1 -0
  27. package/dist/remote/sync-engine.js +257 -0
  28. package/dist/remote/sync-engine.js.map +1 -0
  29. package/dist/remote/types.d.ts +93 -0
  30. package/dist/remote/types.d.ts.map +1 -0
  31. package/dist/remote/types.js +3 -0
  32. package/dist/remote/types.js.map +1 -0
  33. package/dist/sdd.d.ts +13 -0
  34. package/dist/sdd.d.ts.map +1 -1
  35. package/dist/sdd.js +99 -6
  36. package/dist/sdd.js.map +1 -1
  37. package/dist/types.d.ts +9 -4
  38. package/dist/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/errors.ts +16 -0
  41. package/src/index.ts +23 -1
  42. package/src/prompt/apply-prompt-generator.ts +61 -2
  43. package/src/prompt/draft-prompt-generator.ts +74 -0
  44. package/src/remote/api-client.ts +138 -0
  45. package/src/remote/state.ts +35 -0
  46. package/src/remote/sync-engine.ts +296 -0
  47. package/src/remote/types.ts +102 -0
  48. package/src/sdd.ts +114 -6
  49. package/src/types.ts +10 -4
  50. package/tests/api-client.test.ts +198 -0
  51. package/tests/cr.test.ts +27 -10
  52. package/tests/remote-state.test.ts +90 -0
  53. package/tests/sync-engine.test.ts +341 -0
@@ -0,0 +1,198 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import {
3
+ resolveApiKey,
4
+ buildApiConfig,
5
+ pullDocs,
6
+ pushDocs,
7
+ fetchPendingCRs,
8
+ fetchOpenBugs,
9
+ markCRAppliedRemote,
10
+ markBugResolvedRemote,
11
+ } from '../src/remote/api-client.js';
12
+ import { RemoteError, RemoteNotConfiguredError } from '../src/errors.js';
13
+ import type { SDDConfig } from '../src/types.js';
14
+
15
+ function mockFetch(response: unknown, ok = true, status = 200) {
16
+ return vi.fn().mockResolvedValue({
17
+ ok,
18
+ status,
19
+ statusText: ok ? 'OK' : 'Error',
20
+ json: async () => response,
21
+ });
22
+ }
23
+
24
+ const BASE_CONFIG: SDDConfig = {
25
+ description: 'test',
26
+ remote: {
27
+ url: 'http://test.local/api/v1',
28
+ 'api-key': 'config-key-123',
29
+ },
30
+ };
31
+
32
+ const API = { baseUrl: 'http://test.local/api/v1', apiKey: 'key123' };
33
+
34
+ describe('resolveApiKey', () => {
35
+ const originalEnv = process.env.SDD_API_KEY;
36
+
37
+ afterEach(() => {
38
+ if (originalEnv !== undefined) {
39
+ process.env.SDD_API_KEY = originalEnv;
40
+ } else {
41
+ delete process.env.SDD_API_KEY;
42
+ }
43
+ });
44
+
45
+ it('returns env var when set', () => {
46
+ process.env.SDD_API_KEY = 'env-key-456';
47
+ expect(resolveApiKey(BASE_CONFIG)).toBe('env-key-456');
48
+ });
49
+
50
+ it('falls back to config api-key when env var is unset', () => {
51
+ delete process.env.SDD_API_KEY;
52
+ expect(resolveApiKey(BASE_CONFIG)).toBe('config-key-123');
53
+ });
54
+
55
+ it('returns null when neither is available', () => {
56
+ delete process.env.SDD_API_KEY;
57
+ expect(resolveApiKey({ description: 'test' })).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe('buildApiConfig', () => {
62
+ afterEach(() => {
63
+ delete process.env.SDD_API_KEY;
64
+ });
65
+
66
+ it('builds config from SDDConfig', () => {
67
+ delete process.env.SDD_API_KEY;
68
+ const result = buildApiConfig(BASE_CONFIG);
69
+ expect(result.baseUrl).toBe('http://test.local/api/v1');
70
+ expect(result.apiKey).toBe('config-key-123');
71
+ });
72
+
73
+ it('strips trailing slashes from URL', () => {
74
+ delete process.env.SDD_API_KEY;
75
+ const config: SDDConfig = {
76
+ description: 'test',
77
+ remote: { url: 'http://test.local/api/v1/', 'api-key': 'k' },
78
+ };
79
+ expect(buildApiConfig(config).baseUrl).toBe('http://test.local/api/v1');
80
+ });
81
+
82
+ it('throws when remote not configured', () => {
83
+ expect(() => buildApiConfig({ description: 'test' })).toThrow(RemoteNotConfiguredError);
84
+ });
85
+
86
+ it('throws when no API key available', () => {
87
+ delete process.env.SDD_API_KEY;
88
+ const config: SDDConfig = { description: 'test', remote: { url: 'http://test.local' } };
89
+ expect(() => buildApiConfig(config)).toThrow(RemoteNotConfiguredError);
90
+ });
91
+ });
92
+
93
+ describe('API client functions', () => {
94
+ let originalFetch: typeof globalThis.fetch;
95
+
96
+ beforeEach(() => {
97
+ originalFetch = globalThis.fetch;
98
+ });
99
+
100
+ afterEach(() => {
101
+ globalThis.fetch = originalFetch;
102
+ });
103
+
104
+ it('pullDocs sends GET with auth header', async () => {
105
+ const mock = mockFetch([]);
106
+ globalThis.fetch = mock;
107
+
108
+ const result = await pullDocs(API);
109
+ expect(result).toEqual([]);
110
+ expect(mock).toHaveBeenCalledWith(
111
+ 'http://test.local/api/v1/cli/pull-docs',
112
+ expect.objectContaining({
113
+ method: 'GET',
114
+ headers: expect.objectContaining({
115
+ Authorization: 'Bearer key123',
116
+ }),
117
+ }),
118
+ );
119
+ });
120
+
121
+ it('pushDocs sends POST with body', async () => {
122
+ const mock = mockFetch({ created: 1, updated: 0, documents: [] });
123
+ globalThis.fetch = mock;
124
+
125
+ const docs = [{ path: 'product/vision.md', title: 'Vision', content: 'body' }];
126
+ await pushDocs(API, docs);
127
+
128
+ expect(mock).toHaveBeenCalledWith(
129
+ 'http://test.local/api/v1/cli/push-docs',
130
+ expect.objectContaining({
131
+ method: 'POST',
132
+ body: JSON.stringify({ documents: docs }),
133
+ }),
134
+ );
135
+ });
136
+
137
+ it('fetchPendingCRs sends GET to correct endpoint', async () => {
138
+ const mock = mockFetch([]);
139
+ globalThis.fetch = mock;
140
+
141
+ await fetchPendingCRs(API);
142
+ expect(mock).toHaveBeenCalledWith(
143
+ 'http://test.local/api/v1/cli/pending-crs',
144
+ expect.objectContaining({ method: 'GET' }),
145
+ );
146
+ });
147
+
148
+ it('fetchOpenBugs sends GET to correct endpoint', async () => {
149
+ const mock = mockFetch([]);
150
+ globalThis.fetch = mock;
151
+
152
+ await fetchOpenBugs(API);
153
+ expect(mock).toHaveBeenCalledWith(
154
+ 'http://test.local/api/v1/cli/open-bugs',
155
+ expect.objectContaining({ method: 'GET' }),
156
+ );
157
+ });
158
+
159
+ it('markCRAppliedRemote sends POST with cr ID', async () => {
160
+ const mock = mockFetch({ id: 'abc', status: 'applied' });
161
+ globalThis.fetch = mock;
162
+
163
+ await markCRAppliedRemote(API, 'abc-123');
164
+ expect(mock).toHaveBeenCalledWith(
165
+ 'http://test.local/api/v1/cli/crs/abc-123/applied',
166
+ expect.objectContaining({ method: 'POST' }),
167
+ );
168
+ });
169
+
170
+ it('markBugResolvedRemote sends POST with bug ID', async () => {
171
+ const mock = mockFetch({ id: 'xyz', status: 'resolved' });
172
+ globalThis.fetch = mock;
173
+
174
+ await markBugResolvedRemote(API, 'xyz-456');
175
+ expect(mock).toHaveBeenCalledWith(
176
+ 'http://test.local/api/v1/cli/bugs/xyz-456/resolved',
177
+ expect.objectContaining({ method: 'POST' }),
178
+ );
179
+ });
180
+
181
+ it('throws RemoteError on non-OK response', async () => {
182
+ globalThis.fetch = mockFetch({ detail: 'Invalid API key' }, false, 401);
183
+
184
+ await expect(pullDocs(API)).rejects.toThrow(RemoteError);
185
+ await expect(pullDocs(API)).rejects.toThrow('Remote error (401)');
186
+ });
187
+
188
+ it('handles non-JSON error responses', async () => {
189
+ globalThis.fetch = vi.fn().mockResolvedValue({
190
+ ok: false,
191
+ status: 500,
192
+ statusText: 'Internal Server Error',
193
+ json: async () => { throw new Error('not json'); },
194
+ });
195
+
196
+ await expect(pullDocs(API)).rejects.toThrow('Internal Server Error');
197
+ });
198
+ });
package/tests/cr.test.ts CHANGED
@@ -23,6 +23,23 @@ Add JWT-based authentication to the API.
23
23
  - Update \`system/entities.md\` to add User entity
24
24
  `;
25
25
 
26
+ const CR_PENDING = `---
27
+ title: "Add authentication"
28
+ status: pending
29
+ author: "user"
30
+ created-at: "2025-01-01T00:00:00.000Z"
31
+ ---
32
+
33
+ ## Description
34
+
35
+ Add JWT-based authentication to the API.
36
+
37
+ ## Changes
38
+
39
+ - Create \`product/features/auth.md\` with login/logout flows
40
+ - Update \`system/entities.md\` to add User entity
41
+ `;
42
+
26
43
  const CR_APPLIED = `---
27
44
  title: "Fix navigation"
28
45
  status: applied
@@ -110,18 +127,18 @@ describe('SDD CR methods', () => {
110
127
  expect(crs[1].frontmatter.title).toBe('Fix navigation');
111
128
  });
112
129
 
113
- it('pendingChangeRequests() returns only draft CRs', async () => {
114
- await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
130
+ it('pendingChangeRequests() returns only pending CRs', async () => {
131
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_PENDING, 'utf-8');
115
132
  await writeFile(join(tempDir, 'change-requests/CR-002.md'), CR_APPLIED, 'utf-8');
116
133
 
117
134
  const pending = await sdd.pendingChangeRequests();
118
135
  expect(pending).toHaveLength(1);
119
- expect(pending[0].frontmatter.status).toBe('draft');
136
+ expect(pending[0].frontmatter.status).toBe('pending');
120
137
  expect(pending[0].frontmatter.title).toBe('Add authentication');
121
138
  });
122
139
 
123
- it('markCRApplied() changes draft to applied', async () => {
124
- await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
140
+ it('markCRApplied() changes pending to applied', async () => {
141
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_PENDING, 'utf-8');
125
142
 
126
143
  const marked = await sdd.markCRApplied(['change-requests/CR-001.md']);
127
144
  expect(marked).toEqual(['change-requests/CR-001.md']);
@@ -130,10 +147,10 @@ describe('SDD CR methods', () => {
130
147
  expect(content).toContain('status: applied');
131
148
  });
132
149
 
133
- it('markCRApplied() without args marks all draft CRs', async () => {
134
- await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
135
- const draft2 = CR_DRAFT.replace('Add authentication', 'Second CR');
136
- await writeFile(join(tempDir, 'change-requests/CR-002.md'), draft2, 'utf-8');
150
+ it('markCRApplied() without args marks all pending CRs', async () => {
151
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_PENDING, 'utf-8');
152
+ const pending2 = CR_PENDING.replace('Add authentication', 'Second CR');
153
+ await writeFile(join(tempDir, 'change-requests/CR-002.md'), pending2, 'utf-8');
137
154
 
138
155
  const marked = await sdd.markCRApplied();
139
156
  expect(marked).toHaveLength(2);
@@ -147,7 +164,7 @@ describe('SDD CR methods', () => {
147
164
  });
148
165
 
149
166
  it('integration: create CR → pending → mark applied → no longer pending', async () => {
150
- await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_DRAFT, 'utf-8');
167
+ await writeFile(join(tempDir, 'change-requests/CR-001.md'), CR_PENDING, 'utf-8');
151
168
 
152
169
  // Should be pending
153
170
  let pending = await sdd.pendingChangeRequests();
@@ -0,0 +1,90 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm, mkdir, readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { readRemoteState, writeRemoteState } from '../src/remote/state.js';
7
+ import type { RemoteState } from '../src/remote/types.js';
8
+
9
+ describe('Remote state manager', () => {
10
+ let tempDir: string;
11
+
12
+ beforeEach(async () => {
13
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-remote-state-'));
14
+ await mkdir(join(tempDir, '.sdd'), { recursive: true });
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(tempDir, { recursive: true });
19
+ });
20
+
21
+ it('returns empty state when file does not exist', async () => {
22
+ const state = await readRemoteState(tempDir);
23
+ expect(state).toEqual({ documents: {} });
24
+ });
25
+
26
+ it('round-trips state correctly', async () => {
27
+ const state: RemoteState = {
28
+ lastPull: '2026-01-01T00:00:00.000Z',
29
+ lastPush: '2026-01-01T00:00:00.000Z',
30
+ documents: {
31
+ 'product/vision.md': {
32
+ remoteId: 'abc-123',
33
+ remoteVersion: 3,
34
+ localHash: 'deadbeef',
35
+ lastSynced: '2026-01-01T00:00:00.000Z',
36
+ },
37
+ },
38
+ };
39
+
40
+ await writeRemoteState(tempDir, state);
41
+ const result = await readRemoteState(tempDir);
42
+ expect(result).toEqual(state);
43
+ });
44
+
45
+ it('creates .sdd directory if missing', async () => {
46
+ const freshDir = await mkdtemp(join(tmpdir(), 'sdd-fresh-'));
47
+ const state: RemoteState = { documents: {} };
48
+
49
+ await writeRemoteState(freshDir, state);
50
+ expect(existsSync(join(freshDir, '.sdd', 'remote-state.json'))).toBe(true);
51
+
52
+ await rm(freshDir, { recursive: true });
53
+ });
54
+
55
+ it('handles corrupt JSON gracefully', async () => {
56
+ const { writeFile } = await import('node:fs/promises');
57
+ await writeFile(join(tempDir, '.sdd', 'remote-state.json'), 'not valid json', 'utf-8');
58
+
59
+ const state = await readRemoteState(tempDir);
60
+ expect(state).toEqual({ documents: {} });
61
+ });
62
+
63
+ it('updates individual document entries', async () => {
64
+ const state: RemoteState = {
65
+ documents: {
66
+ 'product/vision.md': {
67
+ remoteId: 'a',
68
+ remoteVersion: 1,
69
+ localHash: 'hash1',
70
+ lastSynced: '2026-01-01T00:00:00.000Z',
71
+ },
72
+ },
73
+ };
74
+
75
+ await writeRemoteState(tempDir, state);
76
+
77
+ // Add a new document
78
+ state.documents['system/entities.md'] = {
79
+ remoteId: 'b',
80
+ remoteVersion: 2,
81
+ localHash: 'hash2',
82
+ lastSynced: '2026-01-02T00:00:00.000Z',
83
+ };
84
+
85
+ await writeRemoteState(tempDir, state);
86
+ const result = await readRemoteState(tempDir);
87
+ expect(Object.keys(result.documents)).toHaveLength(2);
88
+ expect(result.documents['system/entities.md'].remoteVersion).toBe(2);
89
+ });
90
+ });
@@ -0,0 +1,341 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+ import { SDD } from '../src/sdd.js';
8
+ import { readRemoteState } from '../src/remote/state.js';
9
+ import type { RemoteDocResponse, RemoteDocBulkResponse, RemoteCRResponse, RemoteBugResponse } from '../src/remote/types.js';
10
+
11
+ function git(cmd: string, cwd: string): string {
12
+ return execSync(`git ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
13
+ }
14
+
15
+ const VISION_MD = `---
16
+ title: "Product Vision"
17
+ status: new
18
+ author: "user"
19
+ last-modified: "2024-01-01T00:00:00.000Z"
20
+ version: "1.0"
21
+ ---
22
+
23
+ # Product Vision
24
+
25
+ A test project.
26
+ `;
27
+
28
+ const VISION_SYNCED = VISION_MD.replace('status: new', 'status: synced');
29
+
30
+ function makeDocResponse(overrides: Partial<RemoteDocResponse> = {}): RemoteDocResponse {
31
+ return {
32
+ id: 'doc-001',
33
+ project_id: 'proj-001',
34
+ path: 'product/vision.md',
35
+ title: 'Product Vision',
36
+ status: 'synced',
37
+ version: 1,
38
+ content: '# Product Vision\n\nA test project.\n',
39
+ last_modified_by: null,
40
+ created_at: '2026-01-01T00:00:00.000Z',
41
+ updated_at: '2026-01-01T00:00:00.000Z',
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ describe('Sync engine - push', () => {
47
+ let tempDir: string;
48
+ let sdd: SDD;
49
+ let originalFetch: typeof globalThis.fetch;
50
+
51
+ beforeEach(async () => {
52
+ originalFetch = globalThis.fetch;
53
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-sync-push-'));
54
+ sdd = new SDD({ root: tempDir });
55
+ await sdd.init({ description: 'test' });
56
+ git('config user.email "test@test.com"', tempDir);
57
+ git('config user.name "Test"', tempDir);
58
+ // Write remote config
59
+ const { writeConfig, readConfig } = await import('../src/config/config-manager.js');
60
+ const config = await readConfig(tempDir);
61
+ config.remote = { url: 'http://test.local/api/v1', 'api-key': 'test-key' };
62
+ await writeConfig(tempDir, config);
63
+ });
64
+
65
+ afterEach(async () => {
66
+ globalThis.fetch = originalFetch;
67
+ await rm(tempDir, { recursive: true });
68
+ });
69
+
70
+ it('pushes pending files and marks them synced', async () => {
71
+ await writeFile(join(tempDir, 'product/vision.md'), VISION_MD, 'utf-8');
72
+
73
+ const pushResponse: RemoteDocBulkResponse = {
74
+ created: 1,
75
+ updated: 0,
76
+ documents: [makeDocResponse()],
77
+ };
78
+ globalThis.fetch = vi.fn().mockResolvedValue({
79
+ ok: true,
80
+ status: 200,
81
+ json: async () => pushResponse,
82
+ });
83
+
84
+ const result = await sdd.push();
85
+
86
+ expect(result.created).toBe(1);
87
+ expect(result.updated).toBe(0);
88
+ expect(result.pushed).toContain('product/vision.md');
89
+
90
+ // Local file should now be synced
91
+ const content = await readFile(join(tempDir, 'product/vision.md'), 'utf-8');
92
+ expect(content).toContain('status: synced');
93
+
94
+ // Remote state should be updated
95
+ const state = await readRemoteState(tempDir);
96
+ expect(state.documents['product/vision.md']).toBeDefined();
97
+ expect(state.documents['product/vision.md'].remoteId).toBe('doc-001');
98
+ expect(state.lastPush).toBeDefined();
99
+ });
100
+
101
+ it('skips synced files when no paths specified', async () => {
102
+ await writeFile(join(tempDir, 'product/vision.md'), VISION_SYNCED, 'utf-8');
103
+
104
+ const result = await sdd.push();
105
+ expect(result.pushed).toHaveLength(0);
106
+ });
107
+
108
+ it('pushes specific paths when provided', async () => {
109
+ await writeFile(join(tempDir, 'product/vision.md'), VISION_SYNCED, 'utf-8');
110
+
111
+ const pushResponse: RemoteDocBulkResponse = {
112
+ created: 0,
113
+ updated: 1,
114
+ documents: [makeDocResponse({ version: 2 })],
115
+ };
116
+ globalThis.fetch = vi.fn().mockResolvedValue({
117
+ ok: true,
118
+ status: 200,
119
+ json: async () => pushResponse,
120
+ });
121
+
122
+ const result = await sdd.push(['product/vision.md']);
123
+ expect(result.pushed).toContain('product/vision.md');
124
+ expect(result.updated).toBe(1);
125
+ });
126
+ });
127
+
128
+ describe('Sync engine - pull', () => {
129
+ let tempDir: string;
130
+ let sdd: SDD;
131
+ let originalFetch: typeof globalThis.fetch;
132
+
133
+ beforeEach(async () => {
134
+ originalFetch = globalThis.fetch;
135
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-sync-pull-'));
136
+ sdd = new SDD({ root: tempDir });
137
+ await sdd.init({ description: 'test' });
138
+ git('config user.email "test@test.com"', tempDir);
139
+ git('config user.name "Test"', tempDir);
140
+ const { writeConfig, readConfig } = await import('../src/config/config-manager.js');
141
+ const config = await readConfig(tempDir);
142
+ config.remote = { url: 'http://test.local/api/v1', 'api-key': 'test-key' };
143
+ await writeConfig(tempDir, config);
144
+ });
145
+
146
+ afterEach(async () => {
147
+ globalThis.fetch = originalFetch;
148
+ await rm(tempDir, { recursive: true });
149
+ });
150
+
151
+ it('creates new local files from remote docs', async () => {
152
+ globalThis.fetch = vi.fn().mockResolvedValue({
153
+ ok: true,
154
+ status: 200,
155
+ json: async () => [makeDocResponse({ path: 'product/new-feature.md', title: 'New Feature' })],
156
+ });
157
+
158
+ const result = await sdd.pull();
159
+
160
+ expect(result.created).toContain('product/new-feature.md');
161
+ expect(existsSync(join(tempDir, 'product/new-feature.md'))).toBe(true);
162
+
163
+ const content = await readFile(join(tempDir, 'product/new-feature.md'), 'utf-8');
164
+ expect(content).toContain('title: New Feature');
165
+ expect(content).toContain('status: synced');
166
+ });
167
+
168
+ it('detects conflicts when local and remote both changed', async () => {
169
+ // Write a file and set up remote state
170
+ await writeFile(join(tempDir, 'product/vision.md'), VISION_MD, 'utf-8');
171
+ const { writeRemoteState } = await import('../src/remote/state.js');
172
+ await writeRemoteState(tempDir, {
173
+ documents: {
174
+ 'product/vision.md': {
175
+ remoteId: 'doc-001',
176
+ remoteVersion: 1,
177
+ localHash: 'different-hash-from-current',
178
+ lastSynced: '2026-01-01T00:00:00.000Z',
179
+ },
180
+ },
181
+ });
182
+
183
+ globalThis.fetch = vi.fn().mockResolvedValue({
184
+ ok: true,
185
+ status: 200,
186
+ json: async () => [makeDocResponse({ version: 2 })],
187
+ });
188
+
189
+ const result = await sdd.pull();
190
+
191
+ expect(result.conflicts).toHaveLength(1);
192
+ expect(result.conflicts[0].path).toBe('product/vision.md');
193
+ expect(result.conflicts[0].remoteVersion).toBe(2);
194
+ // File should NOT be overwritten
195
+ const content = await readFile(join(tempDir, 'product/vision.md'), 'utf-8');
196
+ expect(content).toContain('status: new');
197
+ });
198
+
199
+ it('updates local files when no local changes detected', async () => {
200
+ await writeFile(join(tempDir, 'product/vision.md'), VISION_SYNCED, 'utf-8');
201
+
202
+ // Compute correct hash for the current content
203
+ const { createHash } = await import('node:crypto');
204
+ const currentContent = await readFile(join(tempDir, 'product/vision.md'), 'utf-8');
205
+ const hash = createHash('sha256').update(currentContent, 'utf-8').digest('hex');
206
+
207
+ const { writeRemoteState } = await import('../src/remote/state.js');
208
+ await writeRemoteState(tempDir, {
209
+ documents: {
210
+ 'product/vision.md': {
211
+ remoteId: 'doc-001',
212
+ remoteVersion: 1,
213
+ localHash: hash,
214
+ lastSynced: '2026-01-01T00:00:00.000Z',
215
+ },
216
+ },
217
+ });
218
+
219
+ globalThis.fetch = vi.fn().mockResolvedValue({
220
+ ok: true,
221
+ status: 200,
222
+ json: async () => [makeDocResponse({ version: 2, content: '# Updated Vision\n\nNew content.\n' })],
223
+ });
224
+
225
+ const result = await sdd.pull();
226
+
227
+ expect(result.updated).toContain('product/vision.md');
228
+ const content = await readFile(join(tempDir, 'product/vision.md'), 'utf-8');
229
+ expect(content).toContain('Updated Vision');
230
+ });
231
+ });
232
+
233
+ describe('Sync engine - pull CRs', () => {
234
+ let tempDir: string;
235
+ let sdd: SDD;
236
+ let originalFetch: typeof globalThis.fetch;
237
+
238
+ beforeEach(async () => {
239
+ originalFetch = globalThis.fetch;
240
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-sync-crs-'));
241
+ sdd = new SDD({ root: tempDir });
242
+ await sdd.init({ description: 'test' });
243
+ const { writeConfig, readConfig } = await import('../src/config/config-manager.js');
244
+ const config = await readConfig(tempDir);
245
+ config.remote = { url: 'http://test.local/api/v1', 'api-key': 'test-key' };
246
+ await writeConfig(tempDir, config);
247
+ });
248
+
249
+ afterEach(async () => {
250
+ globalThis.fetch = originalFetch;
251
+ await rm(tempDir, { recursive: true });
252
+ });
253
+
254
+ it('creates local CR files from remote', async () => {
255
+ const cr: RemoteCRResponse = {
256
+ id: 'abcdef01-1234-5678-abcd-ef0123456789',
257
+ project_id: 'proj-001',
258
+ title: 'Add auth flow',
259
+ body: '## Description\n\nAdd JWT authentication.',
260
+ status: 'draft',
261
+ author_id: 'user-1',
262
+ assignee_id: null,
263
+ target_files: null,
264
+ closed_at: null,
265
+ created_at: '2026-01-01T00:00:00.000Z',
266
+ updated_at: '2026-01-01T00:00:00.000Z',
267
+ };
268
+
269
+ globalThis.fetch = vi.fn().mockResolvedValue({
270
+ ok: true,
271
+ status: 200,
272
+ json: async () => [cr],
273
+ });
274
+
275
+ const result = await sdd.pullCRs();
276
+ expect(result.created).toBe(1);
277
+
278
+ const filePath = join(tempDir, 'change-requests', 'CR-abcdef01.md');
279
+ expect(existsSync(filePath)).toBe(true);
280
+
281
+ const content = await readFile(filePath, 'utf-8');
282
+ expect(content).toContain('title: Add auth flow');
283
+ expect(content).toContain('status: draft');
284
+ expect(content).toContain('JWT authentication');
285
+ });
286
+ });
287
+
288
+ describe('Sync engine - pull bugs', () => {
289
+ let tempDir: string;
290
+ let sdd: SDD;
291
+ let originalFetch: typeof globalThis.fetch;
292
+
293
+ beforeEach(async () => {
294
+ originalFetch = globalThis.fetch;
295
+ tempDir = await mkdtemp(join(tmpdir(), 'sdd-sync-bugs-'));
296
+ sdd = new SDD({ root: tempDir });
297
+ await sdd.init({ description: 'test' });
298
+ const { writeConfig, readConfig } = await import('../src/config/config-manager.js');
299
+ const config = await readConfig(tempDir);
300
+ config.remote = { url: 'http://test.local/api/v1', 'api-key': 'test-key' };
301
+ await writeConfig(tempDir, config);
302
+ });
303
+
304
+ afterEach(async () => {
305
+ globalThis.fetch = originalFetch;
306
+ await rm(tempDir, { recursive: true });
307
+ });
308
+
309
+ it('creates local bug files from remote', async () => {
310
+ const bug: RemoteBugResponse = {
311
+ id: '12345678-abcd-ef01-2345-678901234567',
312
+ project_id: 'proj-001',
313
+ title: 'Search returns stale results',
314
+ body: '## Steps\n\n1. Search for a term\n2. Results are outdated',
315
+ status: 'open',
316
+ severity: 'major',
317
+ author_id: 'user-1',
318
+ assignee_id: null,
319
+ closed_at: null,
320
+ created_at: '2026-02-01T00:00:00.000Z',
321
+ updated_at: '2026-02-01T00:00:00.000Z',
322
+ };
323
+
324
+ globalThis.fetch = vi.fn().mockResolvedValue({
325
+ ok: true,
326
+ status: 200,
327
+ json: async () => [bug],
328
+ });
329
+
330
+ const result = await sdd.pullBugs();
331
+ expect(result.created).toBe(1);
332
+
333
+ const filePath = join(tempDir, 'bugs', 'BUG-12345678.md');
334
+ expect(existsSync(filePath)).toBe(true);
335
+
336
+ const content = await readFile(filePath, 'utf-8');
337
+ expect(content).toContain('title: Search returns stale results');
338
+ expect(content).toContain('status: open');
339
+ expect(content).toContain('Results are outdated');
340
+ });
341
+ });