@stoneforge/quarry 1.13.0 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/api/quarry-api.d.ts +9 -1
  2. package/dist/api/quarry-api.d.ts.map +1 -1
  3. package/dist/api/quarry-api.js +21 -2
  4. package/dist/api/quarry-api.js.map +1 -1
  5. package/dist/api/types.d.ts +8 -1
  6. package/dist/api/types.d.ts.map +1 -1
  7. package/dist/api/types.js.map +1 -1
  8. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -1
  9. package/dist/cli/commands/auto-link-helper.js +1 -0
  10. package/dist/cli/commands/auto-link-helper.js.map +1 -1
  11. package/dist/cli/commands/crud.d.ts +2 -0
  12. package/dist/cli/commands/crud.d.ts.map +1 -1
  13. package/dist/cli/commands/crud.js +100 -10
  14. package/dist/cli/commands/crud.js.map +1 -1
  15. package/dist/cli/commands/docs.js +2 -2
  16. package/dist/cli/commands/docs.js.map +1 -1
  17. package/dist/cli/commands/document.js +1 -1
  18. package/dist/cli/commands/document.js.map +1 -1
  19. package/dist/cli/commands/entity.js +1 -1
  20. package/dist/cli/commands/entity.js.map +1 -1
  21. package/dist/cli/commands/external-sync.d.ts +6 -5
  22. package/dist/cli/commands/external-sync.d.ts.map +1 -1
  23. package/dist/cli/commands/external-sync.js +1032 -180
  24. package/dist/cli/commands/external-sync.js.map +1 -1
  25. package/dist/cli/commands/library.js +1 -1
  26. package/dist/cli/commands/library.js.map +1 -1
  27. package/dist/cli/commands/message.js +2 -2
  28. package/dist/cli/commands/message.js.map +1 -1
  29. package/dist/cli/commands/serve.d.ts.map +1 -1
  30. package/dist/cli/commands/serve.js +2 -0
  31. package/dist/cli/commands/serve.js.map +1 -1
  32. package/dist/cli/commands/task.d.ts.map +1 -1
  33. package/dist/cli/commands/task.js +7 -4
  34. package/dist/cli/commands/task.js.map +1 -1
  35. package/dist/cli/commands/team.js +1 -1
  36. package/dist/cli/commands/team.js.map +1 -1
  37. package/dist/cli/commands/workflow.js +1 -1
  38. package/dist/cli/commands/workflow.js.map +1 -1
  39. package/dist/cli/utils/progress.d.ts +30 -0
  40. package/dist/cli/utils/progress.d.ts.map +1 -0
  41. package/dist/cli/utils/progress.js +47 -0
  42. package/dist/cli/utils/progress.js.map +1 -0
  43. package/dist/config/config.d.ts.map +1 -1
  44. package/dist/config/config.js +6 -0
  45. package/dist/config/config.js.map +1 -1
  46. package/dist/config/defaults.d.ts.map +1 -1
  47. package/dist/config/defaults.js +1 -0
  48. package/dist/config/defaults.js.map +1 -1
  49. package/dist/config/file.d.ts.map +1 -1
  50. package/dist/config/file.js +10 -0
  51. package/dist/config/file.js.map +1 -1
  52. package/dist/config/merge.d.ts.map +1 -1
  53. package/dist/config/merge.js +7 -1
  54. package/dist/config/merge.js.map +1 -1
  55. package/dist/config/types.d.ts +7 -2
  56. package/dist/config/types.d.ts.map +1 -1
  57. package/dist/config/types.js +3 -0
  58. package/dist/config/types.js.map +1 -1
  59. package/dist/config/validation.d.ts.map +1 -1
  60. package/dist/config/validation.js +13 -0
  61. package/dist/config/validation.js.map +1 -1
  62. package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
  63. package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
  64. package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
  65. package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
  66. package/dist/external-sync/index.d.ts +3 -0
  67. package/dist/external-sync/index.d.ts.map +1 -1
  68. package/dist/external-sync/index.js +4 -0
  69. package/dist/external-sync/index.js.map +1 -1
  70. package/dist/external-sync/provider-registry.d.ts +7 -3
  71. package/dist/external-sync/provider-registry.d.ts.map +1 -1
  72. package/dist/external-sync/provider-registry.js +20 -3
  73. package/dist/external-sync/provider-registry.js.map +1 -1
  74. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
  75. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
  76. package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
  77. package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
  78. package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
  79. package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
  80. package/dist/external-sync/providers/folder/folder-fs.js +300 -0
  81. package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
  82. package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
  83. package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
  84. package/dist/external-sync/providers/folder/folder-provider.js +87 -0
  85. package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
  86. package/dist/external-sync/providers/folder/index.d.ts +11 -0
  87. package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
  88. package/dist/external-sync/providers/folder/index.js +13 -0
  89. package/dist/external-sync/providers/folder/index.js.map +1 -0
  90. package/dist/external-sync/providers/index.d.ts +4 -0
  91. package/dist/external-sync/providers/index.d.ts.map +1 -1
  92. package/dist/external-sync/providers/index.js +5 -0
  93. package/dist/external-sync/providers/index.js.map +1 -1
  94. package/dist/external-sync/providers/notion/index.d.ts +19 -0
  95. package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
  96. package/dist/external-sync/providers/notion/index.js +20 -0
  97. package/dist/external-sync/providers/notion/index.js.map +1 -0
  98. package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
  99. package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
  100. package/dist/external-sync/providers/notion/notion-api.js +492 -0
  101. package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
  102. package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
  103. package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
  104. package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
  105. package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
  106. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
  107. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
  108. package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
  109. package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
  110. package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
  111. package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
  112. package/dist/external-sync/providers/notion/notion-provider.js +159 -0
  113. package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
  114. package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
  115. package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
  116. package/dist/external-sync/providers/notion/notion-types.js +47 -0
  117. package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
  118. package/dist/external-sync/sync-engine.d.ts +70 -4
  119. package/dist/external-sync/sync-engine.d.ts.map +1 -1
  120. package/dist/external-sync/sync-engine.js +436 -67
  121. package/dist/external-sync/sync-engine.js.map +1 -1
  122. package/dist/server/index.js +8 -8
  123. package/dist/server/index.js.map +1 -1
  124. package/package.json +4 -12
@@ -3,11 +3,12 @@
3
3
  *
4
4
  * Provides CLI commands for external service synchronization:
5
5
  * - config: Show/set provider configuration (tokens, projects)
6
- * - link: Link a task to an external issue
7
- * - link-all: Bulk-link all unlinked tasks to external issues
8
- * - unlink: Remove external link from a task
9
- * - push: Push linked tasks to external service
10
- * - pull: Pull changes from external for linked tasks
6
+ * - link: Link a task/document to an external issue/page
7
+ * - link-all: Bulk-link all unlinked tasks or documents
8
+ * - unlink: Remove external link from a task/document
9
+ * - unlink-all: Bulk-remove external links from all linked elements
10
+ * - push: Push linked elements to external service
11
+ * - pull: Pull changes from external for linked elements
11
12
  * - sync: Bidirectional sync (push + pull)
12
13
  * - status: Show sync state overview
13
14
  * - resolve: Resolve sync conflicts
@@ -18,6 +19,52 @@ import { createAPI, resolveDatabasePath } from '../db.js';
18
19
  import { createStorage, initializeSchema } from '@stoneforge/storage';
19
20
  import { getValue, setValue, VALID_AUTO_LINK_PROVIDERS } from '../../config/index.js';
20
21
  import { taskToExternalTask, getFieldMapConfigForProvider } from '../../external-sync/adapters/task-sync-adapter.js';
22
+ import { isSyncableDocument, documentToExternalDocumentInput, resolveDocumentLibraryPath, resolveDocumentLibraryPaths } from '../../external-sync/adapters/document-sync-adapter.js';
23
+ import { createProgressBar, nullProgressBar } from '../utils/progress.js';
24
+ /**
25
+ * Providers that do not require an authentication token.
26
+ * These providers sync to local resources (e.g., filesystem directories)
27
+ * and can be used with just a project/path configured.
28
+ */
29
+ const TOKENLESS_PROVIDERS = new Set(['folder']);
30
+ /**
31
+ * Threshold for showing a warning when operating on a large set of elements.
32
+ * Operations targeting more than this number of elements will display a
33
+ * warning to inform the user the operation may take a significant amount of time.
34
+ */
35
+ const LARGE_SET_WARNING_THRESHOLD = 100;
36
+ // ============================================================================
37
+ // Type Flag Helper
38
+ // ============================================================================
39
+ /**
40
+ * Parse the --type flag value into an array of SyncAdapterType values.
41
+ *
42
+ * @param typeFlag - The --type flag value: 'task', 'document', or 'all'
43
+ * @returns Array of adapter types, or undefined for 'all' (no filter)
44
+ */
45
+ function parseTypeFlag(typeFlag) {
46
+ if (!typeFlag || typeFlag === 'all') {
47
+ return undefined; // No filter — process all types
48
+ }
49
+ if (typeFlag === 'task') {
50
+ return ['task'];
51
+ }
52
+ if (typeFlag === 'document') {
53
+ return ['document'];
54
+ }
55
+ return undefined; // Unknown value — treat as 'all'
56
+ }
57
+ /**
58
+ * Validate the --type flag value.
59
+ *
60
+ * @returns Error message if invalid, undefined if valid
61
+ */
62
+ function validateTypeFlag(typeFlag) {
63
+ if (!typeFlag || typeFlag === 'all' || typeFlag === 'task' || typeFlag === 'document') {
64
+ return undefined;
65
+ }
66
+ return `Invalid --type value "${typeFlag}". Must be one of: task, document, all`;
67
+ }
21
68
  // ============================================================================
22
69
  // Settings Service Helper
23
70
  // ============================================================================
@@ -37,6 +84,7 @@ async function createSettingsServiceFromOptions(options) {
37
84
  const backend = createStorage({ path: dbPath, create: true });
38
85
  initializeSchema(backend);
39
86
  // Dynamic import to handle optional peer dependency
87
+ // @ts-ignore — smithy is an optional runtime dependency, may not be installed
40
88
  const { createSettingsService } = await import('@stoneforge/smithy/services');
41
89
  return { settingsService: createSettingsService(backend) };
42
90
  }
@@ -84,6 +132,7 @@ async function configHandler(args, options) {
84
132
  const pollInterval = getValue('externalSync.pollInterval');
85
133
  const autoLink = getValue('externalSync.autoLink');
86
134
  const autoLinkProvider = getValue('externalSync.autoLinkProvider');
135
+ const autoLinkDocumentProvider = getValue('externalSync.autoLinkDocumentProvider');
87
136
  const configData = {
88
137
  enabled,
89
138
  conflictStrategy,
@@ -91,6 +140,7 @@ async function configHandler(args, options) {
91
140
  pollInterval,
92
141
  autoLink,
93
142
  autoLinkProvider,
143
+ autoLinkDocumentProvider,
94
144
  providers: Object.fromEntries(Object.entries(settings.providers).map(([name, config]) => [
95
145
  name,
96
146
  {
@@ -115,7 +165,8 @@ async function configHandler(args, options) {
115
165
  ` Default direction: ${defaultDirection}`,
116
166
  ` Poll interval: ${pollInterval}ms`,
117
167
  ` Auto-link: ${autoLink ? 'yes' : 'no'}`,
118
- ` Auto-link provider: ${autoLinkProvider ?? '(not set)'}`,
168
+ ` Auto-link provider (tasks): ${autoLinkProvider ?? '(not set)'}`,
169
+ ` Auto-link provider (docs): ${autoLinkDocumentProvider ?? '(not set)'}`,
119
170
  '',
120
171
  ];
121
172
  const providerEntries = Object.entries(settings.providers);
@@ -186,23 +237,46 @@ async function configSetProjectHandler(args, options) {
186
237
  // ============================================================================
187
238
  async function configSetAutoLinkHandler(args, options) {
188
239
  if (args.length < 1) {
189
- return failure('Usage: sf external-sync config set-auto-link <provider>', ExitCode.INVALID_ARGUMENTS);
240
+ return failure('Usage: sf external-sync config set-auto-link <provider> [--type task|document]', ExitCode.INVALID_ARGUMENTS);
190
241
  }
191
242
  const [provider] = args;
243
+ const typeFlag = options.type;
244
+ const linkType = typeFlag ?? 'task';
245
+ // Validate --type flag
246
+ if (linkType !== 'task' && linkType !== 'document') {
247
+ return failure(`Invalid type "${linkType}". Must be one of: task, document`, ExitCode.VALIDATION);
248
+ }
192
249
  // Validate provider name
193
250
  if (!VALID_AUTO_LINK_PROVIDERS.includes(provider)) {
194
251
  return failure(`Invalid provider "${provider}". Must be one of: ${VALID_AUTO_LINK_PROVIDERS.join(', ')}`, ExitCode.VALIDATION);
195
252
  }
196
- // Check if provider has a token configured
253
+ // Check if provider has a token configured (skip for tokenless providers like folder)
197
254
  const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
198
255
  let tokenWarning;
199
- if (!settingsError) {
256
+ if (!settingsError && !TOKENLESS_PROVIDERS.has(provider)) {
200
257
  const providerConfig = settingsService.getProviderConfig(provider);
201
258
  if (!providerConfig?.token) {
202
259
  tokenWarning = `Warning: Provider "${provider}" has no token configured. Auto-link will not work until a token is set. Run "sf external-sync config set-token ${provider} <token>".`;
203
260
  }
204
261
  }
205
- // Set both autoLink and autoLinkProvider
262
+ if (linkType === 'document') {
263
+ // Set document auto-link provider
264
+ setValue('externalSync.autoLinkDocumentProvider', provider);
265
+ const mode = getOutputMode(options);
266
+ if (mode === 'json') {
267
+ return success({ autoLinkDocumentProvider: provider, warning: tokenWarning });
268
+ }
269
+ if (mode === 'quiet') {
270
+ return success(provider);
271
+ }
272
+ const lines = [`Auto-link for documents enabled with provider "${provider}".`];
273
+ if (tokenWarning) {
274
+ lines.push('');
275
+ lines.push(tokenWarning);
276
+ }
277
+ return success({ autoLinkDocumentProvider: provider }, lines.join('\n'));
278
+ }
279
+ // Default: task auto-link
206
280
  setValue('externalSync.autoLink', true);
207
281
  setValue('externalSync.autoLinkProvider', provider);
208
282
  const mode = getOutputMode(options);
@@ -212,7 +286,7 @@ async function configSetAutoLinkHandler(args, options) {
212
286
  if (mode === 'quiet') {
213
287
  return success(provider);
214
288
  }
215
- const lines = [`Auto-link enabled with provider "${provider}".`];
289
+ const lines = [`Auto-link for tasks enabled with provider "${provider}".`];
216
290
  if (tokenWarning) {
217
291
  lines.push('');
218
292
  lines.push(tokenWarning);
@@ -223,12 +297,42 @@ async function configSetAutoLinkHandler(args, options) {
223
297
  // Config Disable-Auto-Link Command
224
298
  // ============================================================================
225
299
  async function configDisableAutoLinkHandler(_args, options) {
226
- // Set autoLink to false and clear autoLinkProvider
300
+ const typeFlag = options.type;
301
+ const linkType = typeFlag ?? 'all';
302
+ // Validate --type flag
303
+ if (linkType !== 'task' && linkType !== 'document' && linkType !== 'all') {
304
+ return failure(`Invalid type "${linkType}". Must be one of: task, document, all`, ExitCode.VALIDATION);
305
+ }
306
+ const mode = getOutputMode(options);
307
+ if (linkType === 'document') {
308
+ // Only clear document auto-link provider
309
+ setValue('externalSync.autoLinkDocumentProvider', undefined);
310
+ if (mode === 'json') {
311
+ return success({ autoLinkDocumentProvider: null });
312
+ }
313
+ if (mode === 'quiet') {
314
+ return success('disabled');
315
+ }
316
+ return success({ autoLinkDocumentProvider: null }, 'Auto-link for documents disabled.');
317
+ }
318
+ if (linkType === 'task') {
319
+ // Only clear task auto-link
320
+ setValue('externalSync.autoLink', false);
321
+ setValue('externalSync.autoLinkProvider', undefined);
322
+ if (mode === 'json') {
323
+ return success({ autoLink: false, autoLinkProvider: null });
324
+ }
325
+ if (mode === 'quiet') {
326
+ return success('disabled');
327
+ }
328
+ return success({ autoLink: false }, 'Auto-link for tasks disabled.');
329
+ }
330
+ // Default: disable all
227
331
  setValue('externalSync.autoLink', false);
228
332
  setValue('externalSync.autoLinkProvider', undefined);
229
- const mode = getOutputMode(options);
333
+ setValue('externalSync.autoLinkDocumentProvider', undefined);
230
334
  if (mode === 'json') {
231
- return success({ autoLink: false, autoLinkProvider: null });
335
+ return success({ autoLink: false, autoLinkProvider: null, autoLinkDocumentProvider: null });
232
336
  }
233
337
  if (mode === 'quiet') {
234
338
  return success('disabled');
@@ -242,35 +346,46 @@ const linkOptions = [
242
346
  description: 'Provider name (default: github)',
243
347
  hasValue: true,
244
348
  },
349
+ {
350
+ name: 'type',
351
+ short: 't',
352
+ description: 'Element type: task or document (default: task)',
353
+ hasValue: true,
354
+ defaultValue: 'task',
355
+ },
245
356
  ];
246
357
  async function linkHandler(args, options) {
358
+ const elementType = options.type ?? 'task';
359
+ if (elementType !== 'task' && elementType !== 'document') {
360
+ return failure(`Invalid --type value "${elementType}". Must be one of: task, document`, ExitCode.INVALID_ARGUMENTS);
361
+ }
247
362
  if (args.length < 2) {
248
- return failure('Usage: sf external-sync link <taskId> <url-or-issue-number>', ExitCode.INVALID_ARGUMENTS);
363
+ return failure(`Usage: sf external-sync link <${elementType}Id> <url-or-external-id>`, ExitCode.INVALID_ARGUMENTS);
249
364
  }
250
- const [taskId, urlOrNumber] = args;
365
+ const [elementId, urlOrExternalId] = args;
251
366
  const provider = options.provider ?? 'github';
252
367
  const { api, error } = createAPI(options);
253
368
  if (error) {
254
369
  return failure(error, ExitCode.GENERAL_ERROR);
255
370
  }
256
- // Resolve task
257
- let task;
371
+ // Resolve the element (task or document)
372
+ let element;
258
373
  try {
259
- task = await api.get(taskId);
374
+ element = await api.get(elementId);
260
375
  }
261
376
  catch {
262
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
377
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
263
378
  }
264
- if (!task) {
265
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
379
+ if (!element) {
380
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
266
381
  }
267
- if (task.type !== 'task') {
268
- return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
382
+ if (element.type !== elementType) {
383
+ return failure(`Element ${elementId} is not a ${elementType} (type: ${element.type})`, ExitCode.VALIDATION);
269
384
  }
270
- // Determine the external URL
385
+ // Determine the external URL and external ID
271
386
  let externalUrl;
272
387
  let externalId;
273
- if (/^\d+$/.test(urlOrNumber)) {
388
+ if (/^\d+$/.test(urlOrExternalId)) {
274
389
  // Bare number — construct URL from default project
275
390
  const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
276
391
  if (settingsError) {
@@ -279,27 +394,27 @@ async function linkHandler(args, options) {
279
394
  const providerConfig = settingsService.getProviderConfig(provider);
280
395
  if (!providerConfig?.defaultProject) {
281
396
  return failure(`No default project configured for provider "${provider}". ` +
282
- `Run "sf external-sync config set-project ${provider} <owner/repo>" first, ` +
397
+ `Run "sf external-sync config set-project ${provider} <project>" first, ` +
283
398
  `or provide a full URL.`, ExitCode.VALIDATION);
284
399
  }
285
- externalId = urlOrNumber;
400
+ externalId = urlOrExternalId;
286
401
  if (provider === 'github') {
287
402
  const baseUrl = providerConfig.apiBaseUrl
288
403
  ? providerConfig.apiBaseUrl.replace(/\/api\/v3\/?$/, '').replace(/\/$/, '')
289
404
  : 'https://github.com';
290
- externalUrl = `${baseUrl}/${providerConfig.defaultProject}/issues/${urlOrNumber}`;
405
+ externalUrl = `${baseUrl}/${providerConfig.defaultProject}/issues/${urlOrExternalId}`;
291
406
  }
292
407
  else {
293
408
  // Generic URL construction for other providers
294
- externalUrl = `${providerConfig.defaultProject}#${urlOrNumber}`;
409
+ externalUrl = `${providerConfig.defaultProject}#${urlOrExternalId}`;
295
410
  }
296
411
  }
297
412
  else {
298
- // Full URL provided
299
- externalUrl = urlOrNumber;
300
- // Extract issue number from URL
301
- const match = urlOrNumber.match(/\/(\d+)\/?$/);
302
- externalId = match ? match[1] : urlOrNumber;
413
+ // Full URL or external ID provided
414
+ externalUrl = urlOrExternalId;
415
+ // Extract issue number from URL if present, otherwise use the full value
416
+ const match = urlOrExternalId.match(/\/(\d+)\/?$/);
417
+ externalId = match ? match[1] : urlOrExternalId;
303
418
  }
304
419
  // Extract project from URL if possible
305
420
  let project;
@@ -307,18 +422,20 @@ async function linkHandler(args, options) {
307
422
  if (ghMatch) {
308
423
  project = ghMatch[1];
309
424
  }
310
- // Update task with externalRef and _externalSync metadata
425
+ // Determine the adapter type based on element type
426
+ const adapterType = elementType === 'document' ? 'document' : 'task';
427
+ // Update element with externalRef and _externalSync metadata
311
428
  const syncMetadata = {
312
429
  provider,
313
430
  project: project ?? '',
314
431
  externalId,
315
432
  url: externalUrl,
316
433
  direction: getValue('externalSync.defaultDirection'),
317
- adapterType: 'task',
434
+ adapterType,
318
435
  };
319
436
  try {
320
- const existingMetadata = (task.metadata ?? {});
321
- await api.update(taskId, {
437
+ const existingMetadata = (element.metadata ?? {});
438
+ await api.update(elementId, {
322
439
  externalRef: externalUrl,
323
440
  metadata: {
324
441
  ...existingMetadata,
@@ -328,81 +445,255 @@ async function linkHandler(args, options) {
328
445
  }
329
446
  catch (err) {
330
447
  const message = err instanceof Error ? err.message : String(err);
331
- return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
448
+ return failure(`Failed to update ${elementType}: ${message}`, ExitCode.GENERAL_ERROR);
332
449
  }
333
450
  const mode = getOutputMode(options);
334
451
  if (mode === 'json') {
335
- return success({ taskId, externalUrl, provider, externalId, project });
452
+ return success({ elementId, elementType, externalUrl, provider, externalId, project, adapterType });
336
453
  }
337
454
  if (mode === 'quiet') {
338
455
  return success(externalUrl);
339
456
  }
340
- return success({ taskId, externalUrl, provider, externalId }, `Linked task ${taskId} to ${externalUrl}`);
457
+ return success({ elementId, elementType, externalUrl, provider, externalId }, `Linked ${elementType} ${elementId} to ${externalUrl}`);
341
458
  }
342
459
  // ============================================================================
343
460
  // Unlink Command
344
461
  // ============================================================================
345
462
  async function unlinkHandler(args, options) {
346
463
  if (args.length < 1) {
347
- return failure('Usage: sf external-sync unlink <taskId>', ExitCode.INVALID_ARGUMENTS);
464
+ return failure('Usage: sf external-sync unlink <elementId>', ExitCode.INVALID_ARGUMENTS);
348
465
  }
349
- const [taskId] = args;
466
+ const [elementId] = args;
350
467
  const { api, error } = createAPI(options);
351
468
  if (error) {
352
469
  return failure(error, ExitCode.GENERAL_ERROR);
353
470
  }
354
- // Resolve task
355
- let task;
471
+ // Resolve element (task or document)
472
+ let element;
356
473
  try {
357
- task = await api.get(taskId);
474
+ element = await api.get(elementId);
358
475
  }
359
476
  catch {
360
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
477
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
361
478
  }
362
- if (!task) {
363
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
479
+ if (!element) {
480
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
364
481
  }
365
- if (task.type !== 'task') {
366
- return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
482
+ if (element.type !== 'task' && element.type !== 'document') {
483
+ return failure(`Element ${elementId} is not a task or document (type: ${element.type})`, ExitCode.VALIDATION);
367
484
  }
368
- if (!task.externalRef) {
369
- return failure(`Task ${taskId} is not linked to an external issue`, ExitCode.VALIDATION);
485
+ const hasExternalRef = element.externalRef;
486
+ const hasExternalSync = element.metadata?._externalSync;
487
+ if (!hasExternalRef && !hasExternalSync) {
488
+ return failure(`Element ${elementId} is not linked to an external service`, ExitCode.VALIDATION);
370
489
  }
371
490
  // Clear externalRef and _externalSync metadata
372
491
  try {
373
- const existingMetadata = (task.metadata ?? {});
492
+ const existingMetadata = (element.metadata ?? {});
374
493
  const { _externalSync: _, ...restMetadata } = existingMetadata;
375
- await api.update(taskId, {
494
+ await api.update(elementId, {
376
495
  externalRef: undefined,
377
496
  metadata: restMetadata,
378
497
  });
379
498
  }
380
499
  catch (err) {
381
500
  const message = err instanceof Error ? err.message : String(err);
382
- return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
501
+ return failure(`Failed to update element: ${message}`, ExitCode.GENERAL_ERROR);
383
502
  }
384
503
  const mode = getOutputMode(options);
385
504
  if (mode === 'json') {
386
- return success({ taskId, unlinked: true });
505
+ return success({ elementId, elementType: element.type, unlinked: true });
387
506
  }
388
507
  if (mode === 'quiet') {
389
- return success(taskId);
508
+ return success(elementId);
390
509
  }
391
- return success({ taskId, unlinked: true }, `Unlinked task ${taskId} from external issue`);
510
+ return success({ elementId, unlinked: true }, `Unlinked ${element.type} ${elementId} from external service`);
511
+ }
512
+ const unlinkAllOptions = [
513
+ {
514
+ name: 'provider',
515
+ short: 'p',
516
+ description: 'Only unlink elements linked to this provider',
517
+ hasValue: true,
518
+ },
519
+ {
520
+ name: 'type',
521
+ short: 't',
522
+ description: 'Element type to unlink: task, document, or all (default: all)',
523
+ hasValue: true,
524
+ defaultValue: 'all',
525
+ },
526
+ {
527
+ name: 'dry-run',
528
+ short: 'n',
529
+ description: 'Show what would be unlinked without making changes',
530
+ },
531
+ ];
532
+ async function unlinkAllHandler(_args, options) {
533
+ const providerFilter = options.provider;
534
+ const typeFilter = options.type ?? 'all';
535
+ const isDryRun = options['dry-run'] ?? false;
536
+ if (typeFilter !== 'task' && typeFilter !== 'document' && typeFilter !== 'all') {
537
+ return failure(`Invalid --type value "${typeFilter}". Must be one of: task, document, all`, ExitCode.INVALID_ARGUMENTS);
538
+ }
539
+ const { api, error: apiError } = createAPI(options);
540
+ if (apiError) {
541
+ return failure(apiError, ExitCode.GENERAL_ERROR);
542
+ }
543
+ // Gather linked elements based on type filter
544
+ const linkedElements = [];
545
+ const typesToQuery = [];
546
+ if (typeFilter === 'all' || typeFilter === 'task')
547
+ typesToQuery.push('task');
548
+ if (typeFilter === 'all' || typeFilter === 'document')
549
+ typesToQuery.push('document');
550
+ for (const elType of typesToQuery) {
551
+ try {
552
+ const results = await api.list({ type: elType });
553
+ for (const el of results) {
554
+ const metadata = (el.metadata ?? {});
555
+ const syncState = metadata._externalSync;
556
+ if (!syncState)
557
+ continue;
558
+ // Apply provider filter if specified
559
+ if (providerFilter && syncState.provider !== providerFilter)
560
+ continue;
561
+ linkedElements.push({
562
+ id: el.id,
563
+ type: elType,
564
+ title: el.title ?? '(untitled)',
565
+ provider: syncState.provider,
566
+ });
567
+ }
568
+ }
569
+ catch (err) {
570
+ const message = err instanceof Error ? err.message : String(err);
571
+ return failure(`Failed to list ${elType}s: ${message}`, ExitCode.GENERAL_ERROR);
572
+ }
573
+ }
574
+ const mode = getOutputMode(options);
575
+ if (linkedElements.length === 0) {
576
+ const result = { unlinked: 0, total: 0, dryRun: isDryRun };
577
+ if (mode === 'json') {
578
+ return success(result);
579
+ }
580
+ const providerHint = providerFilter ? ` linked to "${providerFilter}"` : '';
581
+ const typeHint = typeFilter !== 'all' ? ` of type "${typeFilter}"` : '';
582
+ return success(result, `No linked elements${typeHint}${providerHint} found.`);
583
+ }
584
+ // Dry run — just list elements that would be unlinked
585
+ if (isDryRun) {
586
+ const elementList = linkedElements.map((el) => ({
587
+ id: el.id,
588
+ type: el.type,
589
+ title: el.title,
590
+ provider: el.provider,
591
+ }));
592
+ const jsonResult = {
593
+ dryRun: true,
594
+ total: linkedElements.length,
595
+ elements: elementList,
596
+ };
597
+ if (providerFilter)
598
+ jsonResult.provider = providerFilter;
599
+ if (typeFilter !== 'all')
600
+ jsonResult.type = typeFilter;
601
+ if (mode === 'json') {
602
+ return success(jsonResult);
603
+ }
604
+ if (mode === 'quiet') {
605
+ return success(String(linkedElements.length));
606
+ }
607
+ const lines = [];
608
+ lines.push(`Dry run: ${linkedElements.length} element(s) would be unlinked`);
609
+ lines.push('');
610
+ for (const el of linkedElements) {
611
+ lines.push(` ${el.id} ${el.type.padEnd(10)} ${el.provider.padEnd(10)} ${el.title}`);
612
+ }
613
+ return success(jsonResult, lines.join('\n'));
614
+ }
615
+ // Actually unlink elements
616
+ let totalUnlinked = 0;
617
+ let totalFailed = 0;
618
+ const progressLines = [];
619
+ for (const el of linkedElements) {
620
+ try {
621
+ const element = await api.get(el.id);
622
+ if (!element) {
623
+ totalFailed++;
624
+ continue;
625
+ }
626
+ const existingMetadata = (element.metadata ?? {});
627
+ const { _externalSync: _, ...restMetadata } = existingMetadata;
628
+ await api.update(el.id, {
629
+ externalRef: undefined,
630
+ metadata: restMetadata,
631
+ });
632
+ totalUnlinked++;
633
+ if (mode !== 'json' && mode !== 'quiet') {
634
+ progressLines.push(` Unlinked ${el.type} ${el.id} (${el.provider}): ${el.title}`);
635
+ }
636
+ }
637
+ catch (err) {
638
+ totalFailed++;
639
+ if (mode !== 'json' && mode !== 'quiet') {
640
+ const message = err instanceof Error ? err.message : String(err);
641
+ progressLines.push(` Failed to unlink ${el.type} ${el.id}: ${message}`);
642
+ }
643
+ }
644
+ }
645
+ const result = {
646
+ unlinked: totalUnlinked,
647
+ failed: totalFailed,
648
+ total: linkedElements.length,
649
+ };
650
+ if (providerFilter)
651
+ result.provider = providerFilter;
652
+ if (typeFilter !== 'all')
653
+ result.type = typeFilter;
654
+ if (mode === 'json') {
655
+ return success(result);
656
+ }
657
+ if (mode === 'quiet') {
658
+ return success(String(totalUnlinked));
659
+ }
660
+ const lines = [...progressLines, ''];
661
+ const summaryParts = [`Unlinked ${totalUnlinked} element(s)`];
662
+ if (totalFailed > 0) {
663
+ summaryParts.push(`(${totalFailed} failed)`);
664
+ }
665
+ lines.push(summaryParts.join(' '));
666
+ return success(result, lines.join('\n'));
392
667
  }
393
668
  const pushOptions = [
394
669
  {
395
670
  name: 'all',
396
671
  short: 'a',
397
- description: 'Push all linked tasks',
672
+ description: 'Push all linked elements',
398
673
  },
399
674
  {
400
675
  name: 'force',
401
676
  short: 'f',
402
- description: 'Push all linked tasks regardless of whether they have changed',
677
+ description: 'Push all linked elements regardless of whether they have changed',
678
+ },
679
+ {
680
+ name: 'type',
681
+ short: 't',
682
+ description: 'Element type to push: task, document, or all (default: all)',
683
+ hasValue: true,
684
+ defaultValue: 'all',
685
+ },
686
+ {
687
+ name: 'no-library',
688
+ description: 'Include documents that are not in any library (excluded by default)',
403
689
  },
404
690
  ];
405
691
  async function pushHandler(args, options) {
692
+ // Validate --type flag
693
+ const typeError = validateTypeFlag(options.type);
694
+ if (typeError) {
695
+ return failure(typeError, ExitCode.INVALID_ARGUMENTS);
696
+ }
406
697
  const { api, error } = createAPI(options);
407
698
  if (error) {
408
699
  return failure(error, ExitCode.GENERAL_ERROR);
@@ -437,7 +728,23 @@ async function pushHandler(args, options) {
437
728
  })),
438
729
  });
439
730
  // Build push options
731
+ const adapterTypes = parseTypeFlag(options.type);
732
+ const mode = getOutputMode(options);
733
+ // Progress bar — created lazily once total is known (via onProgress callback)
734
+ let pushProgress = nullProgressBar;
735
+ const onProgress = mode === 'json' || mode === 'quiet'
736
+ ? undefined
737
+ : (current, total) => {
738
+ if (current === 0 && total > 0) {
739
+ // First call — create the progress bar now that we know the total
740
+ pushProgress = createProgressBar(total, 'Pushing');
741
+ }
742
+ pushProgress.update(current);
743
+ };
440
744
  const syncPushOptions = {};
745
+ if (adapterTypes) {
746
+ syncPushOptions.adapterTypes = adapterTypes;
747
+ }
441
748
  if (options.all) {
442
749
  syncPushOptions.all = true;
443
750
  }
@@ -445,14 +752,29 @@ async function pushHandler(args, options) {
445
752
  syncPushOptions.taskIds = args;
446
753
  }
447
754
  else {
448
- return failure('Usage: sf external-sync push [taskId...] or sf external-sync push --all', ExitCode.INVALID_ARGUMENTS);
755
+ return failure('Usage: sf external-sync push [elementId...] or sf external-sync push --all', ExitCode.INVALID_ARGUMENTS);
449
756
  }
450
757
  if (options.force) {
451
758
  syncPushOptions.force = true;
452
759
  }
760
+ if (options['no-library']) {
761
+ syncPushOptions.includeNoLibrary = true;
762
+ }
763
+ if (onProgress) {
764
+ syncPushOptions.onProgress = onProgress;
765
+ }
766
+ // Show warning for large element sets before processing begins
767
+ if (mode !== 'json' && mode !== 'quiet') {
768
+ syncPushOptions.onBeforeProcess = (count) => {
769
+ if (count > LARGE_SET_WARNING_THRESHOLD) {
770
+ process.stderr.write(`\nWarning: About to push ${count} elements. ` +
771
+ `This may take a significant amount of time for large element sets.\n\n`);
772
+ }
773
+ };
774
+ }
453
775
  try {
454
776
  const result = await engine.push(syncPushOptions);
455
- const mode = getOutputMode(options);
777
+ pushProgress.finish();
456
778
  const output = {
457
779
  success: result.success,
458
780
  pushed: result.pushed,
@@ -460,19 +782,26 @@ async function pushHandler(args, options) {
460
782
  errors: result.errors,
461
783
  conflicts: result.conflicts,
462
784
  };
785
+ if (result.noLibrarySkipped && result.noLibrarySkipped > 0) {
786
+ output.noLibrarySkipped = result.noLibrarySkipped;
787
+ }
463
788
  if (mode === 'json') {
464
789
  return success(output);
465
790
  }
466
791
  if (mode === 'quiet') {
467
792
  return success(String(result.pushed));
468
793
  }
794
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
469
795
  const lines = [
470
- `Push: ${result.pushed} task(s) pushed successfully`,
796
+ `Push: ${result.pushed} ${typeLabel} pushed successfully`,
471
797
  '',
472
798
  ];
473
799
  if (result.skipped > 0) {
474
800
  lines.push(`Skipped: ${result.skipped}`);
475
801
  }
802
+ if (result.noLibrarySkipped && result.noLibrarySkipped > 0) {
803
+ lines.push(`Skipped ${result.noLibrarySkipped} document(s) not in any library (use --no-library to include)`);
804
+ }
476
805
  if (result.errors.length > 0) {
477
806
  lines.push('');
478
807
  lines.push(`Errors (${result.errors.length}):`);
@@ -490,6 +819,7 @@ async function pushHandler(args, options) {
490
819
  return success(output, lines.join('\n'));
491
820
  }
492
821
  catch (err) {
822
+ pushProgress.finish();
493
823
  const message = err instanceof Error ? err.message : String(err);
494
824
  return failure(`Push failed: ${message}`, ExitCode.GENERAL_ERROR);
495
825
  }
@@ -506,8 +836,20 @@ const pullOptions = [
506
836
  short: 'd',
507
837
  description: 'Discover new issues not yet linked',
508
838
  },
839
+ {
840
+ name: 'type',
841
+ short: 't',
842
+ description: 'Element type to pull: task, document, or all (default: all)',
843
+ hasValue: true,
844
+ defaultValue: 'all',
845
+ },
509
846
  ];
510
847
  async function pullHandler(_args, options) {
848
+ // Validate --type flag
849
+ const typeError = validateTypeFlag(options.type);
850
+ if (typeError) {
851
+ return failure(typeError, ExitCode.INVALID_ARGUMENTS);
852
+ }
511
853
  const { api, error } = createAPI(options);
512
854
  if (error) {
513
855
  return failure(error, ExitCode.GENERAL_ERROR);
@@ -563,7 +905,11 @@ async function pullHandler(_args, options) {
563
905
  })),
564
906
  });
565
907
  // Build pull options — discover maps to 'all' to create local tasks for unlinked external issues
908
+ const adapterTypes = parseTypeFlag(options.type);
566
909
  const syncPullOptions = {};
910
+ if (adapterTypes) {
911
+ syncPullOptions.adapterTypes = adapterTypes;
912
+ }
567
913
  if (options.discover) {
568
914
  syncPullOptions.all = true;
569
915
  }
@@ -584,8 +930,9 @@ async function pullHandler(_args, options) {
584
930
  if (mode === 'quiet') {
585
931
  return success(String(result.pulled));
586
932
  }
933
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
587
934
  const lines = [
588
- `Pull: ${result.pulled} task(s) pulled successfully`,
935
+ `Pull: ${result.pulled} ${typeLabel} pulled successfully`,
589
936
  '',
590
937
  ];
591
938
  if (result.skipped > 0) {
@@ -622,8 +969,20 @@ const syncOptions = [
622
969
  short: 'n',
623
970
  description: 'Show what would change without making changes',
624
971
  },
972
+ {
973
+ name: 'type',
974
+ short: 't',
975
+ description: 'Element type to sync: task, document, or all (default: all)',
976
+ hasValue: true,
977
+ defaultValue: 'all',
978
+ },
625
979
  ];
626
980
  async function syncHandler(_args, options) {
981
+ // Validate --type flag
982
+ const typeError = validateTypeFlag(options.type);
983
+ if (typeError) {
984
+ return failure(typeError, ExitCode.INVALID_ARGUMENTS);
985
+ }
627
986
  const { api, error: apiError } = createAPI(options);
628
987
  if (apiError) {
629
988
  return failure(apiError, ExitCode.GENERAL_ERROR);
@@ -658,7 +1017,11 @@ async function syncHandler(_args, options) {
658
1017
  })),
659
1018
  });
660
1019
  // Build sync options
1020
+ const adapterTypes = parseTypeFlag(options.type);
661
1021
  const syncOpts = {};
1022
+ if (adapterTypes) {
1023
+ syncOpts.adapterTypes = adapterTypes;
1024
+ }
662
1025
  if (isDryRun) {
663
1026
  syncOpts.dryRun = true;
664
1027
  }
@@ -680,11 +1043,12 @@ async function syncHandler(_args, options) {
680
1043
  if (mode === 'quiet') {
681
1044
  return success(`${result.pushed}/${result.pulled}`);
682
1045
  }
1046
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
683
1047
  const lines = [
684
1048
  isDryRun ? 'Sync (dry run) - showing what would change' : 'Bidirectional Sync Complete',
685
1049
  '',
686
- ` Pushed: ${result.pushed} task(s)`,
687
- ` Pulled: ${result.pulled} task(s)`,
1050
+ ` Pushed: ${result.pushed} ${typeLabel}`,
1051
+ ` Pulled: ${result.pulled} ${typeLabel}`,
688
1052
  ];
689
1053
  if (result.skipped > 0) {
690
1054
  lines.push(` Skipped: ${result.skipped} (no changes)`);
@@ -724,19 +1088,21 @@ async function statusHandler(_args, options) {
724
1088
  }
725
1089
  const settings = settingsService.getExternalSyncSettings();
726
1090
  const enabled = getValue('externalSync.enabled');
727
- // Count linked tasks and check for conflicts
728
- let linkedCount = 0;
1091
+ // Count linked tasks and documents, and check for conflicts
1092
+ let linkedTaskCount = 0;
1093
+ let linkedDocCount = 0;
729
1094
  let conflictCount = 0;
730
- const providerCounts = {};
1095
+ const providerTaskCounts = {};
1096
+ const providerDocCounts = {};
731
1097
  try {
732
1098
  const allTasks = await api.list({ type: 'task' });
733
1099
  for (const t of allTasks) {
734
1100
  const task = t;
735
1101
  if (task.externalRef && task.metadata?._externalSync) {
736
- linkedCount++;
1102
+ linkedTaskCount++;
737
1103
  const syncMeta = task.metadata._externalSync;
738
1104
  const provider = syncMeta?.provider ?? 'unknown';
739
- providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;
1105
+ providerTaskCounts[provider] = (providerTaskCounts[provider] ?? 0) + 1;
740
1106
  }
741
1107
  if (task.tags?.includes('sync-conflict')) {
742
1108
  conflictCount++;
@@ -747,6 +1113,21 @@ async function statusHandler(_args, options) {
747
1113
  const message = err instanceof Error ? err.message : String(err);
748
1114
  return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
749
1115
  }
1116
+ try {
1117
+ const allDocs = await api.list({ type: 'document' });
1118
+ for (const d of allDocs) {
1119
+ const doc = d;
1120
+ if (doc.metadata?._externalSync) {
1121
+ linkedDocCount++;
1122
+ const syncMeta = doc.metadata._externalSync;
1123
+ const provider = syncMeta?.provider ?? 'unknown';
1124
+ providerDocCounts[provider] = (providerDocCounts[provider] ?? 0) + 1;
1125
+ }
1126
+ }
1127
+ }
1128
+ catch {
1129
+ // If listing documents fails, continue with task counts only
1130
+ }
750
1131
  // Build cursor info
751
1132
  const cursors = {};
752
1133
  for (const [key, value] of Object.entries(settings.syncCursors)) {
@@ -755,9 +1136,11 @@ async function statusHandler(_args, options) {
755
1136
  const mode = getOutputMode(options);
756
1137
  const statusData = {
757
1138
  enabled,
758
- linkedTaskCount: linkedCount,
1139
+ linkedTaskCount,
1140
+ linkedDocumentCount: linkedDocCount,
759
1141
  conflictCount,
760
- providerCounts,
1142
+ providerTaskCounts,
1143
+ providerDocumentCounts: providerDocCounts,
761
1144
  configuredProviders: Object.keys(settings.providers),
762
1145
  syncCursors: cursors,
763
1146
  pollIntervalMs: settings.pollIntervalMs,
@@ -767,13 +1150,14 @@ async function statusHandler(_args, options) {
767
1150
  return success(statusData);
768
1151
  }
769
1152
  if (mode === 'quiet') {
770
- return success(`${linkedCount}:${conflictCount}`);
1153
+ return success(`${linkedTaskCount}:${linkedDocCount}:${conflictCount}`);
771
1154
  }
772
1155
  const lines = [
773
1156
  'External Sync Status',
774
1157
  '',
775
1158
  ` Enabled: ${enabled ? 'yes' : 'no'}`,
776
- ` Linked tasks: ${linkedCount}`,
1159
+ ` Linked tasks: ${linkedTaskCount}`,
1160
+ ` Linked documents: ${linkedDocCount}`,
777
1161
  ` Pending conflicts: ${conflictCount}`,
778
1162
  ` Poll interval: ${settings.pollIntervalMs}ms`,
779
1163
  ` Default direction: ${settings.defaultDirection}`,
@@ -784,9 +1168,10 @@ async function statusHandler(_args, options) {
784
1168
  if (providerEntries.length > 0) {
785
1169
  lines.push(' Providers:');
786
1170
  for (const [name, config] of providerEntries) {
787
- const count = providerCounts[name] ?? 0;
1171
+ const taskCount = providerTaskCounts[name] ?? 0;
1172
+ const docCount = providerDocCounts[name] ?? 0;
788
1173
  const hasToken = config.token ? 'yes' : 'no';
789
- lines.push(` ${name}: ${count} linked task(s), token: ${hasToken}, project: ${config.defaultProject ?? '(not set)'}`);
1174
+ lines.push(` ${name}: ${taskCount} linked task(s), ${docCount} linked document(s), token: ${hasToken}, project: ${config.defaultProject ?? '(not set)'}`);
790
1175
  }
791
1176
  }
792
1177
  else {
@@ -818,9 +1203,9 @@ const resolveOptions = [
818
1203
  ];
819
1204
  async function resolveHandler(args, options) {
820
1205
  if (args.length < 1) {
821
- return failure('Usage: sf external-sync resolve <taskId> --keep local|remote', ExitCode.INVALID_ARGUMENTS);
1206
+ return failure('Usage: sf external-sync resolve <elementId> --keep local|remote', ExitCode.INVALID_ARGUMENTS);
822
1207
  }
823
- const [taskId] = args;
1208
+ const [elementId] = args;
824
1209
  const keep = options.keep;
825
1210
  if (!keep || (keep !== 'local' && keep !== 'remote')) {
826
1211
  return failure('The --keep flag is required and must be either "local" or "remote"', ExitCode.INVALID_ARGUMENTS);
@@ -829,27 +1214,28 @@ async function resolveHandler(args, options) {
829
1214
  if (error) {
830
1215
  return failure(error, ExitCode.GENERAL_ERROR);
831
1216
  }
832
- // Resolve task
833
- let task;
1217
+ // Resolve element (task or document)
1218
+ let element;
834
1219
  try {
835
- task = await api.get(taskId);
1220
+ element = await api.get(elementId);
836
1221
  }
837
1222
  catch {
838
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
1223
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
839
1224
  }
840
- if (!task) {
841
- return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
1225
+ if (!element) {
1226
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
842
1227
  }
843
- if (task.type !== 'task') {
844
- return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
1228
+ if (element.type !== 'task' && element.type !== 'document') {
1229
+ return failure(`Element ${elementId} is not a task or document (type: ${element.type})`, ExitCode.VALIDATION);
845
1230
  }
846
- if (!task.tags?.includes('sync-conflict')) {
847
- return failure(`Task ${taskId} does not have a sync conflict. Only tasks tagged with "sync-conflict" can be resolved.`, ExitCode.VALIDATION);
1231
+ const elementTags = element.tags;
1232
+ if (!elementTags?.includes('sync-conflict')) {
1233
+ return failure(`Element ${elementId} does not have a sync conflict. Only elements tagged with "sync-conflict" can be resolved.`, ExitCode.VALIDATION);
848
1234
  }
849
1235
  // Remove sync-conflict tag and update metadata
850
1236
  try {
851
- const newTags = (task.tags ?? []).filter((t) => t !== 'sync-conflict');
852
- const existingMetadata = (task.metadata ?? {});
1237
+ const newTags = (elementTags ?? []).filter((t) => t !== 'sync-conflict');
1238
+ const existingMetadata = (element.metadata ?? {});
853
1239
  const syncMeta = (existingMetadata._externalSync ?? {});
854
1240
  // Record resolution in metadata
855
1241
  const updatedSyncMeta = {
@@ -861,7 +1247,7 @@ async function resolveHandler(args, options) {
861
1247
  };
862
1248
  // Clear conflict data from metadata
863
1249
  const { _syncConflict: _, ...restMetadata } = existingMetadata;
864
- await api.update(taskId, {
1250
+ await api.update(elementId, {
865
1251
  tags: newTags,
866
1252
  metadata: {
867
1253
  ...restMetadata,
@@ -875,12 +1261,12 @@ async function resolveHandler(args, options) {
875
1261
  }
876
1262
  const mode = getOutputMode(options);
877
1263
  if (mode === 'json') {
878
- return success({ taskId, resolved: true, kept: keep });
1264
+ return success({ elementId, elementType: element.type, resolved: true, kept: keep });
879
1265
  }
880
1266
  if (mode === 'quiet') {
881
- return success(taskId);
1267
+ return success(elementId);
882
1268
  }
883
- return success({ taskId, resolved: true, kept: keep }, `Resolved sync conflict for task ${taskId} (kept: ${keep})`);
1269
+ return success({ elementId, resolved: true, kept: keep }, `Resolved sync conflict for ${element.type} ${elementId} (kept: ${keep})`);
884
1270
  }
885
1271
  const linkAllOptions = [
886
1272
  {
@@ -898,26 +1284,37 @@ const linkAllOptions = [
898
1284
  {
899
1285
  name: 'status',
900
1286
  short: 's',
901
- description: 'Only link tasks with this status (can be repeated)',
1287
+ description: 'Only link elements with this status (can be repeated)',
902
1288
  hasValue: true,
903
1289
  array: true,
904
1290
  },
905
1291
  {
906
1292
  name: 'dry-run',
907
1293
  short: 'n',
908
- description: 'List tasks that would be linked without creating external issues',
1294
+ description: 'List elements that would be linked without creating external issues/pages',
909
1295
  },
910
1296
  {
911
1297
  name: 'batch-size',
912
1298
  short: 'b',
913
- description: 'How many tasks to process concurrently (default: 10)',
1299
+ description: 'How many elements to process concurrently (default: 10)',
914
1300
  hasValue: true,
915
1301
  defaultValue: '10',
916
1302
  },
917
1303
  {
918
1304
  name: 'force',
919
1305
  short: 'f',
920
- description: 'Re-link tasks that are already linked to a different provider',
1306
+ description: 'Re-link elements that are already linked (including same provider)',
1307
+ },
1308
+ {
1309
+ name: 'type',
1310
+ short: 't',
1311
+ description: 'Element type: task or document (default: task)',
1312
+ hasValue: true,
1313
+ defaultValue: 'task',
1314
+ },
1315
+ {
1316
+ name: 'no-library',
1317
+ description: 'Include documents that are not in any library (excluded by default)',
921
1318
  },
922
1319
  ];
923
1320
  /**
@@ -981,17 +1378,28 @@ async function createProviderFromSettings(providerName, projectOverride, options
981
1378
  const backend = createStorage({ path: dbPath, create: true });
982
1379
  initializeSchema(backend);
983
1380
  // Dynamic import to handle optional peer dependency
1381
+ // @ts-ignore — smithy is an optional runtime dependency, may not be installed
984
1382
  const { createSettingsService } = await import('@stoneforge/smithy/services');
985
1383
  const settingsService = createSettingsService(backend);
986
1384
  const providerConfig = settingsService.getProviderConfig(providerName);
987
- if (!providerConfig?.token) {
1385
+ const isTokenless = TOKENLESS_PROVIDERS.has(providerName);
1386
+ // Token-free providers (e.g., folder) only need a config entry — no token required.
1387
+ // All other providers require both a config entry and a token.
1388
+ if (!providerConfig) {
1389
+ if (isTokenless) {
1390
+ return { error: `Provider "${providerName}" is not configured. Run "sf external-sync config set-project ${providerName} <path>" first.` };
1391
+ }
1392
+ return { error: `Provider "${providerName}" has no token configured. Run "sf external-sync config set-token ${providerName} <token>" first.` };
1393
+ }
1394
+ if (!isTokenless && !providerConfig.token) {
988
1395
  return { error: `Provider "${providerName}" has no token configured. Run "sf external-sync config set-token ${providerName} <token>" first.` };
989
1396
  }
990
- const project = projectOverride ?? providerConfig.defaultProject;
1397
+ const project = projectOverride ?? providerConfig?.defaultProject;
991
1398
  if (!project) {
992
1399
  return { error: `No project specified and provider "${providerName}" has no default project configured. Use --project or run "sf external-sync config set-project ${providerName} <project>" first.` };
993
1400
  }
994
1401
  let provider;
1402
+ // Token is guaranteed non-undefined for non-tokenless providers (validated above)
995
1403
  if (providerName === 'github') {
996
1404
  const { createGitHubProvider } = await import('../../external-sync/providers/github/index.js');
997
1405
  provider = createGitHubProvider({
@@ -1007,8 +1415,18 @@ async function createProviderFromSettings(providerName, projectOverride, options
1007
1415
  apiKey: providerConfig.token,
1008
1416
  });
1009
1417
  }
1418
+ else if (providerName === 'notion') {
1419
+ const { createNotionProvider } = await import('../../external-sync/providers/notion/index.js');
1420
+ provider = createNotionProvider({
1421
+ token: providerConfig.token,
1422
+ });
1423
+ }
1424
+ else if (providerName === 'folder') {
1425
+ const { createFolderProvider } = await import('../../external-sync/providers/folder/index.js');
1426
+ provider = createFolderProvider();
1427
+ }
1010
1428
  else {
1011
- return { error: `Unsupported provider: "${providerName}". Supported providers: github, linear` };
1429
+ return { error: `Unsupported provider: "${providerName}". Supported providers: github, linear, notion, folder` };
1012
1430
  }
1013
1431
  const direction = (getValue('externalSync.defaultDirection') ?? 'bidirectional');
1014
1432
  return { provider, project, direction };
@@ -1083,11 +1501,329 @@ async function processBatch(tasks, adapter, api, providerName, project, directio
1083
1501
  }
1084
1502
  return { succeeded, failed, rateLimited, resetAt };
1085
1503
  }
1504
+ /**
1505
+ * Process a batch of documents: create external pages and link them.
1506
+ */
1507
+ async function processDocumentBatch(docs, adapter, api, providerName, project, direction, progressLines) {
1508
+ let succeeded = 0;
1509
+ let failed = 0;
1510
+ let rateLimited = false;
1511
+ let resetAt;
1512
+ for (const doc of docs) {
1513
+ try {
1514
+ // Resolve library path for directory-based organization
1515
+ let libraryPath;
1516
+ try {
1517
+ libraryPath = await resolveDocumentLibraryPath(api, doc.id);
1518
+ }
1519
+ catch {
1520
+ // If library path resolution fails, continue without it
1521
+ // (document will be placed in project root)
1522
+ }
1523
+ // Convert document to external document input (with library path)
1524
+ const externalInput = documentToExternalDocumentInput(doc, libraryPath);
1525
+ // Create the external page
1526
+ const externalDoc = await adapter.createPage(project, externalInput);
1527
+ // Build the ExternalSyncState metadata
1528
+ const syncState = {
1529
+ provider: providerName,
1530
+ project,
1531
+ externalId: externalDoc.externalId,
1532
+ url: externalDoc.url,
1533
+ direction,
1534
+ adapterType: 'document',
1535
+ };
1536
+ // Update the document with externalRef and _externalSync metadata
1537
+ const existingMetadata = (doc.metadata ?? {});
1538
+ await api.update(doc.id, {
1539
+ externalRef: externalDoc.url,
1540
+ metadata: {
1541
+ ...existingMetadata,
1542
+ _externalSync: syncState,
1543
+ },
1544
+ });
1545
+ progressLines.push(`Linked ${doc.id} → ${externalDoc.url}`);
1546
+ succeeded++;
1547
+ }
1548
+ catch (err) {
1549
+ // Check for rate limit errors
1550
+ const rlCheck = isRateLimitError(err);
1551
+ if (rlCheck.isRateLimit) {
1552
+ rateLimited = true;
1553
+ resetAt = rlCheck.resetAt;
1554
+ const message = err instanceof Error ? err.message : String(err);
1555
+ progressLines.push(`Rate limit hit while linking ${doc.id}: ${message}`);
1556
+ break;
1557
+ }
1558
+ const message = err instanceof Error ? err.message : String(err);
1559
+ const detail = extractValidationDetail(err);
1560
+ progressLines.push(detail
1561
+ ? `Failed to link ${doc.id}: ${message} — ${detail}`
1562
+ : `Failed to link ${doc.id}: ${message}`);
1563
+ failed++;
1564
+ }
1565
+ }
1566
+ return { succeeded, failed, rateLimited, resetAt };
1567
+ }
1568
+ /**
1569
+ * Handle link-all for documents.
1570
+ * Queries all documents, filters out system categories and already-linked ones,
1571
+ * then creates external pages for each via the document adapter.
1572
+ */
1573
+ async function linkAllDocumentsHandler(options) {
1574
+ const providerName = options.provider;
1575
+ const isDryRun = options['dry-run'] ?? false;
1576
+ const force = options.force ?? false;
1577
+ const batchSize = parseInt(options['batch-size'] ?? '10', 10);
1578
+ if (isNaN(batchSize) || batchSize < 1) {
1579
+ return failure('--batch-size must be a positive integer', ExitCode.INVALID_ARGUMENTS);
1580
+ }
1581
+ // Parse status filters
1582
+ const statusFilters = [];
1583
+ if (options.status) {
1584
+ if (Array.isArray(options.status)) {
1585
+ statusFilters.push(...options.status);
1586
+ }
1587
+ else {
1588
+ statusFilters.push(options.status);
1589
+ }
1590
+ }
1591
+ // Get API for querying/updating documents
1592
+ const { api, error: apiError } = createAPI(options);
1593
+ if (apiError) {
1594
+ return failure(apiError, ExitCode.GENERAL_ERROR);
1595
+ }
1596
+ // Query all documents
1597
+ let allDocs;
1598
+ try {
1599
+ const results = await api.list({ type: 'document' });
1600
+ allDocs = results;
1601
+ }
1602
+ catch (err) {
1603
+ const message = err instanceof Error ? err.message : String(err);
1604
+ return failure(`Failed to list documents: ${message}`, ExitCode.GENERAL_ERROR);
1605
+ }
1606
+ // Filter out system categories and untitled documents
1607
+ allDocs = allDocs.filter((doc) => isSyncableDocument(doc));
1608
+ // Filter documents: unlinked docs, plus (with --force) docs linked to any provider
1609
+ let relinkedFromProvider;
1610
+ let relinkCount = 0;
1611
+ let docsToLink = allDocs.filter((doc) => {
1612
+ const metadata = (doc.metadata ?? {});
1613
+ const syncState = metadata._externalSync;
1614
+ if (!syncState) {
1615
+ return true;
1616
+ }
1617
+ if (force) {
1618
+ // Re-link regardless of current provider
1619
+ if (syncState.provider !== providerName) {
1620
+ relinkedFromProvider = syncState.provider;
1621
+ }
1622
+ relinkCount++;
1623
+ return true;
1624
+ }
1625
+ return false;
1626
+ });
1627
+ // Apply status filter if specified (documents use 'active'/'archived')
1628
+ if (statusFilters.length > 0) {
1629
+ docsToLink = docsToLink.filter((doc) => statusFilters.includes(doc.status));
1630
+ }
1631
+ // Skip archived documents by default
1632
+ docsToLink = docsToLink.filter((doc) => doc.status !== 'archived');
1633
+ // Filter out documents not in any library (unless --no-library is set)
1634
+ let noLibrarySkipped = 0;
1635
+ if (!options['no-library'] && docsToLink.length > 0) {
1636
+ const libraryPaths = await resolveDocumentLibraryPaths(api, docsToLink.map(d => d.id));
1637
+ const beforeCount = docsToLink.length;
1638
+ docsToLink = docsToLink.filter((doc) => libraryPaths.has(doc.id));
1639
+ noLibrarySkipped = beforeCount - docsToLink.length;
1640
+ }
1641
+ const mode = getOutputMode(options);
1642
+ if (docsToLink.length === 0) {
1643
+ const result = { linked: 0, failed: 0, skipped: 0, total: 0, dryRun: isDryRun, type: 'document' };
1644
+ if (noLibrarySkipped > 0) {
1645
+ result.noLibrarySkipped = noLibrarySkipped;
1646
+ }
1647
+ if (mode === 'json') {
1648
+ return success(result);
1649
+ }
1650
+ const hints = [];
1651
+ if (force) {
1652
+ hints.push('No documents found to re-link matching the specified criteria.');
1653
+ }
1654
+ else {
1655
+ hints.push('No unlinked documents found. Use --force to re-link existing documents.');
1656
+ }
1657
+ if (noLibrarySkipped > 0) {
1658
+ hints.push(`Skipped ${noLibrarySkipped} document(s) not in any library (use --no-library to include)`);
1659
+ }
1660
+ return success(result, hints.join('\n'));
1661
+ }
1662
+ // Dry run — just list documents that would be linked
1663
+ if (isDryRun) {
1664
+ const docList = docsToLink.map((d) => ({
1665
+ id: d.id,
1666
+ title: d.title ?? '(untitled)',
1667
+ status: d.status,
1668
+ category: d.category,
1669
+ }));
1670
+ const jsonResult = {
1671
+ dryRun: true,
1672
+ provider: providerName,
1673
+ type: 'document',
1674
+ total: docsToLink.length,
1675
+ documents: docList,
1676
+ };
1677
+ if (force && relinkCount > 0) {
1678
+ jsonResult.force = true;
1679
+ jsonResult.relinkCount = relinkCount;
1680
+ jsonResult.relinkFromProvider = relinkedFromProvider;
1681
+ }
1682
+ if (noLibrarySkipped > 0) {
1683
+ jsonResult.noLibrarySkipped = noLibrarySkipped;
1684
+ }
1685
+ if (mode === 'json') {
1686
+ return success(jsonResult);
1687
+ }
1688
+ if (mode === 'quiet') {
1689
+ return success(String(docsToLink.length));
1690
+ }
1691
+ const lines = [];
1692
+ if (force && relinkCount > 0) {
1693
+ lines.push(`Dry run: Re-linking ${relinkCount} document(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1694
+ const newCount = docsToLink.length - relinkCount;
1695
+ if (newCount > 0) {
1696
+ lines.push(` Plus ${newCount} unlinked document(s) to link`);
1697
+ }
1698
+ }
1699
+ else {
1700
+ lines.push(`Dry run: ${docsToLink.length} document(s) would be linked to ${providerName}`);
1701
+ }
1702
+ if (noLibrarySkipped > 0) {
1703
+ lines.push(`Skipped ${noLibrarySkipped} document(s) not in any library (use --no-library to include)`);
1704
+ }
1705
+ lines.push('');
1706
+ for (const doc of docsToLink) {
1707
+ lines.push(` ${doc.id} ${doc.status.padEnd(12)} ${doc.category.padEnd(16)} ${doc.title ?? '(untitled)'}`);
1708
+ }
1709
+ return success(jsonResult, lines.join('\n'));
1710
+ }
1711
+ // Warn about large element sets (skip for json/quiet — already handled above)
1712
+ if (docsToLink.length > LARGE_SET_WARNING_THRESHOLD && mode !== 'json' && mode !== 'quiet') {
1713
+ process.stderr.write(`\nWarning: About to link ${docsToLink.length} documents. ` +
1714
+ `This may take a significant amount of time for large document sets.\n\n`);
1715
+ }
1716
+ // Create provider for actual linking (supports DI for testing)
1717
+ const providerFactory = options._providerFactory ?? createProviderFromSettings;
1718
+ const { provider: externalProvider, project, direction, error: providerError, } = await providerFactory(providerName, options.project, options);
1719
+ if (providerError) {
1720
+ return failure(providerError, ExitCode.GENERAL_ERROR);
1721
+ }
1722
+ // Get the document adapter
1723
+ const docAdapter = externalProvider.getDocumentAdapter?.();
1724
+ if (!docAdapter) {
1725
+ return failure(`Provider "${providerName}" does not support document sync`, ExitCode.GENERAL_ERROR);
1726
+ }
1727
+ const progressLines = [];
1728
+ // Log re-linking info when using --force
1729
+ if (force && relinkCount > 0 && mode !== 'json' && mode !== 'quiet') {
1730
+ progressLines.push(`Re-linking ${relinkCount} document(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1731
+ const newCount = docsToLink.length - relinkCount;
1732
+ if (newCount > 0) {
1733
+ progressLines.push(`Linking ${newCount} unlinked document(s)`);
1734
+ }
1735
+ progressLines.push('');
1736
+ }
1737
+ // Progress bar — only in default/verbose human-readable output mode
1738
+ const progress = mode === 'json' || mode === 'quiet'
1739
+ ? nullProgressBar
1740
+ : createProgressBar(docsToLink.length, 'Linking documents');
1741
+ // Process documents in batches
1742
+ let totalSucceeded = 0;
1743
+ let totalFailed = 0;
1744
+ let rateLimited = false;
1745
+ let rateLimitResetAt;
1746
+ let completed = 0;
1747
+ for (let i = 0; i < docsToLink.length; i += batchSize) {
1748
+ const batch = docsToLink.slice(i, i + batchSize);
1749
+ const batchResult = await processDocumentBatch(batch, docAdapter, api, providerName, project, direction, progressLines);
1750
+ totalSucceeded += batchResult.succeeded;
1751
+ totalFailed += batchResult.failed;
1752
+ completed += batch.length;
1753
+ progress.update(completed);
1754
+ if (batchResult.rateLimited) {
1755
+ rateLimited = true;
1756
+ rateLimitResetAt = batchResult.resetAt;
1757
+ break;
1758
+ }
1759
+ if (i + batchSize < docsToLink.length) {
1760
+ await new Promise((resolve) => setTimeout(resolve, 100));
1761
+ }
1762
+ }
1763
+ progress.finish();
1764
+ const skipped = docsToLink.length - totalSucceeded - totalFailed;
1765
+ const result = {
1766
+ provider: providerName,
1767
+ project,
1768
+ type: 'document',
1769
+ linked: totalSucceeded,
1770
+ failed: totalFailed,
1771
+ skipped,
1772
+ total: docsToLink.length,
1773
+ rateLimited,
1774
+ rateLimitResetAt: rateLimitResetAt ? new Date(rateLimitResetAt * 1000).toISOString() : undefined,
1775
+ };
1776
+ if (force && relinkCount > 0) {
1777
+ result.force = true;
1778
+ result.relinkCount = relinkCount;
1779
+ result.relinkFromProvider = relinkedFromProvider;
1780
+ }
1781
+ if (noLibrarySkipped > 0) {
1782
+ result.noLibrarySkipped = noLibrarySkipped;
1783
+ }
1784
+ if (mode === 'json') {
1785
+ return success(result);
1786
+ }
1787
+ if (mode === 'quiet') {
1788
+ return success(String(totalSucceeded));
1789
+ }
1790
+ const lines = [...progressLines, ''];
1791
+ const summaryParts = [`Linked ${totalSucceeded} documents to ${providerName}`];
1792
+ if (totalFailed > 0) {
1793
+ summaryParts.push(`(${totalFailed} failed)`);
1794
+ }
1795
+ if (skipped > 0) {
1796
+ summaryParts.push(`(${skipped} skipped)`);
1797
+ }
1798
+ lines.push(summaryParts.join(' '));
1799
+ if (noLibrarySkipped > 0) {
1800
+ lines.push(`Skipped ${noLibrarySkipped} document(s) not in any library (use --no-library to include)`);
1801
+ }
1802
+ if (rateLimited) {
1803
+ lines.push('');
1804
+ if (rateLimitResetAt) {
1805
+ const resetDate = new Date(rateLimitResetAt * 1000);
1806
+ lines.push(`Rate limit reached. Resets at ${resetDate.toISOString()}. Re-run this command after the reset to link remaining documents.`);
1807
+ }
1808
+ else {
1809
+ lines.push('Rate limit reached. Re-run this command later to link remaining documents.');
1810
+ }
1811
+ }
1812
+ return success(result, lines.join('\n'));
1813
+ }
1086
1814
  async function linkAllHandler(_args, options) {
1087
1815
  const providerName = options.provider;
1088
1816
  if (!providerName) {
1089
1817
  return failure('The --provider flag is required. Usage: sf external-sync link-all --provider <provider>', ExitCode.INVALID_ARGUMENTS);
1090
1818
  }
1819
+ const elementType = options.type ?? 'task';
1820
+ if (elementType !== 'task' && elementType !== 'document') {
1821
+ return failure(`Invalid --type value "${elementType}". Must be one of: task, document`, ExitCode.INVALID_ARGUMENTS);
1822
+ }
1823
+ // Document linking branch
1824
+ if (elementType === 'document') {
1825
+ return linkAllDocumentsHandler(options);
1826
+ }
1091
1827
  const isDryRun = options['dry-run'] ?? false;
1092
1828
  const force = options.force ?? false;
1093
1829
  const batchSize = parseInt(options['batch-size'] ?? '10', 10);
@@ -1119,7 +1855,7 @@ async function linkAllHandler(_args, options) {
1119
1855
  const message = err instanceof Error ? err.message : String(err);
1120
1856
  return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
1121
1857
  }
1122
- // Filter tasks: unlinked tasks, plus (with --force) tasks linked to a DIFFERENT provider
1858
+ // Filter tasks: unlinked tasks, plus (with --force) tasks linked to any provider
1123
1859
  let relinkedFromProvider;
1124
1860
  let relinkCount = 0;
1125
1861
  let tasksToLink = allTasks.filter((task) => {
@@ -1129,13 +1865,15 @@ async function linkAllHandler(_args, options) {
1129
1865
  // Unlinked task — always include
1130
1866
  return true;
1131
1867
  }
1132
- if (force && syncState.provider !== providerName) {
1133
- // Force mode: include tasks linked to a DIFFERENT provider
1134
- relinkedFromProvider = syncState.provider;
1868
+ if (force) {
1869
+ // Force mode: re-link regardless of current provider
1870
+ if (syncState.provider !== providerName) {
1871
+ relinkedFromProvider = syncState.provider;
1872
+ }
1135
1873
  relinkCount++;
1136
1874
  return true;
1137
1875
  }
1138
- // Already linked (to same provider, or force not set) — skip
1876
+ // Already linked (force not set) — skip
1139
1877
  return false;
1140
1878
  });
1141
1879
  // Apply status filter if specified
@@ -1152,7 +1890,7 @@ async function linkAllHandler(_args, options) {
1152
1890
  }
1153
1891
  const hint = force
1154
1892
  ? 'No tasks found to re-link matching the specified criteria.'
1155
- : 'No unlinked tasks found. Use --force to re-link tasks from a different provider.';
1893
+ : 'No unlinked tasks found. Use --force to re-link existing tasks.';
1156
1894
  return success(result, hint);
1157
1895
  }
1158
1896
  // Dry run — just list tasks that would be linked
@@ -1196,6 +1934,11 @@ async function linkAllHandler(_args, options) {
1196
1934
  }
1197
1935
  return success(jsonResult, lines.join('\n'));
1198
1936
  }
1937
+ // Warn about large element sets (skip for json/quiet — already handled above)
1938
+ if (tasksToLink.length > LARGE_SET_WARNING_THRESHOLD && mode !== 'json' && mode !== 'quiet') {
1939
+ process.stderr.write(`\nWarning: About to link ${tasksToLink.length} tasks. ` +
1940
+ `This may take a significant amount of time for large task sets.\n\n`);
1941
+ }
1199
1942
  // Create provider for actual linking (supports DI for testing)
1200
1943
  const providerFactory = options._providerFactory ?? createProviderFromSettings;
1201
1944
  const { provider: externalProvider, project, direction, error: providerError, } = await providerFactory(providerName, options.project, options);
@@ -1217,16 +1960,23 @@ async function linkAllHandler(_args, options) {
1217
1960
  }
1218
1961
  progressLines.push('');
1219
1962
  }
1963
+ // Progress bar — only in default/verbose human-readable output mode
1964
+ const progress = mode === 'json' || mode === 'quiet'
1965
+ ? nullProgressBar
1966
+ : createProgressBar(tasksToLink.length, 'Linking tasks');
1220
1967
  // Process tasks in batches
1221
1968
  let totalSucceeded = 0;
1222
1969
  let totalFailed = 0;
1223
1970
  let rateLimited = false;
1224
1971
  let rateLimitResetAt;
1972
+ let completed = 0;
1225
1973
  for (let i = 0; i < tasksToLink.length; i += batchSize) {
1226
1974
  const batch = tasksToLink.slice(i, i + batchSize);
1227
1975
  const batchResult = await processBatch(batch, adapter, api, providerName, project, direction, progressLines);
1228
1976
  totalSucceeded += batchResult.succeeded;
1229
1977
  totalFailed += batchResult.failed;
1978
+ completed += batch.length;
1979
+ progress.update(completed);
1230
1980
  if (batchResult.rateLimited) {
1231
1981
  rateLimited = true;
1232
1982
  rateLimitResetAt = batchResult.resetAt;
@@ -1237,6 +1987,7 @@ async function linkAllHandler(_args, options) {
1237
1987
  await new Promise((resolve) => setTimeout(resolve, 100));
1238
1988
  }
1239
1989
  }
1990
+ progress.finish();
1240
1991
  const skipped = tasksToLink.length - totalSucceeded - totalFailed;
1241
1992
  // Build result
1242
1993
  const result = {
@@ -1322,35 +2073,65 @@ Examples:
1322
2073
  options: [],
1323
2074
  handler: configSetProjectHandler,
1324
2075
  };
2076
+ const autoLinkTypeOption = {
2077
+ name: 'type',
2078
+ short: 't',
2079
+ description: 'Type of auto-link: task or document (default: task)',
2080
+ hasValue: true,
2081
+ };
1325
2082
  const configSetAutoLinkCommand = {
1326
2083
  name: 'set-auto-link',
1327
2084
  description: 'Enable auto-link with a provider',
1328
- usage: 'sf external-sync config set-auto-link <provider>',
1329
- help: `Enable auto-link for new tasks with the specified provider.
2085
+ usage: 'sf external-sync config set-auto-link <provider> [--type task|document]',
2086
+ help: `Enable auto-link for new elements with the specified provider.
2087
+
2088
+ When auto-link is enabled, newly created Stoneforge elements will automatically
2089
+ get a corresponding external issue or page created and linked.
1330
2090
 
1331
- When auto-link is enabled, newly created Stoneforge tasks will automatically
1332
- get a corresponding external issue created and linked.
2091
+ Use --type to specify whether to configure task or document auto-linking.
2092
+ Defaults to task for backwards compatibility.
1333
2093
 
1334
2094
  Arguments:
1335
- provider Provider name (github or linear)
2095
+ provider Provider name (github, linear, notion, or folder)
2096
+
2097
+ Options:
2098
+ --type, -t Type of auto-link: task or document (default: task)
1336
2099
 
1337
2100
  Examples:
1338
2101
  sf external-sync config set-auto-link github
1339
- sf external-sync config set-auto-link linear`,
1340
- options: [],
2102
+ sf external-sync config set-auto-link linear
2103
+ sf external-sync config set-auto-link --type document folder
2104
+ sf external-sync config set-auto-link --type document notion`,
2105
+ options: [autoLinkTypeOption],
1341
2106
  handler: configSetAutoLinkHandler,
1342
2107
  };
2108
+ const disableAutoLinkTypeOption = {
2109
+ name: 'type',
2110
+ short: 't',
2111
+ description: 'Type of auto-link to disable: task, document, or all (default: all)',
2112
+ hasValue: true,
2113
+ };
1343
2114
  const configDisableAutoLinkCommand = {
1344
2115
  name: 'disable-auto-link',
1345
2116
  description: 'Disable auto-link',
1346
- usage: 'sf external-sync config disable-auto-link',
1347
- help: `Disable auto-link for new tasks.
2117
+ usage: 'sf external-sync config disable-auto-link [--type task|document|all]',
2118
+ help: `Disable auto-link for new elements.
1348
2119
 
1349
- Clears the auto-link provider and disables automatic external issue creation.
2120
+ Clears the auto-link provider and disables automatic external creation.
2121
+
2122
+ Use --type to specify which auto-link to disable:
2123
+ task Only disable task auto-link
2124
+ document Only disable document auto-link
2125
+ all Disable both task and document auto-link (default)
2126
+
2127
+ Options:
2128
+ --type, -t Type of auto-link to disable: task, document, or all (default: all)
1350
2129
 
1351
2130
  Examples:
1352
- sf external-sync config disable-auto-link`,
1353
- options: [],
2131
+ sf external-sync config disable-auto-link
2132
+ sf external-sync config disable-auto-link --type task
2133
+ sf external-sync config disable-auto-link --type document`,
2134
+ options: [disableAutoLinkTypeOption],
1354
2135
  handler: configDisableAutoLinkHandler,
1355
2136
  };
1356
2137
  const configParentCommand = {
@@ -1364,15 +2145,17 @@ Tokens are masked in output for security.
1364
2145
  Subcommands:
1365
2146
  set-token <provider> <token> Store auth token
1366
2147
  set-project <provider> <project> Set default project
1367
- set-auto-link <provider> Enable auto-link with a provider
1368
- disable-auto-link Disable auto-link
2148
+ set-auto-link <provider> [--type task|document] Enable auto-link with a provider
2149
+ disable-auto-link [--type task|document|all] Disable auto-link
1369
2150
 
1370
2151
  Examples:
1371
2152
  sf external-sync config
1372
2153
  sf external-sync config set-token github ghp_xxxxxxxxxxxx
1373
2154
  sf external-sync config set-project github my-org/my-repo
1374
2155
  sf external-sync config set-auto-link github
1375
- sf external-sync config disable-auto-link`,
2156
+ sf external-sync config set-auto-link --type document folder
2157
+ sf external-sync config disable-auto-link
2158
+ sf external-sync config disable-auto-link --type document`,
1376
2159
  subcommands: {
1377
2160
  'set-token': configSetTokenCommand,
1378
2161
  'set-project': configSetProjectCommand,
@@ -1387,24 +2170,28 @@ Examples:
1387
2170
  // ============================================================================
1388
2171
  const linkCommand = {
1389
2172
  name: 'link',
1390
- description: 'Link a task to an external issue',
1391
- usage: 'sf external-sync link <taskId> <url-or-issue-number>',
1392
- help: `Link a Stoneforge task to an external issue (e.g., GitHub issue).
2173
+ description: 'Link a task or document to an external issue/page',
2174
+ usage: 'sf external-sync link <elementId> <url-or-external-id> [--type task|document] [--provider <name>]',
2175
+ help: `Link a Stoneforge element (task or document) to an external issue or page.
1393
2176
 
1394
- Sets the task's externalRef and _externalSync metadata. If given a bare
2177
+ Sets the element's externalRef and _externalSync metadata. If given a bare
1395
2178
  issue number, constructs the URL from the provider's default project.
1396
2179
 
2180
+ Use --type document to link a document element (default: task).
2181
+
1397
2182
  Arguments:
1398
- taskId Stoneforge task ID
1399
- url-or-number Full URL or bare issue number
2183
+ elementId Stoneforge element ID (task or document)
2184
+ url-or-id Full URL, bare issue number, or external ID
1400
2185
 
1401
2186
  Options:
1402
2187
  -p, --provider Provider name (default: github)
2188
+ -t, --type Element type: task or document (default: task)
1403
2189
 
1404
2190
  Examples:
1405
2191
  sf external-sync link el-abc123 https://github.com/org/repo/issues/42
1406
2192
  sf external-sync link el-abc123 42
1407
- sf external-sync link el-abc123 42 --provider github`,
2193
+ sf external-sync link el-abc123 42 --provider github
2194
+ sf external-sync link el-doc456 https://notion.so/page-id --type document --provider notion`,
1408
2195
  options: linkOptions,
1409
2196
  handler: linkHandler,
1410
2197
  };
@@ -1413,27 +2200,36 @@ Examples:
1413
2200
  // ============================================================================
1414
2201
  const linkAllCommand = {
1415
2202
  name: 'link-all',
1416
- description: 'Bulk-link all unlinked tasks to external issues',
1417
- usage: 'sf external-sync link-all --provider <provider> [--project <project>] [--status <status>] [--dry-run] [--batch-size <n>] [--force]',
1418
- help: `Create external issues for all unlinked tasks and link them in bulk.
2203
+ description: 'Bulk-link all unlinked tasks or documents to external issues/pages',
2204
+ usage: 'sf external-sync link-all --provider <provider> [--type task|document] [--project <project>] [--status <status>] [--dry-run] [--batch-size <n>] [--force] [--no-library]',
2205
+ help: `Create external issues/pages for all unlinked elements and link them in bulk.
2206
+
2207
+ Finds all tasks (or documents with --type document) that do NOT have
2208
+ external sync metadata and creates a corresponding external issue or
2209
+ page for each one, then links them.
1419
2210
 
1420
- Finds all tasks that do NOT have external sync metadata and creates
1421
- a corresponding external issue for each one, then links them.
2211
+ Use --type document to link documents instead of tasks. When linking
2212
+ documents, system categories (task-description, message-content) are
2213
+ automatically excluded. Documents not in any library are excluded by
2214
+ default; use --no-library to include them.
1422
2215
 
1423
- Use --force to re-link tasks that are already linked to a different provider.
1424
- Tasks linked to the same target provider are always skipped.
2216
+ Use --force to re-link elements that are already linked, including those linked
2217
+ to the same provider. This is useful for re-syncing from scratch after deleting
2218
+ a synced folder.
1425
2219
 
1426
2220
  Options:
1427
2221
  -p, --provider <name> Provider to link to (required)
2222
+ -t, --type <type> Element type: task or document (default: task)
1428
2223
  --project <project> Override the default project
1429
- -s, --status <status> Only link tasks with this status (can be repeated)
1430
- -n, --dry-run List tasks that would be linked without creating issues
1431
- -b, --batch-size <n> Tasks to process concurrently (default: 10)
1432
- -f, --force Re-link tasks already linked to a different provider
2224
+ -s, --status <status> Only link elements with this status (can be repeated)
2225
+ -n, --dry-run List elements that would be linked without creating issues/pages
2226
+ -b, --batch-size <n> Elements to process concurrently (default: 10)
2227
+ -f, --force Re-link elements already linked (including same provider)
2228
+ --no-library Include documents not in any library (excluded by default)
1433
2229
 
1434
2230
  Rate Limits:
1435
2231
  If a rate limit is hit, the command stops gracefully and reports how
1436
- many tasks were linked. Re-run the command to continue linking.
2232
+ many elements were linked. Re-run the command to continue linking.
1437
2233
 
1438
2234
  Examples:
1439
2235
  sf external-sync link-all --provider github
@@ -1443,7 +2239,9 @@ Examples:
1443
2239
  sf external-sync link-all --provider github --project my-org/my-repo
1444
2240
  sf external-sync link-all --provider linear --batch-size 5
1445
2241
  sf external-sync link-all --provider linear --force
1446
- sf external-sync link-all --provider linear --force --dry-run`,
2242
+ sf external-sync link-all --provider notion --type document
2243
+ sf external-sync link-all --provider notion --type document --dry-run
2244
+ sf external-sync link-all --provider notion --type document --no-library`,
1447
2245
  options: linkAllOptions,
1448
2246
  handler: linkAllHandler,
1449
2247
  };
@@ -1452,14 +2250,15 @@ Examples:
1452
2250
  // ============================================================================
1453
2251
  const unlinkCommand = {
1454
2252
  name: 'unlink',
1455
- description: 'Remove external link from a task',
1456
- usage: 'sf external-sync unlink <taskId>',
1457
- help: `Remove the external link from a Stoneforge task.
2253
+ description: 'Remove external link from a task or document',
2254
+ usage: 'sf external-sync unlink <elementId>',
2255
+ help: `Remove the external link from a Stoneforge task or document.
1458
2256
 
1459
- Clears the task's externalRef field and _externalSync metadata.
2257
+ Clears the element's externalRef field and _externalSync metadata.
2258
+ Works with both tasks and documents.
1460
2259
 
1461
2260
  Arguments:
1462
- taskId Stoneforge task ID
2261
+ elementId Stoneforge element ID (task or document)
1463
2262
 
1464
2263
  Examples:
1465
2264
  sf external-sync unlink el-abc123`,
@@ -1467,33 +2266,70 @@ Examples:
1467
2266
  handler: unlinkHandler,
1468
2267
  };
1469
2268
  // ============================================================================
2269
+ // Unlink-All Command
2270
+ // ============================================================================
2271
+ const unlinkAllCommand = {
2272
+ name: 'unlink-all',
2273
+ description: 'Bulk-remove external links from all linked elements',
2274
+ usage: 'sf external-sync unlink-all [--provider <name>] [--type task|document|all] [--dry-run]',
2275
+ help: `Remove external links from all linked elements in bulk.
2276
+
2277
+ Clears the externalRef field and _externalSync metadata from every
2278
+ linked element. Useful for re-syncing from scratch after deleting
2279
+ a synced folder.
2280
+
2281
+ Options:
2282
+ -p, --provider <name> Only unlink elements linked to this provider
2283
+ -t, --type <type> Element type: task, document, or all (default: all)
2284
+ -n, --dry-run Show what would be unlinked without making changes
2285
+
2286
+ Examples:
2287
+ sf external-sync unlink-all
2288
+ sf external-sync unlink-all --provider folder
2289
+ sf external-sync unlink-all --type document
2290
+ sf external-sync unlink-all --provider github --type task
2291
+ sf external-sync unlink-all --dry-run`,
2292
+ options: unlinkAllOptions,
2293
+ handler: unlinkAllHandler,
2294
+ };
2295
+ // ============================================================================
1470
2296
  // Push Command
1471
2297
  // ============================================================================
1472
2298
  const pushCommand = {
1473
2299
  name: 'push',
1474
- description: 'Push linked tasks to external service',
1475
- usage: 'sf external-sync push [taskId...] [--all] [--force]',
1476
- help: `Push specific linked tasks to their external service, or push all linked tasks.
2300
+ description: 'Push linked elements to external service',
2301
+ usage: 'sf external-sync push [elementId...] [--all] [--force] [--type task|document|all] [--no-library]',
2302
+ help: `Push specific linked elements to their external service, or push all linked elements.
1477
2303
 
1478
- If specific task IDs are given, pushes only those tasks. With --all,
1479
- pushes every task that has an external link.
2304
+ If specific element IDs are given, pushes only those elements. With --all,
2305
+ pushes every element that has an external link.
1480
2306
 
1481
- Use --force to push all linked tasks regardless of whether their local
2307
+ Use --force to push all linked elements regardless of whether their local
1482
2308
  content has changed. This is useful when label generation logic changes
1483
2309
  and the external representation needs to be refreshed.
1484
2310
 
2311
+ Use --type to filter by element type (task, document, or all). Default: all.
2312
+
2313
+ Documents not in any library are excluded by default. Use --no-library to
2314
+ include them.
2315
+
1485
2316
  Arguments:
1486
- taskId... One or more task IDs to push (optional with --all)
2317
+ elementId... One or more element IDs to push (optional with --all)
1487
2318
 
1488
2319
  Options:
1489
- -a, --all Push all linked tasks
1490
- -f, --force Push all linked tasks regardless of whether they have changed
2320
+ -a, --all Push all linked elements
2321
+ -f, --force Push all linked elements regardless of whether they have changed
2322
+ -t, --type <type> Element type to push: task, document, or all (default: all)
2323
+ --no-library Include documents not in any library (excluded by default)
1491
2324
 
1492
2325
  Examples:
1493
2326
  sf external-sync push el-abc123
1494
2327
  sf external-sync push el-abc123 el-def456
1495
2328
  sf external-sync push --all
1496
2329
  sf external-sync push --all --force
2330
+ sf external-sync push --all --type document
2331
+ sf external-sync push --all --type task
2332
+ sf external-sync push --all --type document --no-library
1497
2333
  sf external-sync push el-abc123 --force`,
1498
2334
  options: pushOptions,
1499
2335
  handler: pushHandler,
@@ -1503,20 +2339,25 @@ Examples:
1503
2339
  // ============================================================================
1504
2340
  const pullCommand = {
1505
2341
  name: 'pull',
1506
- description: 'Pull changes from external for linked tasks',
1507
- usage: 'sf external-sync pull [--provider <name>] [--discover]',
1508
- help: `Pull changes from external services for all linked tasks.
2342
+ description: 'Pull changes from external for linked elements',
2343
+ usage: 'sf external-sync pull [--provider <name>] [--discover] [--type task|document|all]',
2344
+ help: `Pull changes from external services for all linked elements (tasks and documents).
1509
2345
 
1510
- Optionally discover new issues not yet linked to Stoneforge tasks.
2346
+ Optionally discover new issues not yet linked to Stoneforge elements.
2347
+
2348
+ Use --type to filter by element type (task, document, or all). Default: all.
1511
2349
 
1512
2350
  Options:
1513
2351
  -p, --provider <name> Pull from specific provider (default: all configured)
1514
2352
  -d, --discover Discover new unlinked issues
2353
+ -t, --type <type> Element type to pull: task, document, or all (default: all)
1515
2354
 
1516
2355
  Examples:
1517
2356
  sf external-sync pull
1518
2357
  sf external-sync pull --provider github
1519
- sf external-sync pull --discover`,
2358
+ sf external-sync pull --discover
2359
+ sf external-sync pull --type document
2360
+ sf external-sync pull --type task --provider notion`,
1520
2361
  options: pullOptions,
1521
2362
  handler: pullHandler,
1522
2363
  };
@@ -1526,18 +2367,23 @@ Examples:
1526
2367
  const biSyncCommand = {
1527
2368
  name: 'sync',
1528
2369
  description: 'Bidirectional sync with external services',
1529
- usage: 'sf external-sync sync [--dry-run]',
2370
+ usage: 'sf external-sync sync [--dry-run] [--type task|document|all]',
1530
2371
  help: `Run bidirectional sync between Stoneforge and external services.
1531
2372
 
1532
- Performs both push and pull operations. In dry-run mode, reports what
1533
- would change without making any modifications.
2373
+ Performs both push and pull operations for tasks and documents.
2374
+ In dry-run mode, reports what would change without making any modifications.
2375
+
2376
+ Use --type to filter by element type (task, document, or all). Default: all.
1534
2377
 
1535
2378
  Options:
1536
- -n, --dry-run Show what would change without making changes
2379
+ -n, --dry-run Show what would change without making changes
2380
+ -t, --type <type> Element type to sync: task, document, or all (default: all)
1537
2381
 
1538
2382
  Examples:
1539
2383
  sf external-sync sync
1540
- sf external-sync sync --dry-run`,
2384
+ sf external-sync sync --dry-run
2385
+ sf external-sync sync --type document
2386
+ sf external-sync sync --type task`,
1541
2387
  options: syncOptions,
1542
2388
  handler: syncHandler,
1543
2389
  };
@@ -1550,8 +2396,8 @@ const extStatusCommand = {
1550
2396
  usage: 'sf external-sync status',
1551
2397
  help: `Show the current external sync state.
1552
2398
 
1553
- Displays linked task count, last sync times, configured providers,
1554
- and pending conflicts.
2399
+ Displays linked task and document counts, last sync times, configured
2400
+ providers, and pending conflicts.
1555
2401
 
1556
2402
  Examples:
1557
2403
  sf external-sync status
@@ -1565,14 +2411,14 @@ Examples:
1565
2411
  const resolveCommand = {
1566
2412
  name: 'resolve',
1567
2413
  description: 'Resolve a sync conflict',
1568
- usage: 'sf external-sync resolve <taskId> --keep local|remote',
2414
+ usage: 'sf external-sync resolve <elementId> --keep local|remote',
1569
2415
  help: `Resolve a sync conflict by choosing which version to keep.
1570
2416
 
1571
- Tasks with sync conflicts are tagged with "sync-conflict". This command
1572
- resolves the conflict by keeping either the local or remote version.
2417
+ Elements (tasks or documents) with sync conflicts are tagged with "sync-conflict".
2418
+ This command resolves the conflict by keeping either the local or remote version.
1573
2419
 
1574
2420
  Arguments:
1575
- taskId Task ID with a sync conflict
2421
+ elementId Element ID with a sync conflict (task or document)
1576
2422
 
1577
2423
  Options:
1578
2424
  -k, --keep <version> Which version to keep: local or remote (required)
@@ -1597,11 +2443,12 @@ Commands:
1597
2443
  config Show provider configuration
1598
2444
  config set-token <provider> <token> Store auth token
1599
2445
  config set-project <provider> <project> Set default project
1600
- config set-auto-link <provider> Enable auto-link with a provider
1601
- config disable-auto-link Disable auto-link
2446
+ config set-auto-link <provider> [--type task|document] Enable auto-link
2447
+ config disable-auto-link [--type task|document|all] Disable auto-link
1602
2448
  link <taskId> <url-or-issue-number> Link task to external issue
1603
2449
  link-all --provider <name> Bulk-link all unlinked tasks
1604
- unlink <taskId> Remove external link
2450
+ unlink <elementId> Remove external link
2451
+ unlink-all [--provider] [--type] Bulk-remove all external links
1605
2452
  push [taskId...] [--force] Push linked task(s) to external
1606
2453
  pull Pull changes from external
1607
2454
  sync [--dry-run] Bidirectional sync
@@ -1613,10 +2460,14 @@ Examples:
1613
2460
  sf external-sync config set-token github ghp_xxxxxxxxxxxx
1614
2461
  sf external-sync config set-project github my-org/my-repo
1615
2462
  sf external-sync config set-auto-link github
2463
+ sf external-sync config set-auto-link --type document folder
1616
2464
  sf external-sync config disable-auto-link
2465
+ sf external-sync config disable-auto-link --type document
1617
2466
  sf external-sync link el-abc123 42
1618
2467
  sf external-sync link-all --provider github
1619
2468
  sf external-sync link-all --provider github --dry-run
2469
+ sf external-sync unlink-all
2470
+ sf external-sync unlink-all --provider folder --type document
1620
2471
  sf external-sync push --all
1621
2472
  sf external-sync push --all --force
1622
2473
  sf external-sync pull
@@ -1628,6 +2479,7 @@ Examples:
1628
2479
  link: linkCommand,
1629
2480
  'link-all': linkAllCommand,
1630
2481
  unlink: unlinkCommand,
2482
+ 'unlink-all': unlinkAllCommand,
1631
2483
  push: pushCommand,
1632
2484
  pull: pullCommand,
1633
2485
  sync: biSyncCommand,
@@ -1638,10 +2490,10 @@ Examples:
1638
2490
  const mode = getOutputMode(options);
1639
2491
  if (mode === 'json') {
1640
2492
  return success({
1641
- commands: ['config', 'link', 'link-all', 'unlink', 'push', 'pull', 'sync', 'status', 'resolve'],
2493
+ commands: ['config', 'link', 'link-all', 'unlink', 'unlink-all', 'push', 'pull', 'sync', 'status', 'resolve'],
1642
2494
  });
1643
2495
  }
1644
- return failure('Usage: sf external-sync <command>\n\nCommands: config, link, link-all, unlink, push, pull, sync, status, resolve\n\nRun "sf external-sync --help" for more information.', ExitCode.INVALID_ARGUMENTS);
2496
+ return failure('Usage: sf external-sync <command>\n\nCommands: config, link, link-all, unlink, unlink-all, push, pull, sync, status, resolve\n\nRun "sf external-sync --help" for more information.', ExitCode.INVALID_ARGUMENTS);
1645
2497
  },
1646
2498
  };
1647
2499
  //# sourceMappingURL=external-sync.js.map