@applica-software-guru/sdd-core 1.4.1 → 1.5.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.
- package/dist/errors.d.ts +3 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +8 -1
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/remote/api-client.d.ts +25 -2
- package/dist/remote/api-client.d.ts.map +1 -1
- package/dist/remote/api-client.js +101 -14
- package/dist/remote/api-client.js.map +1 -1
- package/dist/remote/state.js +1 -1
- package/dist/remote/state.js.map +1 -1
- package/dist/remote/sync-engine.d.ts +5 -4
- package/dist/remote/sync-engine.d.ts.map +1 -1
- package/dist/remote/sync-engine.js +127 -43
- package/dist/remote/sync-engine.js.map +1 -1
- package/dist/remote/types.d.ts +20 -0
- package/dist/remote/types.d.ts.map +1 -1
- package/dist/sdd.d.ts +5 -4
- package/dist/sdd.d.ts.map +1 -1
- package/dist/sdd.js +9 -9
- package/dist/sdd.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +7 -0
- package/src/index.ts +2 -2
- package/src/remote/api-client.ts +125 -16
- package/src/remote/state.ts +1 -1
- package/src/remote/sync-engine.ts +138 -43
- package/src/remote/types.ts +23 -0
- package/src/sdd.ts +10 -10
- package/src/types.ts +1 -0
package/src/remote/api-client.ts
CHANGED
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import type { SDDConfig } from '../types.js';
|
|
2
|
-
import { RemoteError, RemoteNotConfiguredError } from '../errors.js';
|
|
2
|
+
import { RemoteError, RemoteNotConfiguredError, RemoteTimeoutError } from '../errors.js';
|
|
3
3
|
import type {
|
|
4
4
|
RemoteDocResponse,
|
|
5
5
|
RemoteDocBulkResponse,
|
|
6
6
|
RemoteCRResponse,
|
|
7
|
+
RemoteCRBulkResponse,
|
|
7
8
|
RemoteBugResponse,
|
|
9
|
+
RemoteBugBulkResponse,
|
|
8
10
|
} from './types.js';
|
|
9
11
|
|
|
12
|
+
/** Default timeout for remote operations (seconds) */
|
|
13
|
+
export const DEFAULT_REMOTE_TIMEOUT = 300;
|
|
14
|
+
|
|
15
|
+
/** Status codes that trigger a retry (server not ready / cold start) */
|
|
16
|
+
const RETRYABLE_STATUS_CODES = new Set([502, 503, 504]);
|
|
17
|
+
|
|
18
|
+
/** Initial backoff delay in ms */
|
|
19
|
+
const INITIAL_BACKOFF_MS = 3_000;
|
|
20
|
+
|
|
21
|
+
/** Maximum backoff delay in ms */
|
|
22
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
23
|
+
|
|
10
24
|
export interface ApiClientConfig {
|
|
11
25
|
baseUrl: string;
|
|
12
26
|
apiKey: string;
|
|
27
|
+
timeout: number;
|
|
13
28
|
}
|
|
14
29
|
|
|
15
30
|
/**
|
|
@@ -25,7 +40,12 @@ export function resolveApiKey(config: SDDConfig): string | null {
|
|
|
25
40
|
* Build an ApiClientConfig from the SDD project config.
|
|
26
41
|
* Throws RemoteNotConfiguredError if URL or API key is missing.
|
|
27
42
|
*/
|
|
28
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Build an ApiClientConfig from the SDD project config.
|
|
45
|
+
* Throws RemoteNotConfiguredError if URL or API key is missing.
|
|
46
|
+
* An optional timeoutOverride (from --timeout flag) takes precedence over config.
|
|
47
|
+
*/
|
|
48
|
+
export function buildApiConfig(config: SDDConfig, timeoutOverride?: number): ApiClientConfig {
|
|
29
49
|
if (!config.remote?.url) {
|
|
30
50
|
throw new RemoteNotConfiguredError();
|
|
31
51
|
}
|
|
@@ -36,9 +56,24 @@ export function buildApiConfig(config: SDDConfig): ApiClientConfig {
|
|
|
36
56
|
return {
|
|
37
57
|
baseUrl: config.remote.url.replace(/\/+$/, ''),
|
|
38
58
|
apiKey,
|
|
59
|
+
timeout: timeoutOverride ?? config.remote.timeout ?? DEFAULT_REMOTE_TIMEOUT,
|
|
39
60
|
};
|
|
40
61
|
}
|
|
41
62
|
|
|
63
|
+
function sleep(ms: number): Promise<void> {
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRetryable(error: unknown): boolean {
|
|
68
|
+
if (error instanceof RemoteError) {
|
|
69
|
+
return RETRYABLE_STATUS_CODES.has(error.statusCode);
|
|
70
|
+
}
|
|
71
|
+
// Network errors (ECONNREFUSED, ENOTFOUND, fetch abort due to connection failure)
|
|
72
|
+
if (error instanceof TypeError) return true;
|
|
73
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
74
|
+
return code === 'ECONNREFUSED' || code === 'ECONNRESET' || code === 'ENOTFOUND' || code === 'UND_ERR_CONNECT_TIMEOUT';
|
|
75
|
+
}
|
|
76
|
+
|
|
42
77
|
async function request<T>(
|
|
43
78
|
config: ApiClientConfig,
|
|
44
79
|
method: string,
|
|
@@ -51,24 +86,82 @@ async function request<T>(
|
|
|
51
86
|
'Content-Type': 'application/json',
|
|
52
87
|
};
|
|
53
88
|
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
89
|
+
const deadline = Date.now() + config.timeout * 1_000;
|
|
90
|
+
let backoff = INITIAL_BACKOFF_MS;
|
|
91
|
+
let attempt = 0;
|
|
92
|
+
|
|
93
|
+
while (true) {
|
|
94
|
+
attempt++;
|
|
95
|
+
const remaining = deadline - Date.now();
|
|
96
|
+
if (remaining <= 0) {
|
|
97
|
+
throw new RemoteTimeoutError(config.timeout);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Per-request timeout: min of remaining budget and 60s
|
|
101
|
+
const perRequestTimeout = Math.min(remaining, 60_000);
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
const timer = setTimeout(() => controller.abort(), perRequestTimeout);
|
|
59
104
|
|
|
60
|
-
if (!res.ok) {
|
|
61
|
-
let message: string;
|
|
62
105
|
try {
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
106
|
+
const res = await fetch(url, {
|
|
107
|
+
method,
|
|
108
|
+
headers,
|
|
109
|
+
body: body != null ? JSON.stringify(body) : undefined,
|
|
110
|
+
signal: controller.signal,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
let message: string;
|
|
117
|
+
try {
|
|
118
|
+
const err = (await res.json()) as { detail?: string };
|
|
119
|
+
message = err.detail ?? res.statusText;
|
|
120
|
+
} catch {
|
|
121
|
+
message = res.statusText;
|
|
122
|
+
}
|
|
123
|
+
const remoteErr = new RemoteError(res.status, message);
|
|
124
|
+
|
|
125
|
+
if (RETRYABLE_STATUS_CODES.has(res.status) && Date.now() + backoff < deadline) {
|
|
126
|
+
process.stderr.write(` ⏳ Server not ready (${res.status}), retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
|
|
127
|
+
await sleep(backoff);
|
|
128
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
throw remoteErr;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (await res.json()) as T;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
|
|
139
|
+
if (error instanceof RemoteError || error instanceof RemoteTimeoutError) {
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Retryable network/connection error
|
|
144
|
+
if (isRetryable(error) && Date.now() + backoff < deadline) {
|
|
145
|
+
process.stderr.write(` ⏳ Cannot reach server, retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
|
|
146
|
+
await sleep(backoff);
|
|
147
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// AbortError from our own timeout
|
|
152
|
+
if ((error as Error).name === 'AbortError') {
|
|
153
|
+
if (Date.now() + backoff < deadline) {
|
|
154
|
+
process.stderr.write(` ⏳ Request timed out, retrying in ${Math.round(backoff / 1000)}s... (attempt ${attempt})\n`);
|
|
155
|
+
await sleep(backoff);
|
|
156
|
+
backoff = Math.min(backoff * 2, MAX_BACKOFF_MS);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
throw new RemoteTimeoutError(config.timeout);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw error;
|
|
67
163
|
}
|
|
68
|
-
throw new RemoteError(res.status, message);
|
|
69
164
|
}
|
|
70
|
-
|
|
71
|
-
return (await res.json()) as T;
|
|
72
165
|
}
|
|
73
166
|
|
|
74
167
|
/** GET /cli/pull-docs */
|
|
@@ -89,11 +182,27 @@ export async function fetchPendingCRs(config: ApiClientConfig): Promise<RemoteCR
|
|
|
89
182
|
return request<RemoteCRResponse[]>(config, 'GET', '/cli/pending-crs');
|
|
90
183
|
}
|
|
91
184
|
|
|
185
|
+
/** POST /cli/push-crs */
|
|
186
|
+
export async function pushCRs(
|
|
187
|
+
config: ApiClientConfig,
|
|
188
|
+
changeRequests: Array<{ path: string; title: string; body: string; id?: string }>,
|
|
189
|
+
): Promise<RemoteCRBulkResponse> {
|
|
190
|
+
return request<RemoteCRBulkResponse>(config, 'POST', '/cli/push-crs', { change_requests: changeRequests });
|
|
191
|
+
}
|
|
192
|
+
|
|
92
193
|
/** GET /cli/open-bugs */
|
|
93
194
|
export async function fetchOpenBugs(config: ApiClientConfig): Promise<RemoteBugResponse[]> {
|
|
94
195
|
return request<RemoteBugResponse[]>(config, 'GET', '/cli/open-bugs');
|
|
95
196
|
}
|
|
96
197
|
|
|
198
|
+
/** POST /cli/push-bugs */
|
|
199
|
+
export async function pushBugs(
|
|
200
|
+
config: ApiClientConfig,
|
|
201
|
+
bugs: Array<{ path: string; title: string; body: string; severity?: string; id?: string }>,
|
|
202
|
+
): Promise<RemoteBugBulkResponse> {
|
|
203
|
+
return request<RemoteBugBulkResponse>(config, 'POST', '/cli/push-bugs', { bugs });
|
|
204
|
+
}
|
|
205
|
+
|
|
97
206
|
/** POST /cli/crs/:crId/applied */
|
|
98
207
|
export async function markCRAppliedRemote(
|
|
99
208
|
config: ApiClientConfig,
|
package/src/remote/state.ts
CHANGED
|
@@ -10,7 +10,7 @@ function stateFilePath(root: string): string {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function emptyState(): RemoteState {
|
|
13
|
-
return { documents: {} };
|
|
13
|
+
return { documents: {}, changeRequests: {}, bugs: {} };
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export async function readRemoteState(root: string): Promise<RemoteState> {
|
|
@@ -6,7 +6,9 @@ import matter from 'gray-matter';
|
|
|
6
6
|
|
|
7
7
|
import { readConfig } from '../config/config-manager.js';
|
|
8
8
|
import { parseAllStoryFiles } from '../parser/story-parser.js';
|
|
9
|
-
import {
|
|
9
|
+
import { parseAllCRFiles } from '../parser/cr-parser.js';
|
|
10
|
+
import { parseAllBugFiles } from '../parser/bug-parser.js';
|
|
11
|
+
import { buildApiConfig, pullDocs, pushDocs, pushCRs, pushBugs, fetchPendingCRs, fetchOpenBugs } from './api-client.js';
|
|
10
12
|
import { readRemoteState, writeRemoteState } from './state.js';
|
|
11
13
|
import type {
|
|
12
14
|
PushResult,
|
|
@@ -71,12 +73,21 @@ function buildBugMarkdown(title: string, body: string, createdAt: string, status
|
|
|
71
73
|
export interface PushOptions {
|
|
72
74
|
paths?: string[];
|
|
73
75
|
all?: boolean;
|
|
76
|
+
timeout?: number;
|
|
74
77
|
}
|
|
75
78
|
|
|
76
79
|
export async function pushToRemote(root: string, options?: PushOptions): Promise<PushResult> {
|
|
77
80
|
const config = await readConfig(root);
|
|
78
|
-
const api = buildApiConfig(config);
|
|
81
|
+
const api = buildApiConfig(config, options?.timeout);
|
|
82
|
+
const state = await readRemoteState(root);
|
|
83
|
+
if (!state.changeRequests) state.changeRequests = {};
|
|
84
|
+
if (!state.bugs) state.bugs = {};
|
|
85
|
+
|
|
86
|
+
let totalCreated = 0;
|
|
87
|
+
let totalUpdated = 0;
|
|
88
|
+
const allPushed: string[] = [];
|
|
79
89
|
|
|
90
|
+
// ── Documents ──────────────────────────────────────────────────────────
|
|
80
91
|
const files = await parseAllStoryFiles(root);
|
|
81
92
|
const toPush = files.filter((f) => {
|
|
82
93
|
if (options?.paths && options.paths.length > 0) return options.paths.includes(f.relativePath);
|
|
@@ -84,57 +95,141 @@ export async function pushToRemote(root: string, options?: PushOptions): Promise
|
|
|
84
95
|
return f.frontmatter.status !== 'synced';
|
|
85
96
|
});
|
|
86
97
|
|
|
87
|
-
if (toPush.length
|
|
88
|
-
|
|
98
|
+
if (toPush.length > 0) {
|
|
99
|
+
const documents = toPush.map((f) => ({
|
|
100
|
+
path: normalizePath(f.relativePath),
|
|
101
|
+
title: f.frontmatter.title,
|
|
102
|
+
content: f.body,
|
|
103
|
+
}));
|
|
104
|
+
|
|
105
|
+
const result = await pushDocs(api, documents);
|
|
106
|
+
|
|
107
|
+
for (const doc of result.documents) {
|
|
108
|
+
const localPath = doc.path;
|
|
109
|
+
const absPath = resolve(root, localPath);
|
|
110
|
+
const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
|
|
111
|
+
state.documents[localPath] = {
|
|
112
|
+
remoteId: doc.id,
|
|
113
|
+
remoteVersion: doc.version,
|
|
114
|
+
localHash: sha256(rawContent),
|
|
115
|
+
lastSynced: new Date().toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Mark local files as synced (drafts are excluded — they need AI enrichment first)
|
|
120
|
+
for (const f of toPush) {
|
|
121
|
+
if (f.frontmatter.status === 'draft') continue;
|
|
122
|
+
const absPath = resolve(root, f.relativePath);
|
|
123
|
+
const content = await readFile(absPath, 'utf-8');
|
|
124
|
+
const updated = content.replace(/^status:\s*(new|changed)/m, 'status: synced');
|
|
125
|
+
if (updated !== content) {
|
|
126
|
+
await writeFile(absPath, updated, 'utf-8');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
totalCreated += result.created;
|
|
131
|
+
totalUpdated += result.updated;
|
|
132
|
+
allPushed.push(...toPush.map((f) => f.relativePath));
|
|
89
133
|
}
|
|
90
134
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
// ── Change Requests ────────────────────────────────────────────────────
|
|
136
|
+
const crFiles = await parseAllCRFiles(root);
|
|
137
|
+
const crsToPush = crFiles.filter((cr) => {
|
|
138
|
+
if (options?.paths && options.paths.length > 0) return options.paths.includes(cr.relativePath);
|
|
139
|
+
if (options?.all) return true;
|
|
140
|
+
return cr.frontmatter.status !== 'applied';
|
|
141
|
+
});
|
|
96
142
|
|
|
97
|
-
|
|
143
|
+
if (crsToPush.length > 0) {
|
|
144
|
+
const crPayload = crsToPush.map((cr) => {
|
|
145
|
+
const tracked = state.changeRequests![normalizePath(cr.relativePath)];
|
|
146
|
+
return {
|
|
147
|
+
path: normalizePath(cr.relativePath),
|
|
148
|
+
title: cr.frontmatter.title,
|
|
149
|
+
body: cr.body,
|
|
150
|
+
...(tracked ? { id: tracked.remoteId } : {}),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const crResult = await pushCRs(api, crPayload);
|
|
155
|
+
|
|
156
|
+
for (const cr of crResult.change_requests) {
|
|
157
|
+
const localPath = crsToPush.find(
|
|
158
|
+
(f) => f.frontmatter.title === cr.title,
|
|
159
|
+
)?.relativePath;
|
|
160
|
+
if (localPath) {
|
|
161
|
+
const absPath = resolve(root, localPath);
|
|
162
|
+
const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
|
|
163
|
+
state.changeRequests![normalizePath(localPath)] = {
|
|
164
|
+
remoteId: cr.id,
|
|
165
|
+
localHash: sha256(rawContent),
|
|
166
|
+
lastSynced: new Date().toISOString(),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
98
170
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const localPath = doc.path;
|
|
103
|
-
const absPath = resolve(root, localPath);
|
|
104
|
-
const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
|
|
105
|
-
state.documents[localPath] = {
|
|
106
|
-
remoteId: doc.id,
|
|
107
|
-
remoteVersion: doc.version,
|
|
108
|
-
localHash: sha256(rawContent),
|
|
109
|
-
lastSynced: new Date().toISOString(),
|
|
110
|
-
};
|
|
171
|
+
totalCreated += crResult.created;
|
|
172
|
+
totalUpdated += crResult.updated;
|
|
173
|
+
allPushed.push(...crsToPush.map((cr) => cr.relativePath));
|
|
111
174
|
}
|
|
112
|
-
state.lastPush = new Date().toISOString();
|
|
113
|
-
await writeRemoteState(root, state);
|
|
114
175
|
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
176
|
+
// ── Bugs ───────────────────────────────────────────────────────────────
|
|
177
|
+
const bugFiles = await parseAllBugFiles(root);
|
|
178
|
+
const bugsToPush = bugFiles.filter((bug) => {
|
|
179
|
+
if (options?.paths && options.paths.length > 0) return options.paths.includes(bug.relativePath);
|
|
180
|
+
if (options?.all) return true;
|
|
181
|
+
return bug.frontmatter.status !== 'resolved';
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (bugsToPush.length > 0) {
|
|
185
|
+
const bugPayload = bugsToPush.map((bug) => {
|
|
186
|
+
const tracked = state.bugs![normalizePath(bug.relativePath)];
|
|
187
|
+
return {
|
|
188
|
+
path: normalizePath(bug.relativePath),
|
|
189
|
+
title: bug.frontmatter.title,
|
|
190
|
+
body: bug.body,
|
|
191
|
+
...(tracked ? { id: tracked.remoteId } : {}),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const bugResult = await pushBugs(api, bugPayload);
|
|
196
|
+
|
|
197
|
+
for (const bug of bugResult.bugs) {
|
|
198
|
+
const localPath = bugsToPush.find(
|
|
199
|
+
(b) => b.frontmatter.title === bug.title,
|
|
200
|
+
)?.relativePath;
|
|
201
|
+
if (localPath) {
|
|
202
|
+
const absPath = resolve(root, localPath);
|
|
203
|
+
const rawContent = existsSync(absPath) ? await readFile(absPath, 'utf-8') : '';
|
|
204
|
+
state.bugs![normalizePath(localPath)] = {
|
|
205
|
+
remoteId: bug.id,
|
|
206
|
+
localHash: sha256(rawContent),
|
|
207
|
+
lastSynced: new Date().toISOString(),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
123
210
|
}
|
|
211
|
+
|
|
212
|
+
totalCreated += bugResult.created;
|
|
213
|
+
totalUpdated += bugResult.updated;
|
|
214
|
+
allPushed.push(...bugsToPush.map((bug) => bug.relativePath));
|
|
124
215
|
}
|
|
125
216
|
|
|
217
|
+
// ── Finalize ───────────────────────────────────────────────────────────
|
|
218
|
+
state.lastPush = new Date().toISOString();
|
|
219
|
+
await writeRemoteState(root, state);
|
|
220
|
+
|
|
126
221
|
return {
|
|
127
|
-
created:
|
|
128
|
-
updated:
|
|
129
|
-
pushed:
|
|
222
|
+
created: totalCreated,
|
|
223
|
+
updated: totalUpdated,
|
|
224
|
+
pushed: allPushed,
|
|
130
225
|
};
|
|
131
226
|
}
|
|
132
227
|
|
|
133
228
|
// ─── Pull Documents ──────────────────────────────────────────────────────
|
|
134
229
|
|
|
135
|
-
export async function pullFromRemote(root: string): Promise<PullResult> {
|
|
230
|
+
export async function pullFromRemote(root: string, timeout?: number): Promise<PullResult> {
|
|
136
231
|
const config = await readConfig(root);
|
|
137
|
-
const api = buildApiConfig(config);
|
|
232
|
+
const api = buildApiConfig(config, timeout);
|
|
138
233
|
|
|
139
234
|
const remoteDocs = await pullDocs(api);
|
|
140
235
|
const state = await readRemoteState(root);
|
|
@@ -206,9 +301,9 @@ function updateDocState(
|
|
|
206
301
|
|
|
207
302
|
// ─── Pull CRs ────────────────────────────────────────────────────────────
|
|
208
303
|
|
|
209
|
-
export async function pullCRsFromRemote(root: string): Promise<PullEntitiesResult> {
|
|
304
|
+
export async function pullCRsFromRemote(root: string, timeout?: number): Promise<PullEntitiesResult> {
|
|
210
305
|
const config = await readConfig(root);
|
|
211
|
-
const api = buildApiConfig(config);
|
|
306
|
+
const api = buildApiConfig(config, timeout);
|
|
212
307
|
|
|
213
308
|
const remoteCRs = await fetchPendingCRs(api);
|
|
214
309
|
const crDir = resolve(root, 'change-requests');
|
|
@@ -238,9 +333,9 @@ export async function pullCRsFromRemote(root: string): Promise<PullEntitiesResul
|
|
|
238
333
|
|
|
239
334
|
// ─── Pull Bugs ───────────────────────────────────────────────────────────
|
|
240
335
|
|
|
241
|
-
export async function pullBugsFromRemote(root: string): Promise<PullEntitiesResult> {
|
|
336
|
+
export async function pullBugsFromRemote(root: string, timeout?: number): Promise<PullEntitiesResult> {
|
|
242
337
|
const config = await readConfig(root);
|
|
243
|
-
const api = buildApiConfig(config);
|
|
338
|
+
const api = buildApiConfig(config, timeout);
|
|
244
339
|
|
|
245
340
|
const remoteBugs = await fetchOpenBugs(api);
|
|
246
341
|
const bugsDir = resolve(root, 'bugs');
|
|
@@ -270,7 +365,7 @@ export async function pullBugsFromRemote(root: string): Promise<PullEntitiesResu
|
|
|
270
365
|
|
|
271
366
|
// ─── Remote Status ───────────────────────────────────────────────────────
|
|
272
367
|
|
|
273
|
-
export async function getRemoteStatus(root: string): Promise<RemoteStatusResult> {
|
|
368
|
+
export async function getRemoteStatus(root: string, timeout?: number): Promise<RemoteStatusResult> {
|
|
274
369
|
const config = await readConfig(root);
|
|
275
370
|
|
|
276
371
|
if (!config.remote?.url) {
|
|
@@ -281,7 +376,7 @@ export async function getRemoteStatus(root: string): Promise<RemoteStatusResult>
|
|
|
281
376
|
const localPending = files.filter((f) => f.frontmatter.status !== 'synced').length;
|
|
282
377
|
|
|
283
378
|
try {
|
|
284
|
-
const api = buildApiConfig(config);
|
|
379
|
+
const api = buildApiConfig(config, timeout);
|
|
285
380
|
const docs = await pullDocs(api);
|
|
286
381
|
return {
|
|
287
382
|
configured: true,
|
package/src/remote/types.ts
CHANGED
|
@@ -49,6 +49,20 @@ export interface RemoteBugResponse {
|
|
|
49
49
|
updated_at: string;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Bulk push response from POST /cli/push-crs */
|
|
53
|
+
export interface RemoteCRBulkResponse {
|
|
54
|
+
created: number;
|
|
55
|
+
updated: number;
|
|
56
|
+
change_requests: RemoteCRResponse[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Bulk push response from POST /cli/push-bugs */
|
|
60
|
+
export interface RemoteBugBulkResponse {
|
|
61
|
+
created: number;
|
|
62
|
+
updated: number;
|
|
63
|
+
bugs: RemoteBugResponse[];
|
|
64
|
+
}
|
|
65
|
+
|
|
52
66
|
/** Tracks per-document remote sync state */
|
|
53
67
|
export interface RemoteDocState {
|
|
54
68
|
remoteId: string;
|
|
@@ -57,11 +71,20 @@ export interface RemoteDocState {
|
|
|
57
71
|
lastSynced: string;
|
|
58
72
|
}
|
|
59
73
|
|
|
74
|
+
/** Tracks per-entity remote sync state (CRs, bugs) */
|
|
75
|
+
export interface RemoteEntityState {
|
|
76
|
+
remoteId: string;
|
|
77
|
+
localHash: string;
|
|
78
|
+
lastSynced: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
/** Persisted to .sdd/remote-state.json */
|
|
61
82
|
export interface RemoteState {
|
|
62
83
|
lastPull?: string;
|
|
63
84
|
lastPush?: string;
|
|
64
85
|
documents: Record<string, RemoteDocState>;
|
|
86
|
+
changeRequests?: Record<string, RemoteEntityState>;
|
|
87
|
+
bugs?: Record<string, RemoteEntityState>;
|
|
65
88
|
}
|
|
66
89
|
|
|
67
90
|
/** Result of a push operation */
|
package/src/sdd.ts
CHANGED
|
@@ -254,29 +254,29 @@ export class SDD {
|
|
|
254
254
|
|
|
255
255
|
// ── Remote sync ──────────────────────────────────────────────────────
|
|
256
256
|
|
|
257
|
-
async remoteStatus(): Promise<RemoteStatusResult> {
|
|
257
|
+
async remoteStatus(timeout?: number): Promise<RemoteStatusResult> {
|
|
258
258
|
this.ensureInitialized();
|
|
259
|
-
return getRemoteStatus(this.root);
|
|
259
|
+
return getRemoteStatus(this.root, timeout);
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
async push(paths?: string[], options?: { all?: boolean }): Promise<PushResult> {
|
|
262
|
+
async push(paths?: string[], options?: { all?: boolean; timeout?: number }): Promise<PushResult> {
|
|
263
263
|
this.ensureInitialized();
|
|
264
|
-
return pushToRemote(this.root, { paths, all: options?.all });
|
|
264
|
+
return pushToRemote(this.root, { paths, all: options?.all, timeout: options?.timeout });
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
-
async pull(): Promise<PullResult> {
|
|
267
|
+
async pull(timeout?: number): Promise<PullResult> {
|
|
268
268
|
this.ensureInitialized();
|
|
269
|
-
return pullFromRemote(this.root);
|
|
269
|
+
return pullFromRemote(this.root, timeout);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
async pullCRs(): Promise<PullEntitiesResult> {
|
|
272
|
+
async pullCRs(timeout?: number): Promise<PullEntitiesResult> {
|
|
273
273
|
this.ensureInitialized();
|
|
274
|
-
return pullCRsFromRemote(this.root);
|
|
274
|
+
return pullCRsFromRemote(this.root, timeout);
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
-
async pullBugs(): Promise<PullEntitiesResult> {
|
|
277
|
+
async pullBugs(timeout?: number): Promise<PullEntitiesResult> {
|
|
278
278
|
this.ensureInitialized();
|
|
279
|
-
return pullBugsFromRemote(this.root);
|
|
279
|
+
return pullBugsFromRemote(this.root, timeout);
|
|
280
280
|
}
|
|
281
281
|
|
|
282
282
|
private ensureInitialized(): void {
|