draftgo-cli 1.0.4 → 1.0.6

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/src/mcp/hosts.js CHANGED
@@ -22,6 +22,7 @@ const HOSTS = Object.freeze([
22
22
  { name: 'gemini', displayName: 'Gemini CLI', path: '.gemini/settings.json', format: 'jsonc', registry: 'mcpServers', signals: ['.gemini'] },
23
23
  { name: 'kiro', displayName: 'Kiro', path: '.kiro/settings/mcp.json', format: 'jsonc', registry: 'mcpServers', signals: ['.kiro'] },
24
24
  { name: 'copilot', aliases: ['github-copilot'], displayName: 'GitHub Copilot', path: '.vscode/mcp.json', format: 'jsonc', registry: 'servers', vscode: true, signals: ['.vscode', '.github/prompts', '.github/copilot-instructions.md'] },
25
+ { name: 'zcode', displayName: 'ZCode', path: '.zcode/config.json', format: 'jsonc', registry: 'mcp.servers', vscode: true, signals: ['.zcode'] },
25
26
  { name: 'windsurf', displayName: 'Windsurf', supported: false, reason: 'Windsurf has no supported project-level MCP configuration adapter.', signals: ['.windsurf'] },
26
27
  { name: 'antigravity', displayName: 'Antigravity', supported: false, reason: 'Antigravity has no supported project-level MCP configuration adapter.', signals: ['.agent'] },
27
28
  ]);
@@ -249,41 +250,85 @@ function insertObjectProperty(source, open, close, key, value, newline) {
249
250
  );
250
251
  }
251
252
 
253
+ function registryPath(registry) {
254
+ return String(registry || '').split('.').map((part) => part.trim()).filter(Boolean);
255
+ }
256
+
257
+ function nestedValue(value, keys) {
258
+ let current = value;
259
+ for (const key of keys) {
260
+ if (!current || typeof current !== 'object' || Array.isArray(current) || !Object.prototype.hasOwnProperty.call(current, key)) {
261
+ return undefined;
262
+ }
263
+ current = current[key];
264
+ }
265
+ return current;
266
+ }
267
+
268
+ function assertRegistryObjects(parsed, keys, file) {
269
+ let current = parsed;
270
+ for (let index = 0; index < keys.length; index += 1) {
271
+ const key = keys[index];
272
+ if (current[key] === undefined) return;
273
+ if (!current[key] || typeof current[key] !== 'object' || Array.isArray(current[key])) {
274
+ throw new Error(`Invalid ${file}: ${keys.slice(0, index + 1).join('.')} must be an object.`);
275
+ }
276
+ current = current[key];
277
+ }
278
+ }
279
+
280
+ function findNestedObject(source, open, close, keys) {
281
+ let currentOpen = open;
282
+ let currentClose = close;
283
+ let matched = 0;
284
+ for (const key of keys) {
285
+ const property = objectProperties(source, currentOpen, currentClose).find((item) => item.key === key);
286
+ if (!property) break;
287
+ currentOpen = property.valueStart;
288
+ currentClose = scanComposite(source, currentOpen) - 1;
289
+ matched += 1;
290
+ }
291
+ return { open: currentOpen, close: currentClose, matched };
292
+ }
293
+
294
+ function upsertDraftGoEntry(source, open, close, entry, newline) {
295
+ const serverProperties = objectProperties(source, open, close);
296
+ const draftgo = serverProperties.find((item) => item.key === 'draftgo');
297
+ if (draftgo) {
298
+ const indent = lineIndent(source, draftgo.propertyStart);
299
+ return replaceRange(
300
+ source,
301
+ draftgo.valueStart,
302
+ draftgo.valueEnd,
303
+ formatJsonValue(entry, indent, newline),
304
+ );
305
+ }
306
+ return insertObjectProperty(source, open, close, 'draftgo', entry, newline);
307
+ }
308
+
252
309
  function mergeJsoncConfig(source, registry, entry, file = 'MCP config') {
310
+ const keys = registryPath(registry);
311
+ if (!keys.length) throw new Error(`Invalid ${file}: MCP registry path is missing.`);
253
312
  const initial = source.trim() ? source : '{\n}\n';
254
313
  const parsed = parseJsonc(initial, file);
255
- if (parsed[registry] !== undefined
256
- && (!parsed[registry] || typeof parsed[registry] !== 'object' || Array.isArray(parsed[registry]))) {
257
- throw new Error(`Invalid ${file}: ${registry} must be an object.`);
258
- }
314
+ assertRegistryObjects(parsed, keys, file);
259
315
 
260
316
  const newline = initial.includes('\r\n') ? '\r\n' : '\n';
261
317
  const rootOffset = initial.charCodeAt(0) === 0xfeff ? 1 : 0;
262
318
  const rootOpen = skipTrivia(initial, rootOffset);
263
319
  const rootEnd = scanComposite(initial, rootOpen);
264
320
  const rootClose = rootEnd - 1;
265
- const rootProperties = objectProperties(initial, rootOpen, rootClose);
266
- const registryProperty = rootProperties.find((item) => item.key === registry);
321
+ const nested = findNestedObject(initial, rootOpen, rootClose, keys);
267
322
  let result;
268
323
 
269
- if (!registryProperty) {
270
- result = insertObjectProperty(initial, rootOpen, rootClose, registry, { draftgo: entry }, newline);
324
+ if (nested.matched === keys.length) {
325
+ result = upsertDraftGoEntry(initial, nested.open, nested.close, entry, newline);
271
326
  } else {
272
- const registryOpen = registryProperty.valueStart;
273
- const registryClose = scanComposite(initial, registryOpen) - 1;
274
- const serverProperties = objectProperties(initial, registryOpen, registryClose);
275
- const draftgo = serverProperties.find((item) => item.key === 'draftgo');
276
- if (draftgo) {
277
- const indent = lineIndent(initial, draftgo.propertyStart);
278
- result = replaceRange(
279
- initial,
280
- draftgo.valueStart,
281
- draftgo.valueEnd,
282
- formatJsonValue(entry, indent, newline),
283
- );
284
- } else {
285
- result = insertObjectProperty(initial, registryOpen, registryClose, 'draftgo', entry, newline);
327
+ let value = { draftgo: entry };
328
+ for (let index = keys.length - 1; index > nested.matched; index -= 1) {
329
+ value = { [keys[index]]: value };
286
330
  }
331
+ result = insertObjectProperty(initial, nested.open, nested.close, keys[nested.matched], value, newline);
287
332
  }
288
333
 
289
334
  parseJsonc(result, file);
@@ -482,7 +527,7 @@ function statusHost(projectDir, target) {
482
527
  if (host.format === 'toml') return { host, supported: true, path: file, ...codexStatus(source) };
483
528
  try {
484
529
  const value = parseJsonc(source, host.path);
485
- const registry = value[host.registry];
530
+ const registry = nestedValue(value, registryPath(host.registry));
486
531
  const entry = registry && typeof registry === 'object' && !Array.isArray(registry)
487
532
  ? registry.draftgo
488
533
  : undefined;
package/src/platforms.js CHANGED
@@ -79,10 +79,13 @@ const platforms = [
79
79
  {
80
80
  name: 'codex',
81
81
  displayName: 'Codex CLI',
82
- mainFile: '.codex/skills/draftgo/SKILL.md',
83
- assetDir: '.codex/skills/draftgo',
84
- skillDir: '.codex/skills/draftgo',
82
+ // Repo-level Codex skills now follow the Agent Skills path.
83
+ // MCP config still lives in .codex/config.toml, so detection stays on .codex.
84
+ mainFile: '.agents/skills/draftgo/SKILL.md',
85
+ assetDir: '.agents/skills/draftgo',
86
+ skillDir: '.agents/skills/draftgo',
85
87
  signals: ['.codex'],
88
+ legacyAssetDirs: ['.codex/skills/draftgo'],
86
89
  frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
87
90
  },
88
91
  {
@@ -100,7 +103,16 @@ const platforms = [
100
103
  mainFile: '.agents/skills/draftgo/SKILL.md',
101
104
  assetDir: '.agents/skills/draftgo',
102
105
  skillDir: '.agents/skills/draftgo',
103
- signals: ['.agents/skills', '.pi'],
106
+ signals: ['.pi'],
107
+ frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
108
+ },
109
+ {
110
+ name: 'zcode',
111
+ displayName: 'ZCode',
112
+ mainFile: '.zcode/skills/draftgo/SKILL.md',
113
+ assetDir: '.zcode/skills/draftgo',
114
+ skillDir: '.zcode/skills/draftgo',
115
+ signals: ['.zcode'],
104
116
  frontmatter: { name: SKILL_NAME, description: DESCRIPTION },
105
117
  },
106
118
  ];
package/src/skill.js CHANGED
@@ -193,6 +193,19 @@ function ensureRuntime(projectDir) {
193
193
  appendGitignoreLine(projectDir, '.draftgo/conflicts/');
194
194
  }
195
195
 
196
+ function legacySkillDirs(projectDir, platform) {
197
+ const destAsset = path.resolve(path.join(projectDir, platform.assetDir));
198
+ return (platform.legacyAssetDirs || [])
199
+ .map((relative) => path.join(projectDir, relative))
200
+ .filter((absolute) => path.resolve(absolute) !== destAsset && exists(absolute));
201
+ }
202
+
203
+ function skillAlreadyInstalled(projectDir, platform) {
204
+ const destAsset = path.join(projectDir, platform.assetDir);
205
+ const destMain = path.join(projectDir, platform.mainFile);
206
+ return exists(destAsset) || exists(destMain) || legacySkillDirs(projectDir, platform).length > 0;
207
+ }
208
+
196
209
  function installPlatform(projectDir, platform, opts = {}) {
197
210
  if (!exists(SKILL_SOURCE_DIR)) {
198
211
  throw new Error(
@@ -202,7 +215,7 @@ function installPlatform(projectDir, platform, opts = {}) {
202
215
  const destAsset = path.join(projectDir, platform.assetDir);
203
216
  const destMain = path.join(projectDir, platform.mainFile);
204
217
  const mainOutsideAsset = path.relative(destAsset, destMain).startsWith('..');
205
- if ((exists(destAsset) || exists(destMain)) && !opts.force) {
218
+ if (skillAlreadyInstalled(projectDir, platform) && !opts.force) {
206
219
  return { path: platform.mainFile, skipped: true };
207
220
  }
208
221
 
@@ -251,24 +264,31 @@ function installPlatform(projectDir, platform, opts = {}) {
251
264
 
252
265
  if (exists(backupAsset)) removePath(backupAsset);
253
266
  if (backupMain && exists(backupMain)) removePath(backupMain);
267
+ for (const legacyDir of legacySkillDirs(projectDir, platform)) removePath(legacyDir);
254
268
  if (!exists(destMain)) throw new Error(`安装后缺少入口文件:${platform.mainFile}`);
255
269
 
256
270
  return { path: platform.mainFile };
257
271
  }
258
272
 
259
- function uninstallPlatform(projectDir, platform) {
273
+ function uninstallPlatform(projectDir, platform, opts = {}) {
260
274
  const destAsset = path.join(projectDir, platform.assetDir);
261
275
  const destMain = path.join(projectDir, platform.mainFile);
262
276
  let removed = false;
263
- if (exists(destAsset)) { removePath(destAsset); removed = true; }
264
- if (path.relative(destAsset, destMain).startsWith('..') && exists(destMain)) {
265
- removePath(destMain); removed = true;
277
+ if (!opts.keepSharedSkill) {
278
+ if (exists(destAsset)) { removePath(destAsset); removed = true; }
279
+ if (path.relative(destAsset, destMain).startsWith('..') && exists(destMain)) {
280
+ removePath(destMain); removed = true;
281
+ }
282
+ }
283
+ for (const legacyDir of legacySkillDirs(projectDir, platform)) {
284
+ removePath(legacyDir);
285
+ removed = true;
266
286
  }
267
287
  return removed;
268
288
  }
269
289
 
270
290
  function statusPlatform(projectDir, platform) {
271
- return { installed: exists(path.join(projectDir, platform.mainFile)) };
291
+ return { installed: skillAlreadyInstalled(projectDir, platform) };
272
292
  }
273
293
 
274
294
  function installAll(projectDir, platforms, opts = {}) {
package/src/targets.js CHANGED
@@ -14,8 +14,8 @@ function makeInstaller(p) {
14
14
  install(projectDir, opts) {
15
15
  return skill.installPlatform(projectDir, p, opts || {});
16
16
  },
17
- uninstall(projectDir) {
18
- return skill.uninstallPlatform(projectDir, p);
17
+ uninstall(projectDir, opts) {
18
+ return skill.uninstallPlatform(projectDir, p, opts || {});
19
19
  },
20
20
  status(projectDir) {
21
21
  return skill.statusPlatform(projectDir, p);
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const crypto = require('crypto');
3
4
  const fs = require('fs');
5
+ const path = require('path');
4
6
  const { Readable } = require('stream');
5
7
  const { DraftGoMcpClient } = require('../mcp/client');
6
8
  const { BackendHttpError, WorktreeError } = require('./errors');
@@ -15,8 +17,21 @@ const FINAL_OPERATIONS = Object.freeze({
15
17
  pages: Object.freeze({ checkout: 'listPageCheckout', commit: 'updatePageCommit', contentType: 'text/html; charset=utf-8' }),
16
18
  navigations: Object.freeze({ checkout: 'listNavigationCheckout', commit: 'updateNavigationCommit', contentType: 'text/html; charset=utf-8' }),
17
19
  docs: Object.freeze({ checkout: 'listContentArticleCheckout', commit: 'updateContentArticleCommit', contentType: 'text/html; charset=utf-8', jsonCommit: true }),
20
+ services: Object.freeze({
21
+ checkout: 'listCustomServiceDraft',
22
+ commit: 'updateCustomServiceDraft',
23
+ contentType: 'text/plain; charset=utf-8',
24
+ jsonCommit: true,
25
+ commitField: 'source',
26
+ fileExtension: '.go',
27
+ sdk: 'listCustomServiceSdk',
28
+ }),
18
29
  });
19
30
 
31
+ function sha256Utf8(value) {
32
+ return crypto.createHash('sha256').update(String(value), 'utf8').digest('hex');
33
+ }
34
+
20
35
  function isObject(value) {
21
36
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
22
37
  }
@@ -184,29 +199,126 @@ async function finalMetadata(config, resourceType, resourceId, session, options
184
199
  if (!response.ok) throw await errorForResponse(response, config.token || config.sat);
185
200
  const raw = await readBounded(response, MAX_CHECKOUT_JSON_BYTES, true);
186
201
  const value = parseJsonText(raw, 'DraftGo checkout endpoint');
202
+ if (type === 'services' && typeof value.source !== 'string') {
203
+ throw new WorktreeError('INVALID_BACKEND_RESPONSE', 'DraftGo service draft did not include source text.');
204
+ }
187
205
  const version = firstValue(value.content_version, value.revision);
188
- const content = typeof value.content === 'string' ? value.content : null;
206
+ const sourceContent = type === 'services' ? value.source : null;
207
+ const content = sourceContent != null
208
+ ? sourceContent
209
+ : (typeof value.content === 'string' ? value.content : null);
189
210
  const contentURL = firstValue(value.content_url, checkoutURL);
190
- const contentType = type === 'docs' ? spec.contentType : spec.contentType;
211
+ const contentType = spec.contentType;
191
212
  const size = content == null ? null : Buffer.byteLength(content, 'utf8');
213
+ const contentHash = sourceContent != null ? sha256Utf8(sourceContent) : value.content_hash;
214
+ const usesRevision = type === 'docs' || type === 'services';
192
215
  return normalizeMetadata(config, type, resourceId, {
193
216
  resource_type: type,
194
217
  resource_id: String(resourceId),
195
218
  title: value.title || '', route: value.route || null, code: value.code || null, slug: value.slug || null,
196
219
  content_type: contentType,
197
- file_extension: '.html',
220
+ file_extension: spec.fileExtension || '.html',
198
221
  content_size: size,
199
- content_hash: value.content_hash,
200
- ...(type === 'docs' ? { base_revision: version } : { base_version: version }),
201
- etag: value.content_hash,
222
+ content_hash: contentHash,
223
+ ...(usesRevision ? { base_revision: version } : { base_version: version }),
224
+ etag: contentHash,
202
225
  download_url: contentURL,
203
226
  commit_url: operationPath(config, commitOperation.path, resourceId),
204
227
  commit_method: String(commitOperation.method || 'PUT').toUpperCase(),
205
228
  inline_content: content,
206
229
  json_commit: spec.jsonCommit === true,
230
+ commit_field: spec.commitField || null,
207
231
  });
208
232
  }
209
233
 
234
+ function sdkRelativePath(value) {
235
+ const relative = String(value || '').replace(/\\/g, '/').replace(/^\/+/, '');
236
+ if (!relative || relative.includes('\0') || relative.split('/').some((part) => part === '' || part === '.' || part === '..')) {
237
+ throw new WorktreeError('UNSAFE_SDK_PATH', 'DraftGo SDK bundle included an unsafe relative path.', { path: value });
238
+ }
239
+ return relative;
240
+ }
241
+
242
+ async function syncSDK(config, session, projectDir, options = {}) {
243
+ const { registryRevision, describeOperation } = require('../commands/api');
244
+ const { TOOL_NAMES, openToolSession, callStructured } = require('../mcp/tools');
245
+ const spec = FINAL_OPERATIONS.services;
246
+ const sdkDir = path.join(projectDir, '.draftgo', 'worktree', 'sdk');
247
+ let apiSession;
248
+ try {
249
+ apiSession = session && session.names && session.names[TOOL_NAMES.apiCall]
250
+ ? session
251
+ : await openToolSession(config, [
252
+ TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall,
253
+ ], {
254
+ ...options,
255
+ client: (session && session.client) || options.client,
256
+ tools: (session && session.tools) || options.tools,
257
+ clientInitialized: true,
258
+ });
259
+ } catch (error) {
260
+ throw new WorktreeError(
261
+ 'SDK_CHECKOUT_FAILED',
262
+ `Unable to open an API session for the Go SDK bundle: ${error.message}`,
263
+ );
264
+ }
265
+ let revision;
266
+ let described;
267
+ try {
268
+ revision = await registryRevision(apiSession, spec.sdk);
269
+ described = await describeOperation(projectDir || process.cwd(), config, apiSession, spec.sdk, revision);
270
+ } catch (error) {
271
+ throw new WorktreeError(
272
+ 'SDK_CHECKOUT_FAILED',
273
+ `Unable to describe the Go SDK bundle (${spec.sdk}): ${error.message}`,
274
+ );
275
+ }
276
+ const operation = described && described.operation || {};
277
+ const invoke = await callStructured(apiSession, TOOL_NAMES.apiCall, {
278
+ operation_id: spec.sdk,
279
+ registry_revision: revision,
280
+ }, options);
281
+ const workflow = invoke && invoke.workflow || {};
282
+ if (workflow.required !== true || workflow.workflow !== 'checkout_commit') {
283
+ throw new WorktreeError('INVALID_WORKFLOW_DESCRIPTOR', 'DraftGo did not return a checkout/commit workflow descriptor for the Go SDK bundle.');
284
+ }
285
+ const sdkURL = operationPath(config, operation.path || workflow.path, '');
286
+ const response = await (options.fetch || fetch)(sdkURL, {
287
+ method: String(operation.method || 'GET').toUpperCase(),
288
+ headers: authHeaders(config, { Accept: 'application/json' }),
289
+ signal: options.signal,
290
+ redirect: 'error',
291
+ });
292
+ if (!response.ok) {
293
+ const httpError = await errorForResponse(response, config.token || config.sat);
294
+ throw new WorktreeError(
295
+ 'SDK_CHECKOUT_FAILED',
296
+ `Unable to download the Go SDK bundle: ${httpError.message}`,
297
+ { status: httpError.status, code: httpError.code },
298
+ );
299
+ }
300
+ const raw = await readBounded(response, MAX_CHECKOUT_JSON_BYTES, true);
301
+ const value = parseJsonText(raw, 'DraftGo SDK endpoint');
302
+ const files = isObject(value.files) ? value.files : null;
303
+ if (!files || !Object.keys(files).length) {
304
+ throw new WorktreeError('SDK_CHECKOUT_FAILED', 'DraftGo SDK bundle did not include any files.');
305
+ }
306
+ await fs.promises.rm(sdkDir, { recursive: true, force: true });
307
+ await fs.promises.mkdir(sdkDir, { recursive: true });
308
+ for (const [relative, content] of Object.entries(files)) {
309
+ const safe = sdkRelativePath(relative);
310
+ const destination = path.join(sdkDir, ...safe.split('/'));
311
+ if (typeof content !== 'string') {
312
+ throw new WorktreeError('SDK_CHECKOUT_FAILED', `DraftGo SDK file ${safe} is not text.`);
313
+ }
314
+ await fs.promises.mkdir(path.dirname(destination), { recursive: true });
315
+ await fs.promises.writeFile(destination, content, 'utf8');
316
+ }
317
+ const contentHash = firstValue(value.content_hash, sha256Utf8(JSON.stringify(files)));
318
+ await fs.promises.writeFile(path.join(sdkDir, '.hash'), `${contentHash}\n`, 'utf8');
319
+ return { directory: sdkDir, content_hash: contentHash };
320
+ }
321
+
210
322
  function normalizeUrl(config, value, purpose) {
211
323
  if (!value) return null;
212
324
  let url;
@@ -281,8 +393,16 @@ function normalizeMetadata(config, resourceType, resourceId, payload) {
281
393
 
282
394
  async function resolveMetadata(config, resourceType, resourceId, options = {}) {
283
395
  if (options.metadata) return normalizeMetadata(config, resourceType, resourceId, options.metadata);
396
+ const type = canonicalResourceType(resourceType);
284
397
  const client = options.client || new DraftGoMcpClient(config);
285
398
  if (!options.clientInitialized) await client.initialize(options);
399
+ if (type === 'services') {
400
+ const { TOOL_NAMES, openToolSession } = require('../mcp/tools');
401
+ const session = await openToolSession(config, [
402
+ TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall,
403
+ ], { ...options, client });
404
+ return finalMetadata(config, type, resourceId, session, options);
405
+ }
286
406
  let toolName = METADATA_TOOL;
287
407
  if (typeof client.listAllTools === 'function') {
288
408
  const tools = options.tools || await client.listAllTools(options);
@@ -382,21 +502,29 @@ async function commit(config, metadata, localPath, current, options = {}) {
382
502
  'Content-Length': String(current.size),
383
503
  'X-Content-SHA256': current.hash,
384
504
  });
385
- if (metadata.etag) headers['If-Match'] = String(metadata.etag);
386
- else if (metadata.base_version != null) headers['If-Match'] = String(metadata.base_version);
387
- else if (metadata.base_revision != null) headers['If-Match'] = String(metadata.base_revision);
388
- if (metadata.base_version != null) headers['DraftGo-Base-Version'] = String(metadata.base_version);
389
- if (metadata.base_revision != null) headers['DraftGo-Base-Revision'] = String(metadata.base_revision);
390
-
391
505
  let body;
392
506
  let duplex;
393
- if (metadata.raw && metadata.raw.json_commit === true) {
394
- const content = fs.readFileSync(localPath, 'utf8');
395
- body = JSON.stringify({
396
- content,
397
- ...(metadata.base_revision != null ? { base_revision: metadata.base_revision } : {}),
398
- ...(metadata.base_revision == null && metadata.content_hash ? { base_hash: metadata.content_hash } : {}),
399
- });
507
+ const raw = metadata.raw && typeof metadata.raw === 'object' ? metadata.raw : {};
508
+ const commitField = raw.commitField || raw.commit_field || null;
509
+ if (commitField !== 'source') {
510
+ if (metadata.etag) headers['If-Match'] = String(metadata.etag);
511
+ else if (metadata.base_version != null) headers['If-Match'] = String(metadata.base_version);
512
+ else if (metadata.base_revision != null) headers['If-Match'] = String(metadata.base_revision);
513
+ }
514
+ if (metadata.base_version != null) headers['DraftGo-Base-Version'] = String(metadata.base_version);
515
+ if (metadata.base_revision != null) headers['DraftGo-Base-Revision'] = String(metadata.base_revision);
516
+ if (raw.json_commit === true) {
517
+ const fileContents = fs.readFileSync(localPath, 'utf8');
518
+ body = JSON.stringify(commitField === 'source'
519
+ ? {
520
+ source: fileContents,
521
+ ...(metadata.base_revision != null ? { base_revision: metadata.base_revision } : {}),
522
+ }
523
+ : {
524
+ content: fileContents,
525
+ ...(metadata.base_revision != null ? { base_revision: metadata.base_revision } : {}),
526
+ ...(metadata.base_revision == null && metadata.content_hash ? { base_hash: metadata.content_hash } : {}),
527
+ });
400
528
  headers['Content-Type'] = 'application/json';
401
529
  headers['Content-Length'] = String(Buffer.byteLength(body, 'utf8'));
402
530
  } else {
@@ -413,8 +541,14 @@ async function commit(config, metadata, localPath, current, options = {}) {
413
541
  });
414
542
  if (!response.ok) throw await errorForResponse(response, config.token || config.sat);
415
543
  const text = await readBounded(response, MAX_JSON_BYTES, true);
416
- if (!text.trim()) return {};
417
- return parseJsonText(text, 'DraftGo commit endpoint');
544
+ if (!text.trim()) {
545
+ return commitField === 'source' ? { content_hash: current.hash } : {};
546
+ }
547
+ const payload = parseJsonText(text, 'DraftGo commit endpoint');
548
+ if (commitField === 'source') {
549
+ return { ...payload, content_hash: current.hash };
550
+ }
551
+ return payload;
418
552
  }
419
553
 
420
554
  function nodeReadable(response) {
@@ -424,6 +558,7 @@ function nodeReadable(response) {
424
558
 
425
559
  module.exports = {
426
560
  METADATA_TOOL,
561
+ FINAL_OPERATIONS,
427
562
  readBounded,
428
563
  MAX_CHECKOUT_JSON_BYTES,
429
564
  toolNameMatches,
@@ -433,6 +568,7 @@ module.exports = {
433
568
  resolveMetadata,
434
569
  download,
435
570
  commit,
571
+ syncSDK,
436
572
  nodeReadable,
437
573
  errorForResponse,
438
574
  };
@@ -163,6 +163,9 @@ async function checkoutResourcesUnlocked(projectDir, resourceType, resourceIds,
163
163
  return entry;
164
164
  }, { ...options, settled: true });
165
165
  const results = settled.filter((item) => item.status === 'fulfilled').map((item) => item.value);
166
+ if (canonical === 'services' && results.length && typeof backend.syncSDK === 'function') {
167
+ await backend.syncSDK(config, session, projectDir, { ...options, ...session, projectDir });
168
+ }
166
169
  for (const entry of results) manifest.entries[entryKey(canonical, entry.resource_id)] = entry;
167
170
  if (results.length) await saveManifest(projectDir, manifest);
168
171
  const failures = settled.filter((item) => item.status === 'rejected');
@@ -7,6 +7,7 @@ const RESOURCE_TYPES = Object.freeze({
7
7
  pages: Object.freeze({ directory: 'pages', prefix: 'page' }),
8
8
  navigations: Object.freeze({ directory: 'navigations', prefix: 'nav' }),
9
9
  docs: Object.freeze({ directory: 'docs', prefix: 'article' }),
10
+ services: Object.freeze({ directory: 'services', prefix: 'service' }),
10
11
  });
11
12
 
12
13
  const TYPE_ALIASES = new Map([
@@ -20,6 +21,8 @@ const TYPE_ALIASES = new Map([
20
21
  ['article', 'docs'],
21
22
  ['articles', 'docs'],
22
23
  ['docs/articles', 'docs'],
24
+ ['service', 'services'],
25
+ ['services', 'services'],
23
26
  ]);
24
27
 
25
28
  const CONTENT_EXTENSIONS = new Map([
@@ -28,6 +31,7 @@ const CONTENT_EXTENSIONS = new Map([
28
31
  ['text/markdown', '.md'],
29
32
  ['text/x-markdown', '.md'],
30
33
  ['text/plain', '.txt'],
34
+ ['text/x-go', '.go'],
31
35
  ]);
32
36
 
33
37
  function canonicalResourceType(value) {
@@ -36,7 +40,7 @@ function canonicalResourceType(value) {
36
40
  if (!canonical) {
37
41
  throw new WorktreeError(
38
42
  'UNSUPPORTED_RESOURCE_TYPE',
39
- `Unsupported checkout resource type: ${value}. Expected pages, nav, or docs.`,
43
+ `Unsupported checkout resource type: ${value}. Expected pages, nav, docs, or services.`,
40
44
  { resource_type: value }
41
45
  );
42
46
  }
@@ -48,25 +52,25 @@ function mediaType(contentType) {
48
52
  }
49
53
 
50
54
  function normalizeExtension(contentType, backendExtension) {
55
+ if (backendExtension != null && backendExtension !== '') {
56
+ let extension = String(backendExtension).trim();
57
+ if (!extension.startsWith('.')) extension = `.${extension}`;
58
+ if (!/^\.[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/.test(extension)) {
59
+ throw new WorktreeError(
60
+ 'UNSAFE_FILE_EXTENSION',
61
+ 'The backend returned an unsafe checkout file extension.',
62
+ { file_extension: backendExtension }
63
+ );
64
+ }
65
+ return extension.toLowerCase();
66
+ }
51
67
  const known = CONTENT_EXTENSIONS.get(mediaType(contentType));
52
68
  if (known) return known;
53
- if (backendExtension == null || backendExtension === '') {
54
- throw new WorktreeError(
55
- 'MISSING_FILE_EXTENSION',
56
- 'DraftGo must provide a safe file extension for this content type.',
57
- { content_type: contentType },
58
- );
59
- }
60
- let extension = String(backendExtension).trim();
61
- if (!extension.startsWith('.')) extension = `.${extension}`;
62
- if (!/^\.[A-Za-z0-9][A-Za-z0-9._-]{0,15}$/.test(extension)) {
63
- throw new WorktreeError(
64
- 'UNSAFE_FILE_EXTENSION',
65
- 'The backend returned an unsafe checkout file extension.',
66
- { file_extension: backendExtension }
67
- );
68
- }
69
- return extension.toLowerCase();
69
+ throw new WorktreeError(
70
+ 'MISSING_FILE_EXTENSION',
71
+ 'DraftGo must provide a safe file extension for this content type.',
72
+ { content_type: contentType },
73
+ );
70
74
  }
71
75
 
72
76
  function safeIdSegment(value) {