draftgo-cli 3.0.48 → 3.0.49
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/README.md +8 -2
- package/package.json +1 -1
- package/resources/skill/SKILL.md +2 -2
- package/resources/skill/manifest.json +1 -1
- package/resources/skill/references/checkout.md +2 -1
- package/src/cli.js +2 -0
- package/src/commandRegistry.js +1 -0
- package/src/commands/api.js +102 -0
- package/src/commands/autoPush.js +1 -1
- package/src/commands/check.js +24 -2
- package/src/commands/commit.js +31 -5
- package/src/commands/deploy.js +1 -1
- package/src/commands/help.js +10 -2
- package/src/commands/map.js +20 -22
- package/src/commands/mcp.js +26 -3
- package/src/commands/reconcile.js +20 -0
- package/src/commands/verifyUi.js +92 -10
- package/src/mcp/client.js +52 -19
- package/src/projectMap.js +7 -2
- package/src/worktree/index.js +272 -59
- package/src/worktree/status.js +122 -0
package/src/commands/verifyUi.js
CHANGED
|
@@ -5,6 +5,10 @@ const path = require('path');
|
|
|
5
5
|
const { spawnSync } = require('child_process');
|
|
6
6
|
const log = require('../logger');
|
|
7
7
|
const { configPath, loadProjectConfig } = require('../projectConfig');
|
|
8
|
+
const { loadManifest, getEntry } = require('../worktree/manifest');
|
|
9
|
+
const { canonicalResourceType } = require('../worktree/types');
|
|
10
|
+
const { resolveMetadata } = require('../worktree/backend');
|
|
11
|
+
const { inspectEntry, openMetadataSession } = require('../worktree/status');
|
|
8
12
|
|
|
9
13
|
const UI_EXTENSIONS = new Set(['.css', '.scss', '.sass', '.less', '.html', '.htm', '.jsx', '.tsx', '.vue', '.svelte']);
|
|
10
14
|
|
|
@@ -77,6 +81,7 @@ function redactUrlTokens(message) {
|
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
function executableCandidates() {
|
|
84
|
+
const explicit = process.env.DRAFTGO_BROWSER_PATH ? [process.env.DRAFTGO_BROWSER_PATH] : [];
|
|
80
85
|
if (process.platform === 'win32') {
|
|
81
86
|
const roots = [process.env.PROGRAMFILES, process.env['PROGRAMFILES(X86)'], process.env.LOCALAPPDATA].filter(Boolean);
|
|
82
87
|
const rels = [
|
|
@@ -84,23 +89,47 @@ function executableCandidates() {
|
|
|
84
89
|
['Google', 'Chrome', 'Application', 'chrome.exe'],
|
|
85
90
|
['Chromium', 'Application', 'chrome.exe'],
|
|
86
91
|
];
|
|
87
|
-
return roots.flatMap((root) => rels.map((parts) => path.join(root, ...parts)));
|
|
92
|
+
return [...explicit, ...roots.flatMap((root) => rels.map((parts) => path.join(root, ...parts)))];
|
|
88
93
|
}
|
|
89
94
|
if (process.platform === 'darwin') {
|
|
90
|
-
return [
|
|
95
|
+
return [...explicit,
|
|
91
96
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
92
97
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
93
98
|
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
94
99
|
];
|
|
95
100
|
}
|
|
96
|
-
return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
101
|
+
return [...explicit, '/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/microsoft-edge', '/usr/bin/chromium', '/usr/bin/chromium-browser'];
|
|
97
102
|
}
|
|
98
103
|
|
|
99
|
-
|
|
104
|
+
function playwrightCacheCandidates() {
|
|
105
|
+
const root = process.env.PLAYWRIGHT_BROWSERS_PATH
|
|
106
|
+
|| (process.platform === 'win32'
|
|
107
|
+
? path.join(process.env.LOCALAPPDATA || '', 'ms-playwright')
|
|
108
|
+
: path.join(process.env.HOME || '', '.cache', 'ms-playwright'));
|
|
109
|
+
if (!root || !fs.existsSync(root)) return [];
|
|
110
|
+
const candidates = [];
|
|
111
|
+
const visit = (directory, depth) => {
|
|
112
|
+
if (depth > 3) return;
|
|
113
|
+
let entries;
|
|
114
|
+
try { entries = fs.readdirSync(directory, { withFileTypes: true }); } catch { return; }
|
|
115
|
+
for (const entry of entries) {
|
|
116
|
+
const absolute = path.join(directory, entry.name);
|
|
117
|
+
if (entry.isFile() && /^(?:chrome(?:-headless-shell)?|chromium|msedge|headless_shell)(?:\.exe)?$/i.test(entry.name)) candidates.push(absolute);
|
|
118
|
+
else if (entry.isDirectory()) visit(absolute, depth + 1);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
visit(root, 0);
|
|
122
|
+
return candidates;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function launchBrowser(chromium, requested, requestedPath) {
|
|
100
126
|
const attempts = [];
|
|
127
|
+
if (requestedPath) attempts.push({ executablePath: requestedPath, label: `path:${requestedPath}` });
|
|
101
128
|
if (requested && requested !== 'chromium') attempts.push({ channel: requested });
|
|
102
|
-
for (const executablePath of executableCandidates().filter((candidate) => fs.existsSync(candidate))) {
|
|
103
|
-
attempts.
|
|
129
|
+
for (const executablePath of [...executableCandidates(), ...playwrightCacheCandidates()].filter((candidate) => fs.existsSync(candidate))) {
|
|
130
|
+
if (!attempts.some((attempt) => attempt.executablePath === executablePath)) {
|
|
131
|
+
attempts.push({ executablePath, label: `path:${executablePath}` });
|
|
132
|
+
}
|
|
104
133
|
}
|
|
105
134
|
if (!requested || requested === 'chromium') attempts.push({});
|
|
106
135
|
for (const channel of ['msedge', 'chrome']) {
|
|
@@ -108,14 +137,42 @@ async function launchBrowser(chromium, requested) {
|
|
|
108
137
|
}
|
|
109
138
|
|
|
110
139
|
let lastError = null;
|
|
140
|
+
const attempted = [];
|
|
111
141
|
for (const options of attempts) {
|
|
112
142
|
try {
|
|
113
|
-
|
|
143
|
+
const { label, ...launchOptions } = options;
|
|
144
|
+
const browser = await chromium.launch({ headless: true, ...launchOptions });
|
|
145
|
+
return { browser, selected: label || options.channel || 'playwright-managed', attempted };
|
|
114
146
|
} catch (err) {
|
|
115
147
|
lastError = err;
|
|
148
|
+
attempted.push(`${options.label || options.channel || 'playwright-managed'}: ${String(err.message || err).split('\n')[0]}`);
|
|
116
149
|
}
|
|
117
150
|
}
|
|
118
|
-
throw new Error(`未找到可用的 Chromium/Chrome/Edge
|
|
151
|
+
throw new Error(`未找到可用的 Chromium/Chrome/Edge。请使用 --browser-path <executable> 或 DRAFTGO_BROWSER_PATH。`
|
|
152
|
+
+ `尝试记录:${attempted.join('; ')}`
|
|
153
|
+
+ (lastError ? `;最后错误:${lastError.message.split('\n')[0]}` : ''));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parseResourceSpec(value) {
|
|
157
|
+
const raw = String(value || '');
|
|
158
|
+
const separator = raw.indexOf(':');
|
|
159
|
+
if (separator <= 0 || separator === raw.length - 1) {
|
|
160
|
+
throw new Error('--resource 格式必须是 <pages|nav|docs>:<id>。');
|
|
161
|
+
}
|
|
162
|
+
return { resourceType: canonicalResourceType(raw.slice(0, separator)), resourceId: raw.slice(separator + 1) };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function verifyRemoteResource(projectDir, spec) {
|
|
166
|
+
const { resourceType, resourceId } = parseResourceSpec(spec);
|
|
167
|
+
const entry = getEntry(loadManifest(projectDir), resourceType, resourceId);
|
|
168
|
+
if (!entry) throw new Error(`${resourceType} ${resourceId} 未 checkout。`);
|
|
169
|
+
const config = loadProjectConfig(projectDir);
|
|
170
|
+
const session = await openMetadataSession(config);
|
|
171
|
+
const remote = await resolveMetadata(config, resourceType, resourceId, {
|
|
172
|
+
...session,
|
|
173
|
+
clientInitialized: true,
|
|
174
|
+
});
|
|
175
|
+
return inspectEntry(projectDir, entry, remote);
|
|
119
176
|
}
|
|
120
177
|
|
|
121
178
|
async function verifyUi(projectDir, positional, flags = {}) {
|
|
@@ -136,7 +193,9 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
136
193
|
log.err('--mobile-check 只支持 auto、always、never。');
|
|
137
194
|
return 1;
|
|
138
195
|
}
|
|
139
|
-
const decision =
|
|
196
|
+
const decision = flags.resource
|
|
197
|
+
? { run: true, reason: `resource=${flags.resource}` }
|
|
198
|
+
: decideMobileCheck(projectDir, mode);
|
|
140
199
|
if (!decision.run) {
|
|
141
200
|
log.ok(`跳过 UI smoke check:${decision.reason}`);
|
|
142
201
|
return 0;
|
|
@@ -169,7 +228,26 @@ async function verifyUi(projectDir, positional, flags = {}) {
|
|
|
169
228
|
|
|
170
229
|
let browser;
|
|
171
230
|
try {
|
|
172
|
-
|
|
231
|
+
if (flags.resource) {
|
|
232
|
+
const resource = await verifyRemoteResource(projectDir, flags.resource);
|
|
233
|
+
log.info(`UI source: remote committed ${resource.resource_type} ${resource.resource_id}`);
|
|
234
|
+
log.info(`Remote version: ${resource.remote_version || '-'}; remote hash: ${resource.remote_hash || '-'}`);
|
|
235
|
+
if (['local_modified', 'diverged'].includes(resource.state)) {
|
|
236
|
+
log.warn(`Local worktree is not committed (${resource.state}); verify-ui will test the remote version.`);
|
|
237
|
+
}
|
|
238
|
+
if (!resource.local_matches_remote && resource.state !== 'local_modified') {
|
|
239
|
+
throw new Error(`Local and remote content differ (${resource.state}); UI verification stopped.`);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
log.info('UI source: remote URL response; no local worktree version was asserted.');
|
|
243
|
+
}
|
|
244
|
+
const launch = await launchBrowser(
|
|
245
|
+
chromium,
|
|
246
|
+
flags.browser && String(flags.browser),
|
|
247
|
+
flags['browser-path'] && String(flags['browser-path']),
|
|
248
|
+
);
|
|
249
|
+
browser = launch.browser;
|
|
250
|
+
log.info(`Browser: ${launch.selected}`);
|
|
173
251
|
const page = await browser.newPage({ viewport: { width, height } });
|
|
174
252
|
const consoleErrors = [];
|
|
175
253
|
const pageErrors = [];
|
|
@@ -240,3 +318,7 @@ module.exports.gitChangedFiles = gitChangedFiles;
|
|
|
240
318
|
module.exports.isUiFile = isUiFile;
|
|
241
319
|
module.exports.configuredUiUrl = configuredUiUrl;
|
|
242
320
|
module.exports.redactUrlTokens = redactUrlTokens;
|
|
321
|
+
module.exports.executableCandidates = executableCandidates;
|
|
322
|
+
module.exports.playwrightCacheCandidates = playwrightCacheCandidates;
|
|
323
|
+
module.exports.parseResourceSpec = parseResourceSpec;
|
|
324
|
+
module.exports.verifyRemoteResource = verifyRemoteResource;
|
package/src/mcp/client.js
CHANGED
|
@@ -225,16 +225,30 @@ class DraftGoMcpClient {
|
|
|
225
225
|
}
|
|
226
226
|
|
|
227
227
|
async initialize(options = {}) {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
228
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'started');
|
|
229
|
+
let result;
|
|
230
|
+
try {
|
|
231
|
+
result = await this.request('initialize', {
|
|
232
|
+
protocolVersion: options.protocolVersion || this.protocolVersion,
|
|
233
|
+
capabilities: options.capabilities || {},
|
|
234
|
+
clientInfo: options.clientInfo || {
|
|
235
|
+
name: 'draftgo-cli',
|
|
236
|
+
version: pkg.version,
|
|
237
|
+
},
|
|
238
|
+
}, options);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'failed');
|
|
241
|
+
throw error;
|
|
242
|
+
}
|
|
243
|
+
if (typeof options.onStage === 'function') options.onStage('initialize', 'succeeded', result);
|
|
236
244
|
if (result && result.protocolVersion) this.protocolVersion = result.protocolVersion;
|
|
237
|
-
|
|
245
|
+
try {
|
|
246
|
+
await this.notify('notifications/initialized', undefined, options);
|
|
247
|
+
} catch (error) {
|
|
248
|
+
if (typeof options.onStage === 'function') options.onStage('initialized', 'failed');
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
if (typeof options.onStage === 'function') options.onStage('initialized', 'succeeded');
|
|
238
252
|
return result;
|
|
239
253
|
}
|
|
240
254
|
|
|
@@ -279,7 +293,15 @@ class DraftGoMcpClient {
|
|
|
279
293
|
|
|
280
294
|
async testConnection(options = {}) {
|
|
281
295
|
const initialized = await this.initialize(options);
|
|
282
|
-
|
|
296
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'started');
|
|
297
|
+
let tools;
|
|
298
|
+
try {
|
|
299
|
+
tools = await this.listAllTools(options);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'failed');
|
|
302
|
+
throw error;
|
|
303
|
+
}
|
|
304
|
+
if (typeof options.onStage === 'function') options.onStage('tools/list', 'succeeded', { count: tools.length });
|
|
283
305
|
const requiredTools = options.requiredTools || REQUIRED_DRAFTGO_TOOLS;
|
|
284
306
|
const missing = requiredTools.filter((expected) => !tools.some((tool) =>
|
|
285
307
|
tool && typeof tool.name === 'string' && toolMatches(tool.name, expected)));
|
|
@@ -325,21 +347,32 @@ class DraftGoMcpClient {
|
|
|
325
347
|
},
|
|
326
348
|
];
|
|
327
349
|
const runPlan = (plan, startIndex = 0) => allWithAbort(plan.map((entry, offset) =>
|
|
328
|
-
async (queryOptions) =>
|
|
350
|
+
async (queryOptions) => {
|
|
351
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'started');
|
|
352
|
+
let result;
|
|
353
|
+
try {
|
|
354
|
+
result = await this.toolsCall(
|
|
355
|
+
entry.tool.name,
|
|
356
|
+
options.toolArguments && startIndex + offset === 0
|
|
357
|
+
? options.toolArguments
|
|
358
|
+
: entry.args,
|
|
359
|
+
queryOptions,
|
|
360
|
+
);
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'failed');
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
if (typeof options.onStage === 'function') options.onStage(`tools/call:${entry.label}`, 'succeeded');
|
|
366
|
+
return ({
|
|
329
367
|
label: entry.label,
|
|
330
368
|
canonical: entry.canonical,
|
|
331
369
|
name: entry.tool.name,
|
|
332
370
|
arguments: options.toolArguments && startIndex + offset === 0
|
|
333
371
|
? options.toolArguments
|
|
334
372
|
: entry.args,
|
|
335
|
-
result
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
? options.toolArguments
|
|
339
|
-
: entry.args,
|
|
340
|
-
queryOptions,
|
|
341
|
-
),
|
|
342
|
-
})), options);
|
|
373
|
+
result,
|
|
374
|
+
});
|
|
375
|
+
}), options);
|
|
343
376
|
const tested = await runPlan(defaultPlan);
|
|
344
377
|
if (!hasDiagnosticOverrides) {
|
|
345
378
|
let discovery = tested.find((entry) => entry.label === 'api:db_meta');
|
package/src/projectMap.js
CHANGED
|
@@ -148,8 +148,13 @@ function analyzeProject(projectDir) {
|
|
|
148
148
|
}
|
|
149
149
|
if (!fs.existsSync(base)) {
|
|
150
150
|
errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base is missing (${entry.base_path}).`);
|
|
151
|
-
} else
|
|
152
|
-
|
|
151
|
+
} else {
|
|
152
|
+
const actualBaseHash = sha256File(base);
|
|
153
|
+
if (actualBaseHash !== entry.base_hash) {
|
|
154
|
+
errors.push(`${entry.resource_type} ${entry.resource_id}: checkout base hash does not match the manifest `
|
|
155
|
+
+ `(manifest=${entry.base_hash}, base=${actualBaseHash}). `
|
|
156
|
+
+ `Run \`draftgo check --remote\` to classify the mismatch before replacing files.`);
|
|
157
|
+
}
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
const type = mediaType(entry.content_type);
|
package/src/worktree/index.js
CHANGED
|
@@ -15,7 +15,14 @@ const {
|
|
|
15
15
|
resourceFileName,
|
|
16
16
|
safeIdSegment,
|
|
17
17
|
} = require('./types');
|
|
18
|
-
const {
|
|
18
|
+
const {
|
|
19
|
+
streamToFiles,
|
|
20
|
+
hashFile,
|
|
21
|
+
copyFileAtomic,
|
|
22
|
+
writeJsonAtomic,
|
|
23
|
+
normalizeSha256,
|
|
24
|
+
tempPathFor,
|
|
25
|
+
} = require('./streams');
|
|
19
26
|
const {
|
|
20
27
|
loadManifest,
|
|
21
28
|
getEntry,
|
|
@@ -24,6 +31,7 @@ const {
|
|
|
24
31
|
saveManifest,
|
|
25
32
|
} = require('./manifest');
|
|
26
33
|
const { validateContentFile } = require('./validate');
|
|
34
|
+
const { inspectEntry, openMetadataSession, sameVersion } = require('./status');
|
|
27
35
|
|
|
28
36
|
const CONFLICT_SCHEMA_VERSION = 1;
|
|
29
37
|
|
|
@@ -248,36 +256,148 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
248
256
|
const session = await createSession(config, options);
|
|
249
257
|
const manifest = loadManifest(projectDir);
|
|
250
258
|
const results = [];
|
|
259
|
+
const plans = [];
|
|
260
|
+
const preflightFailures = [];
|
|
261
|
+
|
|
262
|
+
const report = (result) => {
|
|
263
|
+
results.push(result);
|
|
264
|
+
if (typeof options.onStatus === 'function') options.onStatus(result);
|
|
265
|
+
};
|
|
251
266
|
|
|
267
|
+
// Resolve and validate every target before the first remote write. This prevents
|
|
268
|
+
// a late local or stale-version error from causing an avoidable partial batch.
|
|
252
269
|
for (const resourceId of ids) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
270
|
+
try {
|
|
271
|
+
const entry = getEntry(manifest, canonical, resourceId);
|
|
272
|
+
if (!entry) {
|
|
273
|
+
throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
|
|
274
|
+
}
|
|
275
|
+
if (entry.server !== config.server) {
|
|
276
|
+
throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
|
|
277
|
+
}
|
|
278
|
+
if (unresolvedConflict(projectDir, canonical, resourceId)) {
|
|
279
|
+
throw new WorktreeError(
|
|
280
|
+
'UNRESOLVED_CONFLICT',
|
|
281
|
+
`${canonical} ${resourceId} has an unresolved conflict; resolve it before committing.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const localPath = absolutePath(projectDir, entry.local_path);
|
|
286
|
+
const current = await hashFile(localPath);
|
|
287
|
+
const basePath = absolutePath(projectDir, entry.base_path);
|
|
288
|
+
const base = await hashFile(basePath);
|
|
289
|
+
if (base.hash !== entry.base_hash) {
|
|
290
|
+
throw new WorktreeError(
|
|
291
|
+
'BASE_HASH_MISMATCH',
|
|
292
|
+
`${canonical} ${resourceId} base hash differs from its manifest; run draftgo check --remote.`,
|
|
293
|
+
{ manifest_hash: entry.base_hash, base_hash: base.hash },
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (current.hash !== entry.base_hash) validateContentFile(localPath, entry.content_type);
|
|
297
|
+
|
|
298
|
+
const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
299
|
+
...options,
|
|
300
|
+
...session,
|
|
301
|
+
clientInitialized: true,
|
|
302
|
+
});
|
|
303
|
+
assertMetadataIdentity(fresh, canonical, resourceId);
|
|
304
|
+
const remoteMatchesBase = fresh.content_hash === entry.base_hash && sameVersion(entry, fresh);
|
|
305
|
+
if (!remoteMatchesBase) {
|
|
306
|
+
const state = await inspectEntry(projectDir, entry, fresh);
|
|
307
|
+
throw new WorktreeError(
|
|
308
|
+
state.local_matches_remote ? 'COMMITTED_UNRECORDED' : 'REMOTE_VERSION_CHANGED',
|
|
309
|
+
state.local_matches_remote
|
|
310
|
+
? `${canonical} ${resourceId} already equals remote; run ${state.recommendation}.`
|
|
311
|
+
: `${canonical} ${resourceId} changed remotely; inspect with draftgo check --remote before committing.`,
|
|
312
|
+
state,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
plans.push({ resourceId, entry, localPath, current, fresh });
|
|
316
|
+
} catch (error) {
|
|
317
|
+
if (error.code === 'REMOTE_VERSION_CHANGED') {
|
|
318
|
+
const entry = getEntry(manifest, canonical, resourceId);
|
|
319
|
+
try {
|
|
320
|
+
const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
|
|
321
|
+
error = new WorktreeError(
|
|
322
|
+
'RESOURCE_VERSION_CONFLICT',
|
|
323
|
+
`${canonical} ${resourceId} changed remotely; conflict materials were preserved during preflight.`,
|
|
324
|
+
conflict,
|
|
325
|
+
);
|
|
326
|
+
} catch (conflictError) {
|
|
327
|
+
error = conflictError;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
preflightFailures.push({
|
|
331
|
+
resource_type: canonical,
|
|
332
|
+
resource_id: resourceId,
|
|
333
|
+
status: 'failed',
|
|
334
|
+
phase: 'preflight',
|
|
335
|
+
code: error.code || 'PREFLIGHT_FAILED',
|
|
336
|
+
message: error.message,
|
|
337
|
+
details: error.details || {},
|
|
338
|
+
});
|
|
259
339
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (preflightFailures.length) {
|
|
343
|
+
const failedIds = new Set(preflightFailures.map((item) => String(item.resource_id)));
|
|
344
|
+
const notStarted = ids.filter((id) => !failedIds.has(String(id))).map((resourceId) => ({
|
|
345
|
+
resource_type: canonical,
|
|
346
|
+
resource_id: resourceId,
|
|
347
|
+
status: 'not_started',
|
|
348
|
+
phase: 'preflight',
|
|
349
|
+
}));
|
|
350
|
+
for (const result of [...preflightFailures, ...notStarted]) report(result);
|
|
351
|
+
if (ids.length === 1 && preflightFailures.length === 1) {
|
|
352
|
+
const original = new WorktreeError(
|
|
353
|
+
preflightFailures[0].code,
|
|
354
|
+
preflightFailures[0].message,
|
|
355
|
+
preflightFailures[0].details,
|
|
264
356
|
);
|
|
357
|
+
throw original;
|
|
265
358
|
}
|
|
359
|
+
throw new WorktreeError(
|
|
360
|
+
'BATCH_PREFLIGHT_FAILED',
|
|
361
|
+
`Commit preflight failed for ${preflightFailures.length} resource(s); no remote content was changed.`,
|
|
362
|
+
{ completed: [], failed: preflightFailures, not_started: notStarted },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
266
365
|
|
|
267
|
-
|
|
268
|
-
const
|
|
366
|
+
function throwBatchFailure(error, index, resourceId, remoteChangePossible = false) {
|
|
367
|
+
const failed = {
|
|
368
|
+
resource_type: canonical,
|
|
369
|
+
resource_id: resourceId,
|
|
370
|
+
status: 'failed',
|
|
371
|
+
phase: 'upload',
|
|
372
|
+
code: error.code || 'COMMIT_FAILED',
|
|
373
|
+
message: error.message,
|
|
374
|
+
remote_change_possible: remoteChangePossible,
|
|
375
|
+
};
|
|
376
|
+
const notStarted = plans.slice(index + 1).map((plan) => ({
|
|
377
|
+
resource_type: canonical,
|
|
378
|
+
resource_id: plan.resourceId,
|
|
379
|
+
status: 'not_started',
|
|
380
|
+
phase: 'upload',
|
|
381
|
+
}));
|
|
382
|
+
report(failed);
|
|
383
|
+
for (const item of notStarted) report(item);
|
|
384
|
+
error.details = {
|
|
385
|
+
...(error.details || {}),
|
|
386
|
+
batch: {
|
|
387
|
+
completed: results.filter((item) => ['committed', 'unchanged'].includes(item.status)),
|
|
388
|
+
failed: [failed],
|
|
389
|
+
not_started: notStarted,
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
throw error;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
for (let index = 0; index < plans.length; index += 1) {
|
|
396
|
+
const { resourceId, entry, localPath, current, fresh } = plans[index];
|
|
269
397
|
if (current.hash === entry.base_hash) {
|
|
270
|
-
|
|
398
|
+
report({ resource_type: canonical, resource_id: resourceId, status: 'unchanged', hash: current.hash });
|
|
271
399
|
continue;
|
|
272
400
|
}
|
|
273
|
-
validateContentFile(localPath, entry.content_type);
|
|
274
|
-
|
|
275
|
-
const fresh = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
276
|
-
...options,
|
|
277
|
-
...session,
|
|
278
|
-
clientInitialized: true,
|
|
279
|
-
});
|
|
280
|
-
assertMetadataIdentity(fresh, canonical, resourceId);
|
|
281
401
|
const commitMetadata = {
|
|
282
402
|
...fresh,
|
|
283
403
|
content_type: entry.content_type,
|
|
@@ -290,55 +410,147 @@ async function commitResources(projectDir, resourceType, resourceIds, options =
|
|
|
290
410
|
try {
|
|
291
411
|
responsePayload = await backend.commit(config, commitMetadata, localPath, current, options);
|
|
292
412
|
} catch (error) {
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
413
|
+
let failure = error;
|
|
414
|
+
const versionConflict = isVersionConflict(error);
|
|
415
|
+
if (versionConflict) {
|
|
416
|
+
const conflict = await writeConflict(projectDir, entry, error, config, backend, options, session);
|
|
417
|
+
failure = new WorktreeError(
|
|
418
|
+
'RESOURCE_VERSION_CONFLICT',
|
|
419
|
+
`${canonical} ${resourceId} changed remotely; conflict materials were preserved.`,
|
|
420
|
+
conflict,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
throwBatchFailure(failure, index, resourceId, !versionConflict);
|
|
300
424
|
}
|
|
301
425
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
426
|
+
try {
|
|
427
|
+
let committed = commitVersion(normalizeCommitPayload(responsePayload));
|
|
428
|
+
if (!committed.hash || (committed.version == null && committed.revision == null)) {
|
|
429
|
+
const confirmed = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
430
|
+
...options,
|
|
431
|
+
...session,
|
|
432
|
+
clientInitialized: true,
|
|
433
|
+
});
|
|
434
|
+
assertMetadataIdentity(confirmed, canonical, resourceId);
|
|
435
|
+
committed = {
|
|
436
|
+
version: confirmed.base_version,
|
|
437
|
+
revision: confirmed.base_revision,
|
|
438
|
+
etag: confirmed.etag,
|
|
439
|
+
hash: confirmed.content_hash,
|
|
440
|
+
updated_at: confirmed.updated_at,
|
|
441
|
+
updated_by: confirmed.updated_by,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
if (committed.hash && committed.hash !== current.hash) {
|
|
445
|
+
throw new WorktreeError(
|
|
446
|
+
'HASH_MISMATCH',
|
|
447
|
+
'DraftGo commit response hash differs from the uploaded raw bytes; manifest was not advanced.',
|
|
448
|
+
{ local_hash: current.hash, remote_hash: committed.hash },
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
await copyFileAtomic(localPath, absolutePath(projectDir, entry.base_path), { expectedHash: current.hash });
|
|
453
|
+
entry.base_hash = current.hash;
|
|
454
|
+
entry.content_size = current.size;
|
|
455
|
+
entry.base_version = committed.version;
|
|
456
|
+
entry.base_revision = committed.revision;
|
|
457
|
+
entry.base_etag = committed.etag;
|
|
458
|
+
entry.updated_at = committed.updated_at;
|
|
459
|
+
entry.updated_by = committed.updated_by;
|
|
460
|
+
entry.committed_at = new Date().toISOString();
|
|
461
|
+
await saveManifest(projectDir, manifest);
|
|
462
|
+
report({
|
|
463
|
+
resource_type: canonical,
|
|
464
|
+
resource_id: resourceId,
|
|
465
|
+
status: 'committed',
|
|
466
|
+
hash: current.hash,
|
|
467
|
+
base_version: entry.base_version,
|
|
468
|
+
base_revision: entry.base_revision,
|
|
308
469
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
470
|
+
} catch (error) {
|
|
471
|
+
throwBatchFailure(error, index, resourceId, true);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return results;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function reconcileResources(projectDir, resourceType, resourceIds, options = {}) {
|
|
478
|
+
const canonical = canonicalResourceType(resourceType);
|
|
479
|
+
const ids = ensureIds(resourceIds);
|
|
480
|
+
const config = options.config || loadProjectConfig(projectDir);
|
|
481
|
+
const backend = backendFor(options);
|
|
482
|
+
const manifest = loadManifest(projectDir);
|
|
483
|
+
const session = options.backend && typeof options.backend.resolveMetadata === 'function'
|
|
484
|
+
? { client: options.client || {}, tools: options.tools || [] }
|
|
485
|
+
: await openMetadataSession(config, options);
|
|
486
|
+
const results = [];
|
|
487
|
+
|
|
488
|
+
for (const resourceId of ids) {
|
|
489
|
+
const entry = getEntry(manifest, canonical, resourceId);
|
|
490
|
+
if (!entry) throw new WorktreeError('RESOURCE_NOT_CHECKED_OUT', `${canonical} ${resourceId} is not checked out.`);
|
|
491
|
+
if (entry.server !== config.server) {
|
|
492
|
+
throw new WorktreeError('CHECKOUT_SERVER_MISMATCH', 'Checkout belongs to a different DraftGo server.');
|
|
318
493
|
}
|
|
319
|
-
if (
|
|
494
|
+
if (unresolvedConflict(projectDir, canonical, resourceId)) {
|
|
320
495
|
throw new WorktreeError(
|
|
321
|
-
'
|
|
322
|
-
|
|
323
|
-
|
|
496
|
+
'UNRESOLVED_CONFLICT',
|
|
497
|
+
`${canonical} ${resourceId} has an unresolved conflict; use conflict resolve instead of reconcile.`,
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
const remote = await backend.resolveMetadata(config, canonical, resourceId, {
|
|
501
|
+
...options, ...session, clientInitialized: true,
|
|
502
|
+
});
|
|
503
|
+
assertMetadataIdentity(remote, canonical, resourceId);
|
|
504
|
+
const status = await inspectEntry(projectDir, entry, remote);
|
|
505
|
+
if (!status.local_matches_remote) {
|
|
506
|
+
throw new WorktreeError(
|
|
507
|
+
'RECONCILE_UNSAFE',
|
|
508
|
+
`${canonical} ${resourceId} local content does not equal remote; refusing to advance metadata.`,
|
|
509
|
+
status,
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
if (entry.content_type !== remote.content_type || entry.file_extension !== remote.file_extension) {
|
|
513
|
+
throw new WorktreeError(
|
|
514
|
+
'RECONCILE_FORMAT_CHANGED',
|
|
515
|
+
`${canonical} ${resourceId} remote content format changed; use checkout after preserving local work.`,
|
|
324
516
|
);
|
|
325
517
|
}
|
|
326
518
|
|
|
327
|
-
await
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
519
|
+
const response = await backend.download(config, remote, options);
|
|
520
|
+
const basePath = absolutePath(projectDir, entry.base_path);
|
|
521
|
+
const verifiedRemote = tempPathFor(basePath);
|
|
522
|
+
let verified;
|
|
523
|
+
try {
|
|
524
|
+
verified = await streamToFiles(response.body, [verifiedRemote], {
|
|
525
|
+
expectedHash: remote.content_hash,
|
|
526
|
+
expectedSize: remote.content_size,
|
|
527
|
+
});
|
|
528
|
+
const localAfterDownload = await hashFile(absolutePath(projectDir, entry.local_path));
|
|
529
|
+
if (localAfterDownload.hash !== verified.hash) {
|
|
530
|
+
throw new WorktreeError(
|
|
531
|
+
'RECONCILE_LOCAL_CHANGED',
|
|
532
|
+
`${canonical} ${resourceId} local content changed during reconcile; manifest was not advanced.`,
|
|
533
|
+
{ local_hash: localAfterDownload.hash, remote_hash: verified.hash },
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
await copyFileAtomic(verifiedRemote, basePath, { expectedHash: verified.hash });
|
|
537
|
+
} finally {
|
|
538
|
+
await fs.promises.rm(verifiedRemote, { force: true }).catch(() => {});
|
|
539
|
+
}
|
|
540
|
+
entry.base_hash = verified.hash;
|
|
541
|
+
entry.content_size = verified.size;
|
|
542
|
+
entry.base_version = remote.base_version;
|
|
543
|
+
entry.base_revision = remote.base_revision;
|
|
544
|
+
entry.base_etag = remote.etag;
|
|
545
|
+
entry.updated_at = remote.updated_at;
|
|
546
|
+
entry.updated_by = remote.updated_by;
|
|
547
|
+
entry.reconciled_at = new Date().toISOString();
|
|
336
548
|
await saveManifest(projectDir, manifest);
|
|
337
549
|
results.push({
|
|
338
550
|
resource_type: canonical,
|
|
339
551
|
resource_id: resourceId,
|
|
340
|
-
status: '
|
|
341
|
-
hash:
|
|
552
|
+
status: 'reconciled',
|
|
553
|
+
hash: verified.hash,
|
|
342
554
|
base_version: entry.base_version,
|
|
343
555
|
base_revision: entry.base_revision,
|
|
344
556
|
});
|
|
@@ -454,6 +666,7 @@ module.exports = {
|
|
|
454
666
|
conflictPaths,
|
|
455
667
|
checkoutResources,
|
|
456
668
|
commitResources,
|
|
669
|
+
reconcileResources,
|
|
457
670
|
diffResource,
|
|
458
671
|
listConflicts,
|
|
459
672
|
showConflict,
|