draftgo-cli 1.0.5 → 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.
@@ -1,15 +1,62 @@
1
1
  'use strict';
2
2
 
3
3
  const log = require('../logger');
4
+ const { loadProjectConfig } = require('../projectConfig');
5
+ const { TOOL_NAMES, openToolSession } = require('../mcp/tools');
6
+ const { canonicalResourceType } = require('../worktree/types');
4
7
  const { checkoutResources } = require('../worktree');
8
+ const map = require('./map');
9
+
10
+ const USAGE = 'Usage: draftgo checkout <pages|nav|docs|services> <id...>';
11
+
12
+ async function resolveUniqueHit(projectDir, resourceType, flags) {
13
+ const canonical = canonicalResourceType(resourceType);
14
+ if (canonical === 'services') {
15
+ log.err('Checkout --route and --title are not supported for services.');
16
+ return null;
17
+ }
18
+ const config = loadProjectConfig(projectDir);
19
+ const resourceTypes = [map.normalizeMapResourceType(resourceType)];
20
+ const searching = flags.route != null || flags.title != null;
21
+ let remote;
22
+ try {
23
+ const session = await openToolSession(config, [searching ? TOOL_NAMES.resourceSearch : TOOL_NAMES.resourceList]);
24
+ remote = await map.listMapResources(session, resourceTypes, flags);
25
+ } catch (error) {
26
+ if (!error || error.code !== 'MCP_TOOL_UNAVAILABLE') throw error;
27
+ const session = await openToolSession(config, [TOOL_NAMES.apiSearch, TOOL_NAMES.apiDescribe, TOOL_NAMES.apiCall]);
28
+ remote = await map.listRegistryResources(projectDir, config, session, resourceTypes, flags);
29
+ }
30
+ const matches = (remote && remote.resources || []).filter((resource) => map.resourceMatches(resource, flags));
31
+ if (matches.length === 0) {
32
+ log.err(`No ${canonical} matched the given --route/--title.`);
33
+ return null;
34
+ }
35
+ if (matches.length !== 1) {
36
+ log.err(`Checkout --route/--title matched ${matches.length} ${canonical}; require exactly one.`);
37
+ return null;
38
+ }
39
+ return String(matches[0].resource_id);
40
+ }
5
41
 
6
42
  async function checkout(projectDir, positional, flags = {}) {
7
43
  const [resourceType, ...ids] = positional;
8
- if (!resourceType || !ids.length) {
9
- log.err('Usage: draftgo checkout <pages|nav|docs> <id...>');
44
+ const selecting = flags.route != null || flags.title != null;
45
+ if (!resourceType || (!ids.length && !selecting)) {
46
+ log.err(USAGE);
10
47
  return 1;
11
48
  }
12
- const results = await checkoutResources(projectDir, resourceType, ids, { force: Boolean(flags.force) });
49
+ let resourceIds = ids;
50
+ if (selecting) {
51
+ if (ids.length) {
52
+ log.err('Checkout --route/--title cannot be combined with resource ids.');
53
+ return 1;
54
+ }
55
+ const resourceId = await resolveUniqueHit(projectDir, resourceType, flags);
56
+ if (!resourceId) return 1;
57
+ resourceIds = [resourceId];
58
+ }
59
+ const results = await checkoutResources(projectDir, resourceType, resourceIds, { force: Boolean(flags.force) });
13
60
  if (flags.output === 'json') console.log(JSON.stringify(results, null, 2));
14
61
  else for (const entry of results) log.ok(`Checked out ${entry.resource_type} ${entry.resource_id} -> ${entry.local_path}`);
15
62
  return 0;
@@ -6,7 +6,7 @@ const { commitResources } = require('../worktree');
6
6
  async function commit(projectDir, positional, flags = {}) {
7
7
  const [resourceType, ...ids] = positional;
8
8
  if (!resourceType || !ids.length) {
9
- log.err('Usage: draftgo commit <pages|nav|docs> <id...>');
9
+ log.err('Usage: draftgo commit <pages|nav|docs|services> <id...>');
10
10
  return 1;
11
11
  }
12
12
  const streamed = [];
@@ -15,7 +15,7 @@ function printRecord(record) {
15
15
  async function conflict(projectDir, positional, flags = {}) {
16
16
  const [action, resourceType, resourceId] = positional;
17
17
  if (!['show', 'resolve'].includes(action) || !resourceType || !resourceId) {
18
- log.err('Usage: draftgo conflict <show|resolve> <pages|nav|docs> <id>');
18
+ log.err('Usage: draftgo conflict <show|resolve> <pages|nav|docs|services> <id>');
19
19
  return 1;
20
20
  }
21
21
  const record = action === 'show'
@@ -8,7 +8,7 @@ const { absolutePath } = require('../worktree/manifest');
8
8
  function diff(projectDir, positional, flags = {}) {
9
9
  const [resourceType, resourceId] = positional;
10
10
  if (!resourceType || !resourceId) {
11
- log.err('Usage: draftgo diff <pages|nav|docs> <id>');
11
+ log.err('Usage: draftgo diff <pages|nav|docs|services> <id>');
12
12
  return 1;
13
13
  }
14
14
  const result = diffResource(projectDir, resourceType, resourceId);
@@ -18,7 +18,7 @@ Tailwind CSS 4, and the DraftGo built-in component library (draftgo/*).
18
18
  Provider brands and shared icons are served from the local /assets/providers and
19
19
  /assets/icons directories; the Page runtime does not require third-party CDNs.
20
20
  System MCP handles live discovery and structured resources; complete page,
21
- navigation, and document bodies use checkout/commit outside MCP context.
21
+ navigation, document, and Go source bodies use checkout/commit outside MCP context.
22
22
  The installed Skill routes agents to task-specific References and MCP tools.
23
23
 
24
24
  Usage:
@@ -113,7 +113,7 @@ Usage:
113
113
  draftgo -h | --help Show this help.
114
114
 
115
115
  Resource types:
116
- pages | nav/navigations | docs/articles
116
+ pages | nav/navigations | docs/articles | services
117
117
 
118
118
  Important flags:
119
119
  --project <dir> Operate on <dir> instead of the current directory.
@@ -134,8 +134,8 @@ Important flags:
134
134
  --output json Print machine-readable output where supported.
135
135
  --type <type> (map) pages | nav/navigations | docs/articles;
136
136
  (capabilities) alias for a Registry module filter.
137
- --route <path> (map) Select resources with this exact route.
138
- --title <title> (map) Select resources with this exact title.
137
+ --route <path> (map/checkout) Select resources with this exact route.
138
+ --title <title> (map/checkout) Select resources with this exact title.
139
139
  --limit <1-100> (map) Maximum resources returned per type/page;
140
140
  defaults to 20.
141
141
  --cursor <cursor> (map) Continue one typed map result page.
@@ -6,7 +6,7 @@ const { reconcileResources } = require('../worktree');
6
6
  async function reconcile(projectDir, positional, flags = {}) {
7
7
  const [resourceType, ...ids] = positional;
8
8
  if (!resourceType || !ids.length) {
9
- log.err('Usage: draftgo reconcile <pages|nav|docs> <id...>');
9
+ log.err('Usage: draftgo reconcile <pages|nav|docs|services> <id...>');
10
10
  return 1;
11
11
  }
12
12
  const results = await reconcileResources(projectDir, resourceType, ids);
@@ -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) {