@stoneforge/quarry 1.12.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 (199) hide show
  1. package/README.md +2 -0
  2. package/dist/api/quarry-api.d.ts +9 -1
  3. package/dist/api/quarry-api.d.ts.map +1 -1
  4. package/dist/api/quarry-api.js +21 -2
  5. package/dist/api/quarry-api.js.map +1 -1
  6. package/dist/api/types.d.ts +8 -1
  7. package/dist/api/types.d.ts.map +1 -1
  8. package/dist/api/types.js.map +1 -1
  9. package/dist/cli/commands/auto-link-helper.d.ts +33 -0
  10. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
  11. package/dist/cli/commands/auto-link-helper.js +74 -0
  12. package/dist/cli/commands/auto-link-helper.js.map +1 -0
  13. package/dist/cli/commands/crud.d.ts +3 -0
  14. package/dist/cli/commands/crud.d.ts.map +1 -1
  15. package/dist/cli/commands/crud.js +144 -15
  16. package/dist/cli/commands/crud.js.map +1 -1
  17. package/dist/cli/commands/docs.js +2 -2
  18. package/dist/cli/commands/docs.js.map +1 -1
  19. package/dist/cli/commands/document.js +1 -1
  20. package/dist/cli/commands/document.js.map +1 -1
  21. package/dist/cli/commands/entity.js +1 -1
  22. package/dist/cli/commands/entity.js.map +1 -1
  23. package/dist/cli/commands/external-sync.d.ts +18 -0
  24. package/dist/cli/commands/external-sync.d.ts.map +1 -0
  25. package/dist/cli/commands/external-sync.js +2499 -0
  26. package/dist/cli/commands/external-sync.js.map +1 -0
  27. package/dist/cli/commands/library.js +1 -1
  28. package/dist/cli/commands/library.js.map +1 -1
  29. package/dist/cli/commands/message.js +2 -2
  30. package/dist/cli/commands/message.js.map +1 -1
  31. package/dist/cli/commands/serve.d.ts.map +1 -1
  32. package/dist/cli/commands/serve.js +2 -0
  33. package/dist/cli/commands/serve.js.map +1 -1
  34. package/dist/cli/commands/task.d.ts.map +1 -1
  35. package/dist/cli/commands/task.js +7 -4
  36. package/dist/cli/commands/task.js.map +1 -1
  37. package/dist/cli/commands/team.js +1 -1
  38. package/dist/cli/commands/team.js.map +1 -1
  39. package/dist/cli/commands/workflow.js +1 -1
  40. package/dist/cli/commands/workflow.js.map +1 -1
  41. package/dist/cli/runner.d.ts.map +1 -1
  42. package/dist/cli/runner.js +3 -0
  43. package/dist/cli/runner.js.map +1 -1
  44. package/dist/cli/utils/progress.d.ts +30 -0
  45. package/dist/cli/utils/progress.d.ts.map +1 -0
  46. package/dist/cli/utils/progress.js +47 -0
  47. package/dist/cli/utils/progress.js.map +1 -0
  48. package/dist/config/config.d.ts.map +1 -1
  49. package/dist/config/config.js +34 -0
  50. package/dist/config/config.js.map +1 -1
  51. package/dist/config/defaults.d.ts +13 -1
  52. package/dist/config/defaults.d.ts.map +1 -1
  53. package/dist/config/defaults.js +22 -0
  54. package/dist/config/defaults.js.map +1 -1
  55. package/dist/config/file.d.ts.map +1 -1
  56. package/dist/config/file.js +71 -0
  57. package/dist/config/file.js.map +1 -1
  58. package/dist/config/index.d.ts +3 -3
  59. package/dist/config/index.d.ts.map +1 -1
  60. package/dist/config/index.js +2 -2
  61. package/dist/config/index.js.map +1 -1
  62. package/dist/config/merge.d.ts.map +1 -1
  63. package/dist/config/merge.js +52 -1
  64. package/dist/config/merge.js.map +1 -1
  65. package/dist/config/types.d.ts +68 -1
  66. package/dist/config/types.d.ts.map +1 -1
  67. package/dist/config/types.js +33 -0
  68. package/dist/config/types.js.map +1 -1
  69. package/dist/config/validation.d.ts.map +1 -1
  70. package/dist/config/validation.js +64 -1
  71. package/dist/config/validation.js.map +1 -1
  72. package/dist/external-sync/adapters/document-sync-adapter.d.ts +150 -0
  73. package/dist/external-sync/adapters/document-sync-adapter.d.ts.map +1 -0
  74. package/dist/external-sync/adapters/document-sync-adapter.js +325 -0
  75. package/dist/external-sync/adapters/document-sync-adapter.js.map +1 -0
  76. package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
  77. package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
  78. package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
  79. package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
  80. package/dist/external-sync/auto-link.d.ts +66 -0
  81. package/dist/external-sync/auto-link.d.ts.map +1 -0
  82. package/dist/external-sync/auto-link.js +98 -0
  83. package/dist/external-sync/auto-link.js.map +1 -0
  84. package/dist/external-sync/conflict-resolver.d.ts +170 -0
  85. package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
  86. package/dist/external-sync/conflict-resolver.js +580 -0
  87. package/dist/external-sync/conflict-resolver.js.map +1 -0
  88. package/dist/external-sync/index.d.ts +23 -0
  89. package/dist/external-sync/index.d.ts.map +1 -0
  90. package/dist/external-sync/index.js +24 -0
  91. package/dist/external-sync/index.js.map +1 -0
  92. package/dist/external-sync/provider-registry.d.ts +113 -0
  93. package/dist/external-sync/provider-registry.d.ts.map +1 -0
  94. package/dist/external-sync/provider-registry.js +205 -0
  95. package/dist/external-sync/provider-registry.js.map +1 -0
  96. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts +97 -0
  97. package/dist/external-sync/providers/folder/folder-document-adapter.d.ts.map +1 -0
  98. package/dist/external-sync/providers/folder/folder-document-adapter.js +261 -0
  99. package/dist/external-sync/providers/folder/folder-document-adapter.js.map +1 -0
  100. package/dist/external-sync/providers/folder/folder-fs.d.ts +146 -0
  101. package/dist/external-sync/providers/folder/folder-fs.d.ts.map +1 -0
  102. package/dist/external-sync/providers/folder/folder-fs.js +300 -0
  103. package/dist/external-sync/providers/folder/folder-fs.js.map +1 -0
  104. package/dist/external-sync/providers/folder/folder-provider.d.ts +28 -0
  105. package/dist/external-sync/providers/folder/folder-provider.d.ts.map +1 -0
  106. package/dist/external-sync/providers/folder/folder-provider.js +87 -0
  107. package/dist/external-sync/providers/folder/folder-provider.js.map +1 -0
  108. package/dist/external-sync/providers/folder/index.d.ts +11 -0
  109. package/dist/external-sync/providers/folder/index.d.ts.map +1 -0
  110. package/dist/external-sync/providers/folder/index.js +13 -0
  111. package/dist/external-sync/providers/folder/index.js.map +1 -0
  112. package/dist/external-sync/providers/github/github-api.d.ts +271 -0
  113. package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
  114. package/dist/external-sync/providers/github/github-api.js +366 -0
  115. package/dist/external-sync/providers/github/github-api.js.map +1 -0
  116. package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
  117. package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
  118. package/dist/external-sync/providers/github/github-field-map.js +157 -0
  119. package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
  120. package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
  121. package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
  122. package/dist/external-sync/providers/github/github-provider.js +212 -0
  123. package/dist/external-sync/providers/github/github-provider.js.map +1 -0
  124. package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
  125. package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
  126. package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
  127. package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
  128. package/dist/external-sync/providers/github/index.d.ts +12 -0
  129. package/dist/external-sync/providers/github/index.d.ts.map +1 -0
  130. package/dist/external-sync/providers/github/index.js +15 -0
  131. package/dist/external-sync/providers/github/index.js.map +1 -0
  132. package/dist/external-sync/providers/index.d.ts +13 -0
  133. package/dist/external-sync/providers/index.d.ts.map +1 -0
  134. package/dist/external-sync/providers/index.js +15 -0
  135. package/dist/external-sync/providers/index.js.map +1 -0
  136. package/dist/external-sync/providers/linear/index.d.ts +19 -0
  137. package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
  138. package/dist/external-sync/providers/linear/index.js +19 -0
  139. package/dist/external-sync/providers/linear/index.js.map +1 -0
  140. package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
  141. package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
  142. package/dist/external-sync/providers/linear/linear-api.js +522 -0
  143. package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
  144. package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
  145. package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
  146. package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
  147. package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
  148. package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
  149. package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
  150. package/dist/external-sync/providers/linear/linear-provider.js +169 -0
  151. package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
  152. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
  153. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
  154. package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
  155. package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
  156. package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
  157. package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
  158. package/dist/external-sync/providers/linear/linear-types.js +10 -0
  159. package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
  160. package/dist/external-sync/providers/notion/index.d.ts +19 -0
  161. package/dist/external-sync/providers/notion/index.d.ts.map +1 -0
  162. package/dist/external-sync/providers/notion/index.js +20 -0
  163. package/dist/external-sync/providers/notion/index.js.map +1 -0
  164. package/dist/external-sync/providers/notion/notion-api.d.ts +253 -0
  165. package/dist/external-sync/providers/notion/notion-api.d.ts.map +1 -0
  166. package/dist/external-sync/providers/notion/notion-api.js +492 -0
  167. package/dist/external-sync/providers/notion/notion-api.js.map +1 -0
  168. package/dist/external-sync/providers/notion/notion-blocks.d.ts +93 -0
  169. package/dist/external-sync/providers/notion/notion-blocks.d.ts.map +1 -0
  170. package/dist/external-sync/providers/notion/notion-blocks.js +773 -0
  171. package/dist/external-sync/providers/notion/notion-blocks.js.map +1 -0
  172. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts +176 -0
  173. package/dist/external-sync/providers/notion/notion-document-adapter.d.ts.map +1 -0
  174. package/dist/external-sync/providers/notion/notion-document-adapter.js +413 -0
  175. package/dist/external-sync/providers/notion/notion-document-adapter.js.map +1 -0
  176. package/dist/external-sync/providers/notion/notion-provider.d.ts +57 -0
  177. package/dist/external-sync/providers/notion/notion-provider.d.ts.map +1 -0
  178. package/dist/external-sync/providers/notion/notion-provider.js +159 -0
  179. package/dist/external-sync/providers/notion/notion-provider.js.map +1 -0
  180. package/dist/external-sync/providers/notion/notion-types.d.ts +388 -0
  181. package/dist/external-sync/providers/notion/notion-types.d.ts.map +1 -0
  182. package/dist/external-sync/providers/notion/notion-types.js +47 -0
  183. package/dist/external-sync/providers/notion/notion-types.js.map +1 -0
  184. package/dist/external-sync/sync-engine.d.ts +364 -0
  185. package/dist/external-sync/sync-engine.d.ts.map +1 -0
  186. package/dist/external-sync/sync-engine.js +1154 -0
  187. package/dist/external-sync/sync-engine.js.map +1 -0
  188. package/dist/index.d.ts +1 -0
  189. package/dist/index.d.ts.map +1 -1
  190. package/dist/index.js +2 -0
  191. package/dist/index.js.map +1 -1
  192. package/dist/server/index.js +8 -8
  193. package/dist/server/index.js.map +1 -1
  194. package/dist/services/inbox.js +1 -1
  195. package/dist/sync/hash.d.ts +5 -0
  196. package/dist/sync/hash.d.ts.map +1 -1
  197. package/dist/sync/hash.js +21 -2
  198. package/dist/sync/hash.js.map +1 -1
  199. package/package.json +10 -12
@@ -0,0 +1,2499 @@
1
+ /**
2
+ * External Sync Commands - Manage bidirectional sync with external services
3
+ *
4
+ * Provides CLI commands for external service synchronization:
5
+ * - config: Show/set provider configuration (tokens, projects)
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
12
+ * - sync: Bidirectional sync (push + pull)
13
+ * - status: Show sync state overview
14
+ * - resolve: Resolve sync conflicts
15
+ */
16
+ import { success, failure, ExitCode } from '../types.js';
17
+ import { getOutputMode } from '../formatter.js';
18
+ import { createAPI, resolveDatabasePath } from '../db.js';
19
+ import { createStorage, initializeSchema } from '@stoneforge/storage';
20
+ import { getValue, setValue, VALID_AUTO_LINK_PROVIDERS } from '../../config/index.js';
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
+ }
68
+ // ============================================================================
69
+ // Settings Service Helper
70
+ // ============================================================================
71
+ /**
72
+ * Dynamically imports and creates a SettingsService from a storage backend.
73
+ * Uses optional peer dependency @stoneforge/smithy.
74
+ */
75
+ async function createSettingsServiceFromOptions(options) {
76
+ const dbPath = resolveDatabasePath(options);
77
+ if (!dbPath) {
78
+ return {
79
+ settingsService: null,
80
+ error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path',
81
+ };
82
+ }
83
+ try {
84
+ const backend = createStorage({ path: dbPath, create: true });
85
+ initializeSchema(backend);
86
+ // Dynamic import to handle optional peer dependency
87
+ // @ts-ignore — smithy is an optional runtime dependency, may not be installed
88
+ const { createSettingsService } = await import('@stoneforge/smithy/services');
89
+ return { settingsService: createSettingsService(backend) };
90
+ }
91
+ catch (err) {
92
+ const message = err instanceof Error ? err.message : String(err);
93
+ // If the import fails, the smithy package isn't available
94
+ if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
95
+ return {
96
+ settingsService: null,
97
+ error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.',
98
+ };
99
+ }
100
+ return {
101
+ settingsService: null,
102
+ error: `Failed to initialize settings: ${message}`,
103
+ };
104
+ }
105
+ }
106
+ // ============================================================================
107
+ // Token Masking
108
+ // ============================================================================
109
+ /**
110
+ * Masks a token for display, showing only first 4 and last 4 characters
111
+ */
112
+ function maskToken(token) {
113
+ if (token.length <= 8) {
114
+ return '****';
115
+ }
116
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
117
+ }
118
+ // ============================================================================
119
+ // Config Command
120
+ // ============================================================================
121
+ async function configHandler(args, options) {
122
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
123
+ if (error) {
124
+ return failure(error, ExitCode.GENERAL_ERROR);
125
+ }
126
+ const settings = settingsService.getExternalSyncSettings();
127
+ const mode = getOutputMode(options);
128
+ // Also get file-based config for display
129
+ const enabled = getValue('externalSync.enabled');
130
+ const conflictStrategy = getValue('externalSync.conflictStrategy');
131
+ const defaultDirection = getValue('externalSync.defaultDirection');
132
+ const pollInterval = getValue('externalSync.pollInterval');
133
+ const autoLink = getValue('externalSync.autoLink');
134
+ const autoLinkProvider = getValue('externalSync.autoLinkProvider');
135
+ const autoLinkDocumentProvider = getValue('externalSync.autoLinkDocumentProvider');
136
+ const configData = {
137
+ enabled,
138
+ conflictStrategy,
139
+ defaultDirection,
140
+ pollInterval,
141
+ autoLink,
142
+ autoLinkProvider,
143
+ autoLinkDocumentProvider,
144
+ providers: Object.fromEntries(Object.entries(settings.providers).map(([name, config]) => [
145
+ name,
146
+ {
147
+ ...config,
148
+ token: config.token ? maskToken(config.token) : undefined,
149
+ },
150
+ ])),
151
+ };
152
+ if (mode === 'json') {
153
+ return success(configData);
154
+ }
155
+ if (mode === 'quiet') {
156
+ const providerNames = Object.keys(settings.providers);
157
+ return success(providerNames.length > 0 ? providerNames.join(',') : 'none');
158
+ }
159
+ // Human-readable output
160
+ const lines = [
161
+ 'External Sync Configuration',
162
+ '',
163
+ ` Enabled: ${enabled ? 'yes' : 'no'}`,
164
+ ` Conflict strategy: ${conflictStrategy}`,
165
+ ` Default direction: ${defaultDirection}`,
166
+ ` Poll interval: ${pollInterval}ms`,
167
+ ` Auto-link: ${autoLink ? 'yes' : 'no'}`,
168
+ ` Auto-link provider (tasks): ${autoLinkProvider ?? '(not set)'}`,
169
+ ` Auto-link provider (docs): ${autoLinkDocumentProvider ?? '(not set)'}`,
170
+ '',
171
+ ];
172
+ const providerEntries = Object.entries(settings.providers);
173
+ if (providerEntries.length === 0) {
174
+ lines.push(' Providers: (none configured)');
175
+ lines.push('');
176
+ lines.push(' Run "sf external-sync config set-token <provider> <token>" to configure a provider.');
177
+ }
178
+ else {
179
+ lines.push(' Providers:');
180
+ for (const [name, config] of providerEntries) {
181
+ lines.push(` ${name}:`);
182
+ lines.push(` Token: ${config.token ? maskToken(config.token) : '(not set)'}`);
183
+ lines.push(` API URL: ${config.apiBaseUrl ?? '(default)'}`);
184
+ lines.push(` Default project: ${config.defaultProject ?? '(not set)'}`);
185
+ }
186
+ }
187
+ return success(configData, lines.join('\n'));
188
+ }
189
+ // ============================================================================
190
+ // Config Set-Token Command
191
+ // ============================================================================
192
+ async function configSetTokenHandler(args, options) {
193
+ if (args.length < 2) {
194
+ return failure('Usage: sf external-sync config set-token <provider> <token>', ExitCode.INVALID_ARGUMENTS);
195
+ }
196
+ const [provider, token] = args;
197
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
198
+ if (error) {
199
+ return failure(error, ExitCode.GENERAL_ERROR);
200
+ }
201
+ const existing = settingsService.getProviderConfig(provider) ?? { provider };
202
+ settingsService.setProviderConfig(provider, { ...existing, token });
203
+ const mode = getOutputMode(options);
204
+ if (mode === 'json') {
205
+ return success({ provider, tokenSet: true });
206
+ }
207
+ if (mode === 'quiet') {
208
+ return success(provider);
209
+ }
210
+ return success({ provider, tokenSet: true }, `Token set for provider "${provider}" (${maskToken(token)})`);
211
+ }
212
+ // ============================================================================
213
+ // Config Set-Project Command
214
+ // ============================================================================
215
+ async function configSetProjectHandler(args, options) {
216
+ if (args.length < 2) {
217
+ return failure('Usage: sf external-sync config set-project <provider> <project>', ExitCode.INVALID_ARGUMENTS);
218
+ }
219
+ const [provider, project] = args;
220
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
221
+ if (error) {
222
+ return failure(error, ExitCode.GENERAL_ERROR);
223
+ }
224
+ const existing = settingsService.getProviderConfig(provider) ?? { provider };
225
+ settingsService.setProviderConfig(provider, { ...existing, defaultProject: project });
226
+ const mode = getOutputMode(options);
227
+ if (mode === 'json') {
228
+ return success({ provider, defaultProject: project });
229
+ }
230
+ if (mode === 'quiet') {
231
+ return success(project);
232
+ }
233
+ return success({ provider, defaultProject: project }, `Default project set for provider "${provider}": ${project}`);
234
+ }
235
+ // ============================================================================
236
+ // Config Set-Auto-Link Command
237
+ // ============================================================================
238
+ async function configSetAutoLinkHandler(args, options) {
239
+ if (args.length < 1) {
240
+ return failure('Usage: sf external-sync config set-auto-link <provider> [--type task|document]', ExitCode.INVALID_ARGUMENTS);
241
+ }
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
+ }
249
+ // Validate provider name
250
+ if (!VALID_AUTO_LINK_PROVIDERS.includes(provider)) {
251
+ return failure(`Invalid provider "${provider}". Must be one of: ${VALID_AUTO_LINK_PROVIDERS.join(', ')}`, ExitCode.VALIDATION);
252
+ }
253
+ // Check if provider has a token configured (skip for tokenless providers like folder)
254
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
255
+ let tokenWarning;
256
+ if (!settingsError && !TOKENLESS_PROVIDERS.has(provider)) {
257
+ const providerConfig = settingsService.getProviderConfig(provider);
258
+ if (!providerConfig?.token) {
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>".`;
260
+ }
261
+ }
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
280
+ setValue('externalSync.autoLink', true);
281
+ setValue('externalSync.autoLinkProvider', provider);
282
+ const mode = getOutputMode(options);
283
+ if (mode === 'json') {
284
+ return success({ autoLink: true, autoLinkProvider: provider, warning: tokenWarning });
285
+ }
286
+ if (mode === 'quiet') {
287
+ return success(provider);
288
+ }
289
+ const lines = [`Auto-link for tasks enabled with provider "${provider}".`];
290
+ if (tokenWarning) {
291
+ lines.push('');
292
+ lines.push(tokenWarning);
293
+ }
294
+ return success({ autoLink: true, autoLinkProvider: provider }, lines.join('\n'));
295
+ }
296
+ // ============================================================================
297
+ // Config Disable-Auto-Link Command
298
+ // ============================================================================
299
+ async function configDisableAutoLinkHandler(_args, options) {
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
331
+ setValue('externalSync.autoLink', false);
332
+ setValue('externalSync.autoLinkProvider', undefined);
333
+ setValue('externalSync.autoLinkDocumentProvider', undefined);
334
+ if (mode === 'json') {
335
+ return success({ autoLink: false, autoLinkProvider: null, autoLinkDocumentProvider: null });
336
+ }
337
+ if (mode === 'quiet') {
338
+ return success('disabled');
339
+ }
340
+ return success({ autoLink: false }, 'Auto-link disabled.');
341
+ }
342
+ const linkOptions = [
343
+ {
344
+ name: 'provider',
345
+ short: 'p',
346
+ description: 'Provider name (default: github)',
347
+ hasValue: true,
348
+ },
349
+ {
350
+ name: 'type',
351
+ short: 't',
352
+ description: 'Element type: task or document (default: task)',
353
+ hasValue: true,
354
+ defaultValue: 'task',
355
+ },
356
+ ];
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
+ }
362
+ if (args.length < 2) {
363
+ return failure(`Usage: sf external-sync link <${elementType}Id> <url-or-external-id>`, ExitCode.INVALID_ARGUMENTS);
364
+ }
365
+ const [elementId, urlOrExternalId] = args;
366
+ const provider = options.provider ?? 'github';
367
+ const { api, error } = createAPI(options);
368
+ if (error) {
369
+ return failure(error, ExitCode.GENERAL_ERROR);
370
+ }
371
+ // Resolve the element (task or document)
372
+ let element;
373
+ try {
374
+ element = await api.get(elementId);
375
+ }
376
+ catch {
377
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
378
+ }
379
+ if (!element) {
380
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
381
+ }
382
+ if (element.type !== elementType) {
383
+ return failure(`Element ${elementId} is not a ${elementType} (type: ${element.type})`, ExitCode.VALIDATION);
384
+ }
385
+ // Determine the external URL and external ID
386
+ let externalUrl;
387
+ let externalId;
388
+ if (/^\d+$/.test(urlOrExternalId)) {
389
+ // Bare number — construct URL from default project
390
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
391
+ if (settingsError) {
392
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
393
+ }
394
+ const providerConfig = settingsService.getProviderConfig(provider);
395
+ if (!providerConfig?.defaultProject) {
396
+ return failure(`No default project configured for provider "${provider}". ` +
397
+ `Run "sf external-sync config set-project ${provider} <project>" first, ` +
398
+ `or provide a full URL.`, ExitCode.VALIDATION);
399
+ }
400
+ externalId = urlOrExternalId;
401
+ if (provider === 'github') {
402
+ const baseUrl = providerConfig.apiBaseUrl
403
+ ? providerConfig.apiBaseUrl.replace(/\/api\/v3\/?$/, '').replace(/\/$/, '')
404
+ : 'https://github.com';
405
+ externalUrl = `${baseUrl}/${providerConfig.defaultProject}/issues/${urlOrExternalId}`;
406
+ }
407
+ else {
408
+ // Generic URL construction for other providers
409
+ externalUrl = `${providerConfig.defaultProject}#${urlOrExternalId}`;
410
+ }
411
+ }
412
+ else {
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;
418
+ }
419
+ // Extract project from URL if possible
420
+ let project;
421
+ const ghMatch = externalUrl.match(/github\.com\/([^/]+\/[^/]+)\//);
422
+ if (ghMatch) {
423
+ project = ghMatch[1];
424
+ }
425
+ // Determine the adapter type based on element type
426
+ const adapterType = elementType === 'document' ? 'document' : 'task';
427
+ // Update element with externalRef and _externalSync metadata
428
+ const syncMetadata = {
429
+ provider,
430
+ project: project ?? '',
431
+ externalId,
432
+ url: externalUrl,
433
+ direction: getValue('externalSync.defaultDirection'),
434
+ adapterType,
435
+ };
436
+ try {
437
+ const existingMetadata = (element.metadata ?? {});
438
+ await api.update(elementId, {
439
+ externalRef: externalUrl,
440
+ metadata: {
441
+ ...existingMetadata,
442
+ _externalSync: syncMetadata,
443
+ },
444
+ });
445
+ }
446
+ catch (err) {
447
+ const message = err instanceof Error ? err.message : String(err);
448
+ return failure(`Failed to update ${elementType}: ${message}`, ExitCode.GENERAL_ERROR);
449
+ }
450
+ const mode = getOutputMode(options);
451
+ if (mode === 'json') {
452
+ return success({ elementId, elementType, externalUrl, provider, externalId, project, adapterType });
453
+ }
454
+ if (mode === 'quiet') {
455
+ return success(externalUrl);
456
+ }
457
+ return success({ elementId, elementType, externalUrl, provider, externalId }, `Linked ${elementType} ${elementId} to ${externalUrl}`);
458
+ }
459
+ // ============================================================================
460
+ // Unlink Command
461
+ // ============================================================================
462
+ async function unlinkHandler(args, options) {
463
+ if (args.length < 1) {
464
+ return failure('Usage: sf external-sync unlink <elementId>', ExitCode.INVALID_ARGUMENTS);
465
+ }
466
+ const [elementId] = args;
467
+ const { api, error } = createAPI(options);
468
+ if (error) {
469
+ return failure(error, ExitCode.GENERAL_ERROR);
470
+ }
471
+ // Resolve element (task or document)
472
+ let element;
473
+ try {
474
+ element = await api.get(elementId);
475
+ }
476
+ catch {
477
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
478
+ }
479
+ if (!element) {
480
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
481
+ }
482
+ if (element.type !== 'task' && element.type !== 'document') {
483
+ return failure(`Element ${elementId} is not a task or document (type: ${element.type})`, ExitCode.VALIDATION);
484
+ }
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);
489
+ }
490
+ // Clear externalRef and _externalSync metadata
491
+ try {
492
+ const existingMetadata = (element.metadata ?? {});
493
+ const { _externalSync: _, ...restMetadata } = existingMetadata;
494
+ await api.update(elementId, {
495
+ externalRef: undefined,
496
+ metadata: restMetadata,
497
+ });
498
+ }
499
+ catch (err) {
500
+ const message = err instanceof Error ? err.message : String(err);
501
+ return failure(`Failed to update element: ${message}`, ExitCode.GENERAL_ERROR);
502
+ }
503
+ const mode = getOutputMode(options);
504
+ if (mode === 'json') {
505
+ return success({ elementId, elementType: element.type, unlinked: true });
506
+ }
507
+ if (mode === 'quiet') {
508
+ return success(elementId);
509
+ }
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'));
667
+ }
668
+ const pushOptions = [
669
+ {
670
+ name: 'all',
671
+ short: 'a',
672
+ description: 'Push all linked elements',
673
+ },
674
+ {
675
+ name: 'force',
676
+ short: 'f',
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)',
689
+ },
690
+ ];
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
+ }
697
+ const { api, error } = createAPI(options);
698
+ if (error) {
699
+ return failure(error, ExitCode.GENERAL_ERROR);
700
+ }
701
+ // Get settings service to create a configured sync engine
702
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
703
+ if (settingsError) {
704
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
705
+ }
706
+ const syncSettings = settingsService.getExternalSyncSettings();
707
+ const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
708
+ if (providerConfigs.length === 0) {
709
+ return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
710
+ }
711
+ // Build sync options
712
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
713
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
714
+ provider: p.provider,
715
+ token: p.token,
716
+ apiBaseUrl: p.apiBaseUrl,
717
+ defaultProject: p.defaultProject,
718
+ })));
719
+ const engine = createSyncEngine({
720
+ api,
721
+ registry,
722
+ settings: settingsService,
723
+ providerConfigs: providerConfigs.map((p) => ({
724
+ provider: p.provider,
725
+ token: p.token,
726
+ apiBaseUrl: p.apiBaseUrl,
727
+ defaultProject: p.defaultProject,
728
+ })),
729
+ });
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
+ };
744
+ const syncPushOptions = {};
745
+ if (adapterTypes) {
746
+ syncPushOptions.adapterTypes = adapterTypes;
747
+ }
748
+ if (options.all) {
749
+ syncPushOptions.all = true;
750
+ }
751
+ else if (args.length > 0) {
752
+ syncPushOptions.taskIds = args;
753
+ }
754
+ else {
755
+ return failure('Usage: sf external-sync push [elementId...] or sf external-sync push --all', ExitCode.INVALID_ARGUMENTS);
756
+ }
757
+ if (options.force) {
758
+ syncPushOptions.force = true;
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
+ }
775
+ try {
776
+ const result = await engine.push(syncPushOptions);
777
+ pushProgress.finish();
778
+ const output = {
779
+ success: result.success,
780
+ pushed: result.pushed,
781
+ skipped: result.skipped,
782
+ errors: result.errors,
783
+ conflicts: result.conflicts,
784
+ };
785
+ if (result.noLibrarySkipped && result.noLibrarySkipped > 0) {
786
+ output.noLibrarySkipped = result.noLibrarySkipped;
787
+ }
788
+ if (mode === 'json') {
789
+ return success(output);
790
+ }
791
+ if (mode === 'quiet') {
792
+ return success(String(result.pushed));
793
+ }
794
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
795
+ const lines = [
796
+ `Push: ${result.pushed} ${typeLabel} pushed successfully`,
797
+ '',
798
+ ];
799
+ if (result.skipped > 0) {
800
+ lines.push(`Skipped: ${result.skipped}`);
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
+ }
805
+ if (result.errors.length > 0) {
806
+ lines.push('');
807
+ lines.push(`Errors (${result.errors.length}):`);
808
+ for (const err of result.errors) {
809
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
810
+ }
811
+ }
812
+ if (result.conflicts.length > 0) {
813
+ lines.push('');
814
+ lines.push(`Conflicts (${result.conflicts.length}):`);
815
+ for (const conflict of result.conflicts) {
816
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
817
+ }
818
+ }
819
+ return success(output, lines.join('\n'));
820
+ }
821
+ catch (err) {
822
+ pushProgress.finish();
823
+ const message = err instanceof Error ? err.message : String(err);
824
+ return failure(`Push failed: ${message}`, ExitCode.GENERAL_ERROR);
825
+ }
826
+ }
827
+ const pullOptions = [
828
+ {
829
+ name: 'provider',
830
+ short: 'p',
831
+ description: 'Provider to pull from (default: all configured)',
832
+ hasValue: true,
833
+ },
834
+ {
835
+ name: 'discover',
836
+ short: 'd',
837
+ description: 'Discover new issues not yet linked',
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
+ },
846
+ ];
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
+ }
853
+ const { api, error } = createAPI(options);
854
+ if (error) {
855
+ return failure(error, ExitCode.GENERAL_ERROR);
856
+ }
857
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
858
+ if (settingsError) {
859
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
860
+ }
861
+ const settings = settingsService.getExternalSyncSettings();
862
+ const providerNames = options.provider
863
+ ? [options.provider]
864
+ : Object.keys(settings.providers);
865
+ if (providerNames.length === 0) {
866
+ return failure('No providers configured. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.VALIDATION);
867
+ }
868
+ // Validate providers have tokens and build provider configs
869
+ const providerConfigs = [];
870
+ const invalidProviders = [];
871
+ for (const name of providerNames) {
872
+ const config = settings.providers[name];
873
+ if (config?.token) {
874
+ providerConfigs.push({
875
+ provider: config.provider,
876
+ token: config.token,
877
+ apiBaseUrl: config.apiBaseUrl,
878
+ defaultProject: config.defaultProject,
879
+ });
880
+ }
881
+ else {
882
+ invalidProviders.push(name);
883
+ }
884
+ }
885
+ if (providerConfigs.length === 0) {
886
+ return failure('No providers with valid tokens found. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
887
+ }
888
+ // Create sync engine (same pattern as pushHandler)
889
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
890
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
891
+ provider: p.provider,
892
+ token: p.token,
893
+ apiBaseUrl: p.apiBaseUrl,
894
+ defaultProject: p.defaultProject,
895
+ })));
896
+ const engine = createSyncEngine({
897
+ api,
898
+ registry,
899
+ settings: settingsService,
900
+ providerConfigs: providerConfigs.map((p) => ({
901
+ provider: p.provider,
902
+ token: p.token,
903
+ apiBaseUrl: p.apiBaseUrl,
904
+ defaultProject: p.defaultProject,
905
+ })),
906
+ });
907
+ // Build pull options — discover maps to 'all' to create local tasks for unlinked external issues
908
+ const adapterTypes = parseTypeFlag(options.type);
909
+ const syncPullOptions = {};
910
+ if (adapterTypes) {
911
+ syncPullOptions.adapterTypes = adapterTypes;
912
+ }
913
+ if (options.discover) {
914
+ syncPullOptions.all = true;
915
+ }
916
+ try {
917
+ const result = await engine.pull(syncPullOptions);
918
+ const mode = getOutputMode(options);
919
+ const output = {
920
+ success: result.success,
921
+ pulled: result.pulled,
922
+ skipped: result.skipped,
923
+ errors: result.errors,
924
+ conflicts: result.conflicts,
925
+ invalidProviders,
926
+ };
927
+ if (mode === 'json') {
928
+ return success(output);
929
+ }
930
+ if (mode === 'quiet') {
931
+ return success(String(result.pulled));
932
+ }
933
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
934
+ const lines = [
935
+ `Pull: ${result.pulled} ${typeLabel} pulled successfully`,
936
+ '',
937
+ ];
938
+ if (result.skipped > 0) {
939
+ lines.push(`Skipped: ${result.skipped}`);
940
+ }
941
+ if (invalidProviders.length > 0) {
942
+ lines.push('');
943
+ lines.push(`Skipped providers (no token): ${invalidProviders.join(', ')}`);
944
+ }
945
+ if (result.errors.length > 0) {
946
+ lines.push('');
947
+ lines.push(`Errors (${result.errors.length}):`);
948
+ for (const err of result.errors) {
949
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
950
+ }
951
+ }
952
+ if (result.conflicts.length > 0) {
953
+ lines.push('');
954
+ lines.push(`Conflicts (${result.conflicts.length}):`);
955
+ for (const conflict of result.conflicts) {
956
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
957
+ }
958
+ }
959
+ return success(output, lines.join('\n'));
960
+ }
961
+ catch (err) {
962
+ const message = err instanceof Error ? err.message : String(err);
963
+ return failure(`Pull failed: ${message}`, ExitCode.GENERAL_ERROR);
964
+ }
965
+ }
966
+ const syncOptions = [
967
+ {
968
+ name: 'dry-run',
969
+ short: 'n',
970
+ description: 'Show what would change without making changes',
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
+ },
979
+ ];
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
+ }
986
+ const { api, error: apiError } = createAPI(options);
987
+ if (apiError) {
988
+ return failure(apiError, ExitCode.GENERAL_ERROR);
989
+ }
990
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
991
+ if (settingsError) {
992
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
993
+ }
994
+ const syncSettings = settingsService.getExternalSyncSettings();
995
+ const isDryRun = options['dry-run'] ?? false;
996
+ const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
997
+ if (providerConfigs.length === 0) {
998
+ return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
999
+ }
1000
+ // Create sync engine (same pattern as pushHandler)
1001
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
1002
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
1003
+ provider: p.provider,
1004
+ token: p.token,
1005
+ apiBaseUrl: p.apiBaseUrl,
1006
+ defaultProject: p.defaultProject,
1007
+ })));
1008
+ const engine = createSyncEngine({
1009
+ api,
1010
+ registry,
1011
+ settings: settingsService,
1012
+ providerConfigs: providerConfigs.map((p) => ({
1013
+ provider: p.provider,
1014
+ token: p.token,
1015
+ apiBaseUrl: p.apiBaseUrl,
1016
+ defaultProject: p.defaultProject,
1017
+ })),
1018
+ });
1019
+ // Build sync options
1020
+ const adapterTypes = parseTypeFlag(options.type);
1021
+ const syncOpts = {};
1022
+ if (adapterTypes) {
1023
+ syncOpts.adapterTypes = adapterTypes;
1024
+ }
1025
+ if (isDryRun) {
1026
+ syncOpts.dryRun = true;
1027
+ }
1028
+ try {
1029
+ const result = await engine.sync(syncOpts);
1030
+ const mode = getOutputMode(options);
1031
+ const output = {
1032
+ success: result.success,
1033
+ dryRun: isDryRun,
1034
+ pushed: result.pushed,
1035
+ pulled: result.pulled,
1036
+ skipped: result.skipped,
1037
+ errors: result.errors,
1038
+ conflicts: result.conflicts,
1039
+ };
1040
+ if (mode === 'json') {
1041
+ return success(output);
1042
+ }
1043
+ if (mode === 'quiet') {
1044
+ return success(`${result.pushed}/${result.pulled}`);
1045
+ }
1046
+ const typeLabel = options.type === 'document' ? 'document(s)' : options.type === 'task' ? 'task(s)' : 'element(s)';
1047
+ const lines = [
1048
+ isDryRun ? 'Sync (dry run) - showing what would change' : 'Bidirectional Sync Complete',
1049
+ '',
1050
+ ` Pushed: ${result.pushed} ${typeLabel}`,
1051
+ ` Pulled: ${result.pulled} ${typeLabel}`,
1052
+ ];
1053
+ if (result.skipped > 0) {
1054
+ lines.push(` Skipped: ${result.skipped} (no changes)`);
1055
+ }
1056
+ if (result.errors.length > 0) {
1057
+ lines.push('');
1058
+ lines.push(`Errors (${result.errors.length}):`);
1059
+ for (const err of result.errors) {
1060
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
1061
+ }
1062
+ }
1063
+ if (result.conflicts.length > 0) {
1064
+ lines.push('');
1065
+ lines.push(`Conflicts (${result.conflicts.length}):`);
1066
+ for (const conflict of result.conflicts) {
1067
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
1068
+ }
1069
+ }
1070
+ return success(output, lines.join('\n'));
1071
+ }
1072
+ catch (err) {
1073
+ const message = err instanceof Error ? err.message : String(err);
1074
+ return failure(`Sync failed: ${message}`, ExitCode.GENERAL_ERROR);
1075
+ }
1076
+ }
1077
+ // ============================================================================
1078
+ // Status Command
1079
+ // ============================================================================
1080
+ async function statusHandler(_args, options) {
1081
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
1082
+ if (settingsError) {
1083
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
1084
+ }
1085
+ const { api, error: apiError } = createAPI(options);
1086
+ if (apiError) {
1087
+ return failure(apiError, ExitCode.GENERAL_ERROR);
1088
+ }
1089
+ const settings = settingsService.getExternalSyncSettings();
1090
+ const enabled = getValue('externalSync.enabled');
1091
+ // Count linked tasks and documents, and check for conflicts
1092
+ let linkedTaskCount = 0;
1093
+ let linkedDocCount = 0;
1094
+ let conflictCount = 0;
1095
+ const providerTaskCounts = {};
1096
+ const providerDocCounts = {};
1097
+ try {
1098
+ const allTasks = await api.list({ type: 'task' });
1099
+ for (const t of allTasks) {
1100
+ const task = t;
1101
+ if (task.externalRef && task.metadata?._externalSync) {
1102
+ linkedTaskCount++;
1103
+ const syncMeta = task.metadata._externalSync;
1104
+ const provider = syncMeta?.provider ?? 'unknown';
1105
+ providerTaskCounts[provider] = (providerTaskCounts[provider] ?? 0) + 1;
1106
+ }
1107
+ if (task.tags?.includes('sync-conflict')) {
1108
+ conflictCount++;
1109
+ }
1110
+ }
1111
+ }
1112
+ catch (err) {
1113
+ const message = err instanceof Error ? err.message : String(err);
1114
+ return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
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
+ }
1131
+ // Build cursor info
1132
+ const cursors = {};
1133
+ for (const [key, value] of Object.entries(settings.syncCursors)) {
1134
+ cursors[key] = value;
1135
+ }
1136
+ const mode = getOutputMode(options);
1137
+ const statusData = {
1138
+ enabled,
1139
+ linkedTaskCount,
1140
+ linkedDocumentCount: linkedDocCount,
1141
+ conflictCount,
1142
+ providerTaskCounts,
1143
+ providerDocumentCounts: providerDocCounts,
1144
+ configuredProviders: Object.keys(settings.providers),
1145
+ syncCursors: cursors,
1146
+ pollIntervalMs: settings.pollIntervalMs,
1147
+ defaultDirection: settings.defaultDirection,
1148
+ };
1149
+ if (mode === 'json') {
1150
+ return success(statusData);
1151
+ }
1152
+ if (mode === 'quiet') {
1153
+ return success(`${linkedTaskCount}:${linkedDocCount}:${conflictCount}`);
1154
+ }
1155
+ const lines = [
1156
+ 'External Sync Status',
1157
+ '',
1158
+ ` Enabled: ${enabled ? 'yes' : 'no'}`,
1159
+ ` Linked tasks: ${linkedTaskCount}`,
1160
+ ` Linked documents: ${linkedDocCount}`,
1161
+ ` Pending conflicts: ${conflictCount}`,
1162
+ ` Poll interval: ${settings.pollIntervalMs}ms`,
1163
+ ` Default direction: ${settings.defaultDirection}`,
1164
+ '',
1165
+ ];
1166
+ // Provider breakdown
1167
+ const providerEntries = Object.entries(settings.providers);
1168
+ if (providerEntries.length > 0) {
1169
+ lines.push(' Providers:');
1170
+ for (const [name, config] of providerEntries) {
1171
+ const taskCount = providerTaskCounts[name] ?? 0;
1172
+ const docCount = providerDocCounts[name] ?? 0;
1173
+ const hasToken = config.token ? 'yes' : 'no';
1174
+ lines.push(` ${name}: ${taskCount} linked task(s), ${docCount} linked document(s), token: ${hasToken}, project: ${config.defaultProject ?? '(not set)'}`);
1175
+ }
1176
+ }
1177
+ else {
1178
+ lines.push(' Providers: (none configured)');
1179
+ }
1180
+ // Sync cursors
1181
+ const cursorEntries = Object.entries(cursors);
1182
+ if (cursorEntries.length > 0) {
1183
+ lines.push('');
1184
+ lines.push(' Last sync cursors:');
1185
+ for (const [key, value] of cursorEntries) {
1186
+ lines.push(` ${key}: ${value}`);
1187
+ }
1188
+ }
1189
+ if (conflictCount > 0) {
1190
+ lines.push('');
1191
+ lines.push(` ⚠ ${conflictCount} conflict(s) need resolution. Run "sf external-sync resolve <taskId> --keep local|remote".`);
1192
+ }
1193
+ return success(statusData, lines.join('\n'));
1194
+ }
1195
+ const resolveOptions = [
1196
+ {
1197
+ name: 'keep',
1198
+ short: 'k',
1199
+ description: 'Which version to keep: local or remote',
1200
+ hasValue: true,
1201
+ required: true,
1202
+ },
1203
+ ];
1204
+ async function resolveHandler(args, options) {
1205
+ if (args.length < 1) {
1206
+ return failure('Usage: sf external-sync resolve <elementId> --keep local|remote', ExitCode.INVALID_ARGUMENTS);
1207
+ }
1208
+ const [elementId] = args;
1209
+ const keep = options.keep;
1210
+ if (!keep || (keep !== 'local' && keep !== 'remote')) {
1211
+ return failure('The --keep flag is required and must be either "local" or "remote"', ExitCode.INVALID_ARGUMENTS);
1212
+ }
1213
+ const { api, error } = createAPI(options);
1214
+ if (error) {
1215
+ return failure(error, ExitCode.GENERAL_ERROR);
1216
+ }
1217
+ // Resolve element (task or document)
1218
+ let element;
1219
+ try {
1220
+ element = await api.get(elementId);
1221
+ }
1222
+ catch {
1223
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
1224
+ }
1225
+ if (!element) {
1226
+ return failure(`Element not found: ${elementId}`, ExitCode.NOT_FOUND);
1227
+ }
1228
+ if (element.type !== 'task' && element.type !== 'document') {
1229
+ return failure(`Element ${elementId} is not a task or document (type: ${element.type})`, ExitCode.VALIDATION);
1230
+ }
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);
1234
+ }
1235
+ // Remove sync-conflict tag and update metadata
1236
+ try {
1237
+ const newTags = (elementTags ?? []).filter((t) => t !== 'sync-conflict');
1238
+ const existingMetadata = (element.metadata ?? {});
1239
+ const syncMeta = (existingMetadata._externalSync ?? {});
1240
+ // Record resolution in metadata
1241
+ const updatedSyncMeta = {
1242
+ ...syncMeta,
1243
+ lastConflictResolution: {
1244
+ resolvedAt: new Date().toISOString(),
1245
+ kept: keep,
1246
+ },
1247
+ };
1248
+ // Clear conflict data from metadata
1249
+ const { _syncConflict: _, ...restMetadata } = existingMetadata;
1250
+ await api.update(elementId, {
1251
+ tags: newTags,
1252
+ metadata: {
1253
+ ...restMetadata,
1254
+ _externalSync: updatedSyncMeta,
1255
+ },
1256
+ });
1257
+ }
1258
+ catch (err) {
1259
+ const message = err instanceof Error ? err.message : String(err);
1260
+ return failure(`Failed to resolve conflict: ${message}`, ExitCode.GENERAL_ERROR);
1261
+ }
1262
+ const mode = getOutputMode(options);
1263
+ if (mode === 'json') {
1264
+ return success({ elementId, elementType: element.type, resolved: true, kept: keep });
1265
+ }
1266
+ if (mode === 'quiet') {
1267
+ return success(elementId);
1268
+ }
1269
+ return success({ elementId, resolved: true, kept: keep }, `Resolved sync conflict for ${element.type} ${elementId} (kept: ${keep})`);
1270
+ }
1271
+ const linkAllOptions = [
1272
+ {
1273
+ name: 'provider',
1274
+ short: 'p',
1275
+ description: 'Provider to link to (required)',
1276
+ hasValue: true,
1277
+ required: true,
1278
+ },
1279
+ {
1280
+ name: 'project',
1281
+ description: 'Override the default project',
1282
+ hasValue: true,
1283
+ },
1284
+ {
1285
+ name: 'status',
1286
+ short: 's',
1287
+ description: 'Only link elements with this status (can be repeated)',
1288
+ hasValue: true,
1289
+ array: true,
1290
+ },
1291
+ {
1292
+ name: 'dry-run',
1293
+ short: 'n',
1294
+ description: 'List elements that would be linked without creating external issues/pages',
1295
+ },
1296
+ {
1297
+ name: 'batch-size',
1298
+ short: 'b',
1299
+ description: 'How many elements to process concurrently (default: 10)',
1300
+ hasValue: true,
1301
+ defaultValue: '10',
1302
+ },
1303
+ {
1304
+ name: 'force',
1305
+ short: 'f',
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)',
1318
+ },
1319
+ ];
1320
+ /**
1321
+ * Helper to detect rate limit errors from GitHub or Linear providers.
1322
+ * Returns the reset timestamp (epoch seconds) if available, or undefined.
1323
+ */
1324
+ function isRateLimitError(err) {
1325
+ // Try GitHub error shape
1326
+ if (err &&
1327
+ typeof err === 'object' &&
1328
+ 'isRateLimited' in err &&
1329
+ err.isRateLimited) {
1330
+ const rateLimit = err.rateLimit;
1331
+ return { isRateLimit: true, resetAt: rateLimit?.reset };
1332
+ }
1333
+ // Also check error message for rate limit keywords
1334
+ if (err instanceof Error && /rate.limit/i.test(err.message)) {
1335
+ return { isRateLimit: true };
1336
+ }
1337
+ return { isRateLimit: false };
1338
+ }
1339
+ /**
1340
+ * Extracts validation error details from a GitHub API error response.
1341
+ * GitHub's 422 responses include an `errors` array with `resource`, `field`,
1342
+ * `code`, and sometimes `value` or `message` entries.
1343
+ *
1344
+ * Example output: "invalid label: sf:priority:high"
1345
+ */
1346
+ function extractValidationDetail(err) {
1347
+ if (!err || typeof err !== 'object')
1348
+ return null;
1349
+ const responseBody = err.responseBody;
1350
+ if (!responseBody || !Array.isArray(responseBody.errors))
1351
+ return null;
1352
+ const details = responseBody.errors
1353
+ .map((e) => {
1354
+ const parts = [];
1355
+ if (e.code && typeof e.code === 'string')
1356
+ parts.push(e.code);
1357
+ if (e.field && typeof e.field === 'string')
1358
+ parts.push(e.field);
1359
+ if (e.value !== undefined)
1360
+ parts.push(String(e.value));
1361
+ if (e.message && typeof e.message === 'string')
1362
+ parts.push(e.message);
1363
+ return parts.join(': ');
1364
+ })
1365
+ .filter(Boolean);
1366
+ return details.length > 0 ? details.join('; ') : null;
1367
+ }
1368
+ /**
1369
+ * Creates an ExternalProvider instance from settings for the given provider name.
1370
+ * Returns the provider, project, and direction, or an error message.
1371
+ */
1372
+ async function createProviderFromSettings(providerName, projectOverride, options) {
1373
+ const dbPath = resolveDatabasePath(options);
1374
+ if (!dbPath) {
1375
+ return { error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path' };
1376
+ }
1377
+ try {
1378
+ const backend = createStorage({ path: dbPath, create: true });
1379
+ initializeSchema(backend);
1380
+ // Dynamic import to handle optional peer dependency
1381
+ // @ts-ignore — smithy is an optional runtime dependency, may not be installed
1382
+ const { createSettingsService } = await import('@stoneforge/smithy/services');
1383
+ const settingsService = createSettingsService(backend);
1384
+ const providerConfig = settingsService.getProviderConfig(providerName);
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) {
1395
+ return { error: `Provider "${providerName}" has no token configured. Run "sf external-sync config set-token ${providerName} <token>" first.` };
1396
+ }
1397
+ const project = projectOverride ?? providerConfig?.defaultProject;
1398
+ if (!project) {
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.` };
1400
+ }
1401
+ let provider;
1402
+ // Token is guaranteed non-undefined for non-tokenless providers (validated above)
1403
+ if (providerName === 'github') {
1404
+ const { createGitHubProvider } = await import('../../external-sync/providers/github/index.js');
1405
+ provider = createGitHubProvider({
1406
+ provider: 'github',
1407
+ token: providerConfig.token,
1408
+ apiBaseUrl: providerConfig.apiBaseUrl,
1409
+ defaultProject: project,
1410
+ });
1411
+ }
1412
+ else if (providerName === 'linear') {
1413
+ const { createLinearProvider } = await import('../../external-sync/providers/linear/index.js');
1414
+ provider = createLinearProvider({
1415
+ apiKey: providerConfig.token,
1416
+ });
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
+ }
1428
+ else {
1429
+ return { error: `Unsupported provider: "${providerName}". Supported providers: github, linear, notion, folder` };
1430
+ }
1431
+ const direction = (getValue('externalSync.defaultDirection') ?? 'bidirectional');
1432
+ return { provider, project, direction };
1433
+ }
1434
+ catch (err) {
1435
+ const message = err instanceof Error ? err.message : String(err);
1436
+ if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
1437
+ return { error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.' };
1438
+ }
1439
+ return { error: `Failed to initialize provider: ${message}` };
1440
+ }
1441
+ }
1442
+ /**
1443
+ * Process a batch of tasks: create external issues and link them.
1444
+ * Uses the adapter's field mapping to include priority, taskType, and status
1445
+ * labels on the created external issues.
1446
+ */
1447
+ async function processBatch(tasks, adapter, api, providerName, project, direction, progressLines) {
1448
+ let succeeded = 0;
1449
+ let failed = 0;
1450
+ let rateLimited = false;
1451
+ let resetAt;
1452
+ const fieldMapConfig = getFieldMapConfigForProvider(providerName);
1453
+ for (const task of tasks) {
1454
+ try {
1455
+ // Build the complete external task input using field mapping.
1456
+ // This maps priority → sf:priority:* labels, taskType → sf:type:* labels,
1457
+ // status → open/closed state, user tags → labels, and hydrates description.
1458
+ const externalInput = await taskToExternalTask(task, fieldMapConfig, api);
1459
+ // Create the external issue with fully mapped fields
1460
+ const externalTask = await adapter.createIssue(project, externalInput);
1461
+ // Build the ExternalSyncState metadata
1462
+ const syncState = {
1463
+ provider: providerName,
1464
+ project,
1465
+ externalId: externalTask.externalId,
1466
+ url: externalTask.url,
1467
+ direction,
1468
+ adapterType: 'task',
1469
+ };
1470
+ // Update the task with externalRef and _externalSync metadata
1471
+ const existingMetadata = (task.metadata ?? {});
1472
+ await api.update(task.id, {
1473
+ externalRef: externalTask.url,
1474
+ metadata: {
1475
+ ...existingMetadata,
1476
+ _externalSync: syncState,
1477
+ },
1478
+ });
1479
+ progressLines.push(`Linked ${task.id} → ${externalTask.url}`);
1480
+ succeeded++;
1481
+ }
1482
+ catch (err) {
1483
+ // Check for rate limit errors
1484
+ const rlCheck = isRateLimitError(err);
1485
+ if (rlCheck.isRateLimit) {
1486
+ rateLimited = true;
1487
+ resetAt = rlCheck.resetAt;
1488
+ const message = err instanceof Error ? err.message : String(err);
1489
+ progressLines.push(`Rate limit hit while linking ${task.id}: ${message}`);
1490
+ // Stop processing further tasks in this batch
1491
+ break;
1492
+ }
1493
+ // Log warning and continue with next task
1494
+ const message = err instanceof Error ? err.message : String(err);
1495
+ const detail = extractValidationDetail(err);
1496
+ progressLines.push(detail
1497
+ ? `Failed to link ${task.id}: ${message} — ${detail}`
1498
+ : `Failed to link ${task.id}: ${message}`);
1499
+ failed++;
1500
+ }
1501
+ }
1502
+ return { succeeded, failed, rateLimited, resetAt };
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
+ }
1814
+ async function linkAllHandler(_args, options) {
1815
+ const providerName = options.provider;
1816
+ if (!providerName) {
1817
+ return failure('The --provider flag is required. Usage: sf external-sync link-all --provider <provider>', ExitCode.INVALID_ARGUMENTS);
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
+ }
1827
+ const isDryRun = options['dry-run'] ?? false;
1828
+ const force = options.force ?? false;
1829
+ const batchSize = parseInt(options['batch-size'] ?? '10', 10);
1830
+ if (isNaN(batchSize) || batchSize < 1) {
1831
+ return failure('--batch-size must be a positive integer', ExitCode.INVALID_ARGUMENTS);
1832
+ }
1833
+ // Parse status filters
1834
+ const statusFilters = [];
1835
+ if (options.status) {
1836
+ if (Array.isArray(options.status)) {
1837
+ statusFilters.push(...options.status);
1838
+ }
1839
+ else {
1840
+ statusFilters.push(options.status);
1841
+ }
1842
+ }
1843
+ // Get API for querying/updating tasks
1844
+ const { api, error: apiError } = createAPI(options);
1845
+ if (apiError) {
1846
+ return failure(apiError, ExitCode.GENERAL_ERROR);
1847
+ }
1848
+ // Query all tasks
1849
+ let allTasks;
1850
+ try {
1851
+ const results = await api.list({ type: 'task' });
1852
+ allTasks = results;
1853
+ }
1854
+ catch (err) {
1855
+ const message = err instanceof Error ? err.message : String(err);
1856
+ return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
1857
+ }
1858
+ // Filter tasks: unlinked tasks, plus (with --force) tasks linked to any provider
1859
+ let relinkedFromProvider;
1860
+ let relinkCount = 0;
1861
+ let tasksToLink = allTasks.filter((task) => {
1862
+ const metadata = (task.metadata ?? {});
1863
+ const syncState = metadata._externalSync;
1864
+ if (!syncState) {
1865
+ // Unlinked task — always include
1866
+ return true;
1867
+ }
1868
+ if (force) {
1869
+ // Force mode: re-link regardless of current provider
1870
+ if (syncState.provider !== providerName) {
1871
+ relinkedFromProvider = syncState.provider;
1872
+ }
1873
+ relinkCount++;
1874
+ return true;
1875
+ }
1876
+ // Already linked (force not set) — skip
1877
+ return false;
1878
+ });
1879
+ // Apply status filter if specified
1880
+ if (statusFilters.length > 0) {
1881
+ tasksToLink = tasksToLink.filter((task) => statusFilters.includes(task.status));
1882
+ }
1883
+ // Skip tombstone tasks by default (soft-deleted)
1884
+ tasksToLink = tasksToLink.filter((task) => task.status !== 'tombstone');
1885
+ const mode = getOutputMode(options);
1886
+ if (tasksToLink.length === 0) {
1887
+ const result = { linked: 0, failed: 0, skipped: 0, total: 0, dryRun: isDryRun };
1888
+ if (mode === 'json') {
1889
+ return success(result);
1890
+ }
1891
+ const hint = force
1892
+ ? 'No tasks found to re-link matching the specified criteria.'
1893
+ : 'No unlinked tasks found. Use --force to re-link existing tasks.';
1894
+ return success(result, hint);
1895
+ }
1896
+ // Dry run — just list tasks that would be linked
1897
+ if (isDryRun) {
1898
+ const taskList = tasksToLink.map((t) => ({
1899
+ id: t.id,
1900
+ title: t.title,
1901
+ status: t.status,
1902
+ }));
1903
+ const jsonResult = {
1904
+ dryRun: true,
1905
+ provider: providerName,
1906
+ total: tasksToLink.length,
1907
+ tasks: taskList,
1908
+ };
1909
+ if (force && relinkCount > 0) {
1910
+ jsonResult.force = true;
1911
+ jsonResult.relinkCount = relinkCount;
1912
+ jsonResult.relinkFromProvider = relinkedFromProvider;
1913
+ }
1914
+ if (mode === 'json') {
1915
+ return success(jsonResult);
1916
+ }
1917
+ if (mode === 'quiet') {
1918
+ return success(String(tasksToLink.length));
1919
+ }
1920
+ const lines = [];
1921
+ if (force && relinkCount > 0) {
1922
+ lines.push(`Dry run: Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1923
+ const newCount = tasksToLink.length - relinkCount;
1924
+ if (newCount > 0) {
1925
+ lines.push(` Plus ${newCount} unlinked task(s) to link`);
1926
+ }
1927
+ }
1928
+ else {
1929
+ lines.push(`Dry run: ${tasksToLink.length} task(s) would be linked to ${providerName}`);
1930
+ }
1931
+ lines.push('');
1932
+ for (const task of tasksToLink) {
1933
+ lines.push(` ${task.id} ${task.status.padEnd(12)} ${task.title}`);
1934
+ }
1935
+ return success(jsonResult, lines.join('\n'));
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
+ }
1942
+ // Create provider for actual linking (supports DI for testing)
1943
+ const providerFactory = options._providerFactory ?? createProviderFromSettings;
1944
+ const { provider: externalProvider, project, direction, error: providerError, } = await providerFactory(providerName, options.project, options);
1945
+ if (providerError) {
1946
+ return failure(providerError, ExitCode.GENERAL_ERROR);
1947
+ }
1948
+ // Get the task adapter
1949
+ const adapter = externalProvider.getTaskAdapter?.();
1950
+ if (!adapter) {
1951
+ return failure(`Provider "${providerName}" does not support task sync`, ExitCode.GENERAL_ERROR);
1952
+ }
1953
+ const progressLines = [];
1954
+ // Log re-linking info when using --force
1955
+ if (force && relinkCount > 0 && mode !== 'json' && mode !== 'quiet') {
1956
+ progressLines.push(`Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1957
+ const newCount = tasksToLink.length - relinkCount;
1958
+ if (newCount > 0) {
1959
+ progressLines.push(`Linking ${newCount} unlinked task(s)`);
1960
+ }
1961
+ progressLines.push('');
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');
1967
+ // Process tasks in batches
1968
+ let totalSucceeded = 0;
1969
+ let totalFailed = 0;
1970
+ let rateLimited = false;
1971
+ let rateLimitResetAt;
1972
+ let completed = 0;
1973
+ for (let i = 0; i < tasksToLink.length; i += batchSize) {
1974
+ const batch = tasksToLink.slice(i, i + batchSize);
1975
+ const batchResult = await processBatch(batch, adapter, api, providerName, project, direction, progressLines);
1976
+ totalSucceeded += batchResult.succeeded;
1977
+ totalFailed += batchResult.failed;
1978
+ completed += batch.length;
1979
+ progress.update(completed);
1980
+ if (batchResult.rateLimited) {
1981
+ rateLimited = true;
1982
+ rateLimitResetAt = batchResult.resetAt;
1983
+ break;
1984
+ }
1985
+ // Small delay between batches to be gentle on the API
1986
+ if (i + batchSize < tasksToLink.length) {
1987
+ await new Promise((resolve) => setTimeout(resolve, 100));
1988
+ }
1989
+ }
1990
+ progress.finish();
1991
+ const skipped = tasksToLink.length - totalSucceeded - totalFailed;
1992
+ // Build result
1993
+ const result = {
1994
+ provider: providerName,
1995
+ project,
1996
+ linked: totalSucceeded,
1997
+ failed: totalFailed,
1998
+ skipped,
1999
+ total: tasksToLink.length,
2000
+ rateLimited,
2001
+ rateLimitResetAt: rateLimitResetAt ? new Date(rateLimitResetAt * 1000).toISOString() : undefined,
2002
+ };
2003
+ if (force && relinkCount > 0) {
2004
+ result.force = true;
2005
+ result.relinkCount = relinkCount;
2006
+ result.relinkFromProvider = relinkedFromProvider;
2007
+ }
2008
+ if (mode === 'json') {
2009
+ return success(result);
2010
+ }
2011
+ if (mode === 'quiet') {
2012
+ return success(String(totalSucceeded));
2013
+ }
2014
+ // Human-readable output
2015
+ const lines = [...progressLines, ''];
2016
+ // Summary
2017
+ const summaryParts = [`Linked ${totalSucceeded} tasks to ${providerName}`];
2018
+ if (totalFailed > 0) {
2019
+ summaryParts.push(`(${totalFailed} failed)`);
2020
+ }
2021
+ if (skipped > 0) {
2022
+ summaryParts.push(`(${skipped} skipped)`);
2023
+ }
2024
+ lines.push(summaryParts.join(' '));
2025
+ if (rateLimited) {
2026
+ lines.push('');
2027
+ if (rateLimitResetAt) {
2028
+ const resetDate = new Date(rateLimitResetAt * 1000);
2029
+ lines.push(`Rate limit reached. Resets at ${resetDate.toISOString()}. Re-run this command after the reset to link remaining tasks.`);
2030
+ }
2031
+ else {
2032
+ lines.push('Rate limit reached. Re-run this command later to link remaining tasks.');
2033
+ }
2034
+ }
2035
+ return success(result, lines.join('\n'));
2036
+ }
2037
+ // ============================================================================
2038
+ // Config Parent Command (for subcommand structure)
2039
+ // ============================================================================
2040
+ const configSetTokenCommand = {
2041
+ name: 'set-token',
2042
+ description: 'Set authentication token for a provider',
2043
+ usage: 'sf external-sync config set-token <provider> <token>',
2044
+ help: `Store an authentication token for an external sync provider.
2045
+
2046
+ The token is stored in the local SQLite database (not git-tracked).
2047
+
2048
+ Arguments:
2049
+ provider Provider name (e.g., github, linear)
2050
+ token Authentication token
2051
+
2052
+ Examples:
2053
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
2054
+ sf external-sync config set-token linear lin_api_xxxxxxxxxxxx`,
2055
+ options: [],
2056
+ handler: configSetTokenHandler,
2057
+ };
2058
+ const configSetProjectCommand = {
2059
+ name: 'set-project',
2060
+ description: 'Set default project for a provider',
2061
+ usage: 'sf external-sync config set-project <provider> <project>',
2062
+ help: `Set the default project (e.g., owner/repo) for an external sync provider.
2063
+
2064
+ This is used when linking tasks with bare issue numbers.
2065
+
2066
+ Arguments:
2067
+ provider Provider name (e.g., github, linear)
2068
+ project Project identifier (e.g., owner/repo for GitHub)
2069
+
2070
+ Examples:
2071
+ sf external-sync config set-project github my-org/my-repo
2072
+ sf external-sync config set-project linear MY-PROJECT`,
2073
+ options: [],
2074
+ handler: configSetProjectHandler,
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
+ };
2082
+ const configSetAutoLinkCommand = {
2083
+ name: 'set-auto-link',
2084
+ description: 'Enable auto-link with a 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.
2090
+
2091
+ Use --type to specify whether to configure task or document auto-linking.
2092
+ Defaults to task for backwards compatibility.
2093
+
2094
+ Arguments:
2095
+ provider Provider name (github, linear, notion, or folder)
2096
+
2097
+ Options:
2098
+ --type, -t Type of auto-link: task or document (default: task)
2099
+
2100
+ Examples:
2101
+ sf external-sync config set-auto-link github
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],
2106
+ handler: configSetAutoLinkHandler,
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
+ };
2114
+ const configDisableAutoLinkCommand = {
2115
+ name: 'disable-auto-link',
2116
+ description: 'Disable auto-link',
2117
+ usage: 'sf external-sync config disable-auto-link [--type task|document|all]',
2118
+ help: `Disable auto-link for new elements.
2119
+
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)
2129
+
2130
+ Examples:
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],
2135
+ handler: configDisableAutoLinkHandler,
2136
+ };
2137
+ const configParentCommand = {
2138
+ name: 'config',
2139
+ description: 'Show or set provider configuration',
2140
+ usage: 'sf external-sync config [set-token|set-project|set-auto-link|disable-auto-link]',
2141
+ help: `Show current external sync provider configuration.
2142
+
2143
+ Tokens are masked in output for security.
2144
+
2145
+ Subcommands:
2146
+ set-token <provider> <token> Store auth token
2147
+ set-project <provider> <project> Set default project
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
2150
+
2151
+ Examples:
2152
+ sf external-sync config
2153
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
2154
+ sf external-sync config set-project github my-org/my-repo
2155
+ sf external-sync config set-auto-link github
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`,
2159
+ subcommands: {
2160
+ 'set-token': configSetTokenCommand,
2161
+ 'set-project': configSetProjectCommand,
2162
+ 'set-auto-link': configSetAutoLinkCommand,
2163
+ 'disable-auto-link': configDisableAutoLinkCommand,
2164
+ },
2165
+ options: [],
2166
+ handler: configHandler,
2167
+ };
2168
+ // ============================================================================
2169
+ // Link Parent Command
2170
+ // ============================================================================
2171
+ const linkCommand = {
2172
+ name: 'link',
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.
2176
+
2177
+ Sets the element's externalRef and _externalSync metadata. If given a bare
2178
+ issue number, constructs the URL from the provider's default project.
2179
+
2180
+ Use --type document to link a document element (default: task).
2181
+
2182
+ Arguments:
2183
+ elementId Stoneforge element ID (task or document)
2184
+ url-or-id Full URL, bare issue number, or external ID
2185
+
2186
+ Options:
2187
+ -p, --provider Provider name (default: github)
2188
+ -t, --type Element type: task or document (default: task)
2189
+
2190
+ Examples:
2191
+ sf external-sync link el-abc123 https://github.com/org/repo/issues/42
2192
+ sf external-sync link el-abc123 42
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`,
2195
+ options: linkOptions,
2196
+ handler: linkHandler,
2197
+ };
2198
+ // ============================================================================
2199
+ // Link-All Command
2200
+ // ============================================================================
2201
+ const linkAllCommand = {
2202
+ name: 'link-all',
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.
2210
+
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.
2215
+
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.
2219
+
2220
+ Options:
2221
+ -p, --provider <name> Provider to link to (required)
2222
+ -t, --type <type> Element type: task or document (default: task)
2223
+ --project <project> Override the default project
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)
2229
+
2230
+ Rate Limits:
2231
+ If a rate limit is hit, the command stops gracefully and reports how
2232
+ many elements were linked. Re-run the command to continue linking.
2233
+
2234
+ Examples:
2235
+ sf external-sync link-all --provider github
2236
+ sf external-sync link-all --provider github --status open
2237
+ sf external-sync link-all --provider github --status open --status in_progress
2238
+ sf external-sync link-all --provider github --dry-run
2239
+ sf external-sync link-all --provider github --project my-org/my-repo
2240
+ sf external-sync link-all --provider linear --batch-size 5
2241
+ sf external-sync link-all --provider linear --force
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`,
2245
+ options: linkAllOptions,
2246
+ handler: linkAllHandler,
2247
+ };
2248
+ // ============================================================================
2249
+ // Unlink Command
2250
+ // ============================================================================
2251
+ const unlinkCommand = {
2252
+ name: 'unlink',
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.
2256
+
2257
+ Clears the element's externalRef field and _externalSync metadata.
2258
+ Works with both tasks and documents.
2259
+
2260
+ Arguments:
2261
+ elementId Stoneforge element ID (task or document)
2262
+
2263
+ Examples:
2264
+ sf external-sync unlink el-abc123`,
2265
+ options: [],
2266
+ handler: unlinkHandler,
2267
+ };
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
+ // ============================================================================
2296
+ // Push Command
2297
+ // ============================================================================
2298
+ const pushCommand = {
2299
+ name: 'push',
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.
2303
+
2304
+ If specific element IDs are given, pushes only those elements. With --all,
2305
+ pushes every element that has an external link.
2306
+
2307
+ Use --force to push all linked elements regardless of whether their local
2308
+ content has changed. This is useful when label generation logic changes
2309
+ and the external representation needs to be refreshed.
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
+
2316
+ Arguments:
2317
+ elementId... One or more element IDs to push (optional with --all)
2318
+
2319
+ Options:
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)
2324
+
2325
+ Examples:
2326
+ sf external-sync push el-abc123
2327
+ sf external-sync push el-abc123 el-def456
2328
+ sf external-sync push --all
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
2333
+ sf external-sync push el-abc123 --force`,
2334
+ options: pushOptions,
2335
+ handler: pushHandler,
2336
+ };
2337
+ // ============================================================================
2338
+ // Pull Command
2339
+ // ============================================================================
2340
+ const pullCommand = {
2341
+ name: 'pull',
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).
2345
+
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.
2349
+
2350
+ Options:
2351
+ -p, --provider <name> Pull from specific provider (default: all configured)
2352
+ -d, --discover Discover new unlinked issues
2353
+ -t, --type <type> Element type to pull: task, document, or all (default: all)
2354
+
2355
+ Examples:
2356
+ sf external-sync pull
2357
+ sf external-sync pull --provider github
2358
+ sf external-sync pull --discover
2359
+ sf external-sync pull --type document
2360
+ sf external-sync pull --type task --provider notion`,
2361
+ options: pullOptions,
2362
+ handler: pullHandler,
2363
+ };
2364
+ // ============================================================================
2365
+ // Sync Command
2366
+ // ============================================================================
2367
+ const biSyncCommand = {
2368
+ name: 'sync',
2369
+ description: 'Bidirectional sync with external services',
2370
+ usage: 'sf external-sync sync [--dry-run] [--type task|document|all]',
2371
+ help: `Run bidirectional sync between Stoneforge and external services.
2372
+
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.
2377
+
2378
+ Options:
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)
2381
+
2382
+ Examples:
2383
+ sf external-sync sync
2384
+ sf external-sync sync --dry-run
2385
+ sf external-sync sync --type document
2386
+ sf external-sync sync --type task`,
2387
+ options: syncOptions,
2388
+ handler: syncHandler,
2389
+ };
2390
+ // ============================================================================
2391
+ // Status Command
2392
+ // ============================================================================
2393
+ const extStatusCommand = {
2394
+ name: 'status',
2395
+ description: 'Show external sync state',
2396
+ usage: 'sf external-sync status',
2397
+ help: `Show the current external sync state.
2398
+
2399
+ Displays linked task and document counts, last sync times, configured
2400
+ providers, and pending conflicts.
2401
+
2402
+ Examples:
2403
+ sf external-sync status
2404
+ sf external-sync status --json`,
2405
+ options: [],
2406
+ handler: statusHandler,
2407
+ };
2408
+ // ============================================================================
2409
+ // Resolve Command
2410
+ // ============================================================================
2411
+ const resolveCommand = {
2412
+ name: 'resolve',
2413
+ description: 'Resolve a sync conflict',
2414
+ usage: 'sf external-sync resolve <elementId> --keep local|remote',
2415
+ help: `Resolve a sync conflict by choosing which version to keep.
2416
+
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.
2419
+
2420
+ Arguments:
2421
+ elementId Element ID with a sync conflict (task or document)
2422
+
2423
+ Options:
2424
+ -k, --keep <version> Which version to keep: local or remote (required)
2425
+
2426
+ Examples:
2427
+ sf external-sync resolve el-abc123 --keep local
2428
+ sf external-sync resolve el-abc123 --keep remote`,
2429
+ options: resolveOptions,
2430
+ handler: resolveHandler,
2431
+ };
2432
+ // ============================================================================
2433
+ // External Sync Parent Command
2434
+ // ============================================================================
2435
+ export const externalSyncCommand = {
2436
+ name: 'external-sync',
2437
+ description: 'External service sync commands',
2438
+ usage: 'sf external-sync <command> [options]',
2439
+ help: `Manage bidirectional synchronization between Stoneforge and external services
2440
+ (GitHub Issues, Linear, etc.).
2441
+
2442
+ Commands:
2443
+ config Show provider configuration
2444
+ config set-token <provider> <token> Store auth token
2445
+ config set-project <provider> <project> Set default project
2446
+ config set-auto-link <provider> [--type task|document] Enable auto-link
2447
+ config disable-auto-link [--type task|document|all] Disable auto-link
2448
+ link <taskId> <url-or-issue-number> Link task to external issue
2449
+ link-all --provider <name> Bulk-link all unlinked tasks
2450
+ unlink <elementId> Remove external link
2451
+ unlink-all [--provider] [--type] Bulk-remove all external links
2452
+ push [taskId...] [--force] Push linked task(s) to external
2453
+ pull Pull changes from external
2454
+ sync [--dry-run] Bidirectional sync
2455
+ status Show sync state
2456
+ resolve <taskId> --keep local|remote Resolve sync conflict
2457
+
2458
+ Examples:
2459
+ sf external-sync config
2460
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
2461
+ sf external-sync config set-project github my-org/my-repo
2462
+ sf external-sync config set-auto-link github
2463
+ sf external-sync config set-auto-link --type document folder
2464
+ sf external-sync config disable-auto-link
2465
+ sf external-sync config disable-auto-link --type document
2466
+ sf external-sync link el-abc123 42
2467
+ sf external-sync link-all --provider github
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
2471
+ sf external-sync push --all
2472
+ sf external-sync push --all --force
2473
+ sf external-sync pull
2474
+ sf external-sync sync --dry-run
2475
+ sf external-sync status
2476
+ sf external-sync resolve el-abc123 --keep local`,
2477
+ subcommands: {
2478
+ config: configParentCommand,
2479
+ link: linkCommand,
2480
+ 'link-all': linkAllCommand,
2481
+ unlink: unlinkCommand,
2482
+ 'unlink-all': unlinkAllCommand,
2483
+ push: pushCommand,
2484
+ pull: pullCommand,
2485
+ sync: biSyncCommand,
2486
+ status: extStatusCommand,
2487
+ resolve: resolveCommand,
2488
+ },
2489
+ handler: async (_args, options) => {
2490
+ const mode = getOutputMode(options);
2491
+ if (mode === 'json') {
2492
+ return success({
2493
+ commands: ['config', 'link', 'link-all', 'unlink', 'unlink-all', 'push', 'pull', 'sync', 'status', 'resolve'],
2494
+ });
2495
+ }
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);
2497
+ },
2498
+ };
2499
+ //# sourceMappingURL=external-sync.js.map