@stoneforge/quarry 1.10.2 → 1.13.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 (137) hide show
  1. package/README.md +3 -1
  2. package/dist/cli/commands/admin.d.ts.map +1 -1
  3. package/dist/cli/commands/admin.js +313 -3
  4. package/dist/cli/commands/admin.js.map +1 -1
  5. package/dist/cli/commands/auto-link-helper.d.ts +33 -0
  6. package/dist/cli/commands/auto-link-helper.d.ts.map +1 -0
  7. package/dist/cli/commands/auto-link-helper.js +73 -0
  8. package/dist/cli/commands/auto-link-helper.js.map +1 -0
  9. package/dist/cli/commands/crud.d.ts +1 -0
  10. package/dist/cli/commands/crud.d.ts.map +1 -1
  11. package/dist/cli/commands/crud.js +44 -5
  12. package/dist/cli/commands/crud.js.map +1 -1
  13. package/dist/cli/commands/docs.d.ts +1 -0
  14. package/dist/cli/commands/docs.d.ts.map +1 -1
  15. package/dist/cli/commands/docs.js +81 -1
  16. package/dist/cli/commands/docs.js.map +1 -1
  17. package/dist/cli/commands/external-sync.d.ts +17 -0
  18. package/dist/cli/commands/external-sync.d.ts.map +1 -0
  19. package/dist/cli/commands/external-sync.js +1647 -0
  20. package/dist/cli/commands/external-sync.js.map +1 -0
  21. package/dist/cli/commands/log.d.ts +18 -0
  22. package/dist/cli/commands/log.d.ts.map +1 -0
  23. package/dist/cli/commands/log.js +282 -0
  24. package/dist/cli/commands/log.js.map +1 -0
  25. package/dist/cli/commands/metrics.d.ts +9 -0
  26. package/dist/cli/commands/metrics.d.ts.map +1 -0
  27. package/dist/cli/commands/metrics.js +219 -0
  28. package/dist/cli/commands/metrics.js.map +1 -0
  29. package/dist/cli/runner.d.ts.map +1 -1
  30. package/dist/cli/runner.js +8 -0
  31. package/dist/cli/runner.js.map +1 -1
  32. package/dist/config/config.d.ts.map +1 -1
  33. package/dist/config/config.js +28 -0
  34. package/dist/config/config.js.map +1 -1
  35. package/dist/config/defaults.d.ts +13 -1
  36. package/dist/config/defaults.d.ts.map +1 -1
  37. package/dist/config/defaults.js +21 -0
  38. package/dist/config/defaults.js.map +1 -1
  39. package/dist/config/file.d.ts.map +1 -1
  40. package/dist/config/file.js +61 -0
  41. package/dist/config/file.js.map +1 -1
  42. package/dist/config/index.d.ts +3 -3
  43. package/dist/config/index.d.ts.map +1 -1
  44. package/dist/config/index.js +2 -2
  45. package/dist/config/index.js.map +1 -1
  46. package/dist/config/merge.d.ts.map +1 -1
  47. package/dist/config/merge.js +46 -1
  48. package/dist/config/merge.js.map +1 -1
  49. package/dist/config/types.d.ts +63 -1
  50. package/dist/config/types.d.ts.map +1 -1
  51. package/dist/config/types.js +30 -0
  52. package/dist/config/types.js.map +1 -1
  53. package/dist/config/validation.d.ts.map +1 -1
  54. package/dist/config/validation.js +51 -1
  55. package/dist/config/validation.js.map +1 -1
  56. package/dist/external-sync/adapters/task-sync-adapter.d.ts +177 -0
  57. package/dist/external-sync/adapters/task-sync-adapter.d.ts.map +1 -0
  58. package/dist/external-sync/adapters/task-sync-adapter.js +353 -0
  59. package/dist/external-sync/adapters/task-sync-adapter.js.map +1 -0
  60. package/dist/external-sync/auto-link.d.ts +66 -0
  61. package/dist/external-sync/auto-link.d.ts.map +1 -0
  62. package/dist/external-sync/auto-link.js +98 -0
  63. package/dist/external-sync/auto-link.js.map +1 -0
  64. package/dist/external-sync/conflict-resolver.d.ts +170 -0
  65. package/dist/external-sync/conflict-resolver.d.ts.map +1 -0
  66. package/dist/external-sync/conflict-resolver.js +580 -0
  67. package/dist/external-sync/conflict-resolver.js.map +1 -0
  68. package/dist/external-sync/index.d.ts +20 -0
  69. package/dist/external-sync/index.d.ts.map +1 -0
  70. package/dist/external-sync/index.js +20 -0
  71. package/dist/external-sync/index.js.map +1 -0
  72. package/dist/external-sync/provider-registry.d.ts +109 -0
  73. package/dist/external-sync/provider-registry.d.ts.map +1 -0
  74. package/dist/external-sync/provider-registry.js +188 -0
  75. package/dist/external-sync/provider-registry.js.map +1 -0
  76. package/dist/external-sync/providers/github/github-api.d.ts +271 -0
  77. package/dist/external-sync/providers/github/github-api.d.ts.map +1 -0
  78. package/dist/external-sync/providers/github/github-api.js +366 -0
  79. package/dist/external-sync/providers/github/github-api.js.map +1 -0
  80. package/dist/external-sync/providers/github/github-field-map.d.ts +76 -0
  81. package/dist/external-sync/providers/github/github-field-map.d.ts.map +1 -0
  82. package/dist/external-sync/providers/github/github-field-map.js +157 -0
  83. package/dist/external-sync/providers/github/github-field-map.js.map +1 -0
  84. package/dist/external-sync/providers/github/github-provider.d.ts +36 -0
  85. package/dist/external-sync/providers/github/github-provider.d.ts.map +1 -0
  86. package/dist/external-sync/providers/github/github-provider.js +212 -0
  87. package/dist/external-sync/providers/github/github-provider.js.map +1 -0
  88. package/dist/external-sync/providers/github/github-task-adapter.d.ts +135 -0
  89. package/dist/external-sync/providers/github/github-task-adapter.d.ts.map +1 -0
  90. package/dist/external-sync/providers/github/github-task-adapter.js +374 -0
  91. package/dist/external-sync/providers/github/github-task-adapter.js.map +1 -0
  92. package/dist/external-sync/providers/github/index.d.ts +12 -0
  93. package/dist/external-sync/providers/github/index.d.ts.map +1 -0
  94. package/dist/external-sync/providers/github/index.js +15 -0
  95. package/dist/external-sync/providers/github/index.js.map +1 -0
  96. package/dist/external-sync/providers/index.d.ts +9 -0
  97. package/dist/external-sync/providers/index.d.ts.map +1 -0
  98. package/dist/external-sync/providers/index.js +10 -0
  99. package/dist/external-sync/providers/index.js.map +1 -0
  100. package/dist/external-sync/providers/linear/index.d.ts +19 -0
  101. package/dist/external-sync/providers/linear/index.d.ts.map +1 -0
  102. package/dist/external-sync/providers/linear/index.js +19 -0
  103. package/dist/external-sync/providers/linear/index.js.map +1 -0
  104. package/dist/external-sync/providers/linear/linear-api.d.ts +252 -0
  105. package/dist/external-sync/providers/linear/linear-api.d.ts.map +1 -0
  106. package/dist/external-sync/providers/linear/linear-api.js +522 -0
  107. package/dist/external-sync/providers/linear/linear-api.js.map +1 -0
  108. package/dist/external-sync/providers/linear/linear-field-map.d.ts +135 -0
  109. package/dist/external-sync/providers/linear/linear-field-map.d.ts.map +1 -0
  110. package/dist/external-sync/providers/linear/linear-field-map.js +338 -0
  111. package/dist/external-sync/providers/linear/linear-field-map.js.map +1 -0
  112. package/dist/external-sync/providers/linear/linear-provider.d.ts +52 -0
  113. package/dist/external-sync/providers/linear/linear-provider.d.ts.map +1 -0
  114. package/dist/external-sync/providers/linear/linear-provider.js +169 -0
  115. package/dist/external-sync/providers/linear/linear-provider.js.map +1 -0
  116. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts +190 -0
  117. package/dist/external-sync/providers/linear/linear-task-adapter.d.ts.map +1 -0
  118. package/dist/external-sync/providers/linear/linear-task-adapter.js +521 -0
  119. package/dist/external-sync/providers/linear/linear-task-adapter.js.map +1 -0
  120. package/dist/external-sync/providers/linear/linear-types.d.ts +114 -0
  121. package/dist/external-sync/providers/linear/linear-types.d.ts.map +1 -0
  122. package/dist/external-sync/providers/linear/linear-types.js +10 -0
  123. package/dist/external-sync/providers/linear/linear-types.js.map +1 -0
  124. package/dist/external-sync/sync-engine.d.ts +298 -0
  125. package/dist/external-sync/sync-engine.d.ts.map +1 -0
  126. package/dist/external-sync/sync-engine.js +785 -0
  127. package/dist/external-sync/sync-engine.js.map +1 -0
  128. package/dist/index.d.ts +1 -0
  129. package/dist/index.d.ts.map +1 -1
  130. package/dist/index.js +2 -0
  131. package/dist/index.js.map +1 -1
  132. package/dist/services/inbox.js +1 -1
  133. package/dist/sync/hash.d.ts +5 -0
  134. package/dist/sync/hash.d.ts.map +1 -1
  135. package/dist/sync/hash.js +21 -2
  136. package/dist/sync/hash.js.map +1 -1
  137. package/package.json +11 -5
@@ -0,0 +1,1647 @@
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 to an external issue
7
+ * - link-all: Bulk-link all unlinked tasks to external issues
8
+ * - unlink: Remove external link from a task
9
+ * - push: Push linked tasks to external service
10
+ * - pull: Pull changes from external for linked tasks
11
+ * - sync: Bidirectional sync (push + pull)
12
+ * - status: Show sync state overview
13
+ * - resolve: Resolve sync conflicts
14
+ */
15
+ import { success, failure, ExitCode } from '../types.js';
16
+ import { getOutputMode } from '../formatter.js';
17
+ import { createAPI, resolveDatabasePath } from '../db.js';
18
+ import { createStorage, initializeSchema } from '@stoneforge/storage';
19
+ import { getValue, setValue, VALID_AUTO_LINK_PROVIDERS } from '../../config/index.js';
20
+ import { taskToExternalTask, getFieldMapConfigForProvider } from '../../external-sync/adapters/task-sync-adapter.js';
21
+ // ============================================================================
22
+ // Settings Service Helper
23
+ // ============================================================================
24
+ /**
25
+ * Dynamically imports and creates a SettingsService from a storage backend.
26
+ * Uses optional peer dependency @stoneforge/smithy.
27
+ */
28
+ async function createSettingsServiceFromOptions(options) {
29
+ const dbPath = resolveDatabasePath(options);
30
+ if (!dbPath) {
31
+ return {
32
+ settingsService: null,
33
+ error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path',
34
+ };
35
+ }
36
+ try {
37
+ const backend = createStorage({ path: dbPath, create: true });
38
+ initializeSchema(backend);
39
+ // Dynamic import to handle optional peer dependency
40
+ const { createSettingsService } = await import('@stoneforge/smithy/services');
41
+ return { settingsService: createSettingsService(backend) };
42
+ }
43
+ catch (err) {
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ // If the import fails, the smithy package isn't available
46
+ if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
47
+ return {
48
+ settingsService: null,
49
+ error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.',
50
+ };
51
+ }
52
+ return {
53
+ settingsService: null,
54
+ error: `Failed to initialize settings: ${message}`,
55
+ };
56
+ }
57
+ }
58
+ // ============================================================================
59
+ // Token Masking
60
+ // ============================================================================
61
+ /**
62
+ * Masks a token for display, showing only first 4 and last 4 characters
63
+ */
64
+ function maskToken(token) {
65
+ if (token.length <= 8) {
66
+ return '****';
67
+ }
68
+ return `${token.slice(0, 4)}...${token.slice(-4)}`;
69
+ }
70
+ // ============================================================================
71
+ // Config Command
72
+ // ============================================================================
73
+ async function configHandler(args, options) {
74
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
75
+ if (error) {
76
+ return failure(error, ExitCode.GENERAL_ERROR);
77
+ }
78
+ const settings = settingsService.getExternalSyncSettings();
79
+ const mode = getOutputMode(options);
80
+ // Also get file-based config for display
81
+ const enabled = getValue('externalSync.enabled');
82
+ const conflictStrategy = getValue('externalSync.conflictStrategy');
83
+ const defaultDirection = getValue('externalSync.defaultDirection');
84
+ const pollInterval = getValue('externalSync.pollInterval');
85
+ const autoLink = getValue('externalSync.autoLink');
86
+ const autoLinkProvider = getValue('externalSync.autoLinkProvider');
87
+ const configData = {
88
+ enabled,
89
+ conflictStrategy,
90
+ defaultDirection,
91
+ pollInterval,
92
+ autoLink,
93
+ autoLinkProvider,
94
+ providers: Object.fromEntries(Object.entries(settings.providers).map(([name, config]) => [
95
+ name,
96
+ {
97
+ ...config,
98
+ token: config.token ? maskToken(config.token) : undefined,
99
+ },
100
+ ])),
101
+ };
102
+ if (mode === 'json') {
103
+ return success(configData);
104
+ }
105
+ if (mode === 'quiet') {
106
+ const providerNames = Object.keys(settings.providers);
107
+ return success(providerNames.length > 0 ? providerNames.join(',') : 'none');
108
+ }
109
+ // Human-readable output
110
+ const lines = [
111
+ 'External Sync Configuration',
112
+ '',
113
+ ` Enabled: ${enabled ? 'yes' : 'no'}`,
114
+ ` Conflict strategy: ${conflictStrategy}`,
115
+ ` Default direction: ${defaultDirection}`,
116
+ ` Poll interval: ${pollInterval}ms`,
117
+ ` Auto-link: ${autoLink ? 'yes' : 'no'}`,
118
+ ` Auto-link provider: ${autoLinkProvider ?? '(not set)'}`,
119
+ '',
120
+ ];
121
+ const providerEntries = Object.entries(settings.providers);
122
+ if (providerEntries.length === 0) {
123
+ lines.push(' Providers: (none configured)');
124
+ lines.push('');
125
+ lines.push(' Run "sf external-sync config set-token <provider> <token>" to configure a provider.');
126
+ }
127
+ else {
128
+ lines.push(' Providers:');
129
+ for (const [name, config] of providerEntries) {
130
+ lines.push(` ${name}:`);
131
+ lines.push(` Token: ${config.token ? maskToken(config.token) : '(not set)'}`);
132
+ lines.push(` API URL: ${config.apiBaseUrl ?? '(default)'}`);
133
+ lines.push(` Default project: ${config.defaultProject ?? '(not set)'}`);
134
+ }
135
+ }
136
+ return success(configData, lines.join('\n'));
137
+ }
138
+ // ============================================================================
139
+ // Config Set-Token Command
140
+ // ============================================================================
141
+ async function configSetTokenHandler(args, options) {
142
+ if (args.length < 2) {
143
+ return failure('Usage: sf external-sync config set-token <provider> <token>', ExitCode.INVALID_ARGUMENTS);
144
+ }
145
+ const [provider, token] = args;
146
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
147
+ if (error) {
148
+ return failure(error, ExitCode.GENERAL_ERROR);
149
+ }
150
+ const existing = settingsService.getProviderConfig(provider) ?? { provider };
151
+ settingsService.setProviderConfig(provider, { ...existing, token });
152
+ const mode = getOutputMode(options);
153
+ if (mode === 'json') {
154
+ return success({ provider, tokenSet: true });
155
+ }
156
+ if (mode === 'quiet') {
157
+ return success(provider);
158
+ }
159
+ return success({ provider, tokenSet: true }, `Token set for provider "${provider}" (${maskToken(token)})`);
160
+ }
161
+ // ============================================================================
162
+ // Config Set-Project Command
163
+ // ============================================================================
164
+ async function configSetProjectHandler(args, options) {
165
+ if (args.length < 2) {
166
+ return failure('Usage: sf external-sync config set-project <provider> <project>', ExitCode.INVALID_ARGUMENTS);
167
+ }
168
+ const [provider, project] = args;
169
+ const { settingsService, error } = await createSettingsServiceFromOptions(options);
170
+ if (error) {
171
+ return failure(error, ExitCode.GENERAL_ERROR);
172
+ }
173
+ const existing = settingsService.getProviderConfig(provider) ?? { provider };
174
+ settingsService.setProviderConfig(provider, { ...existing, defaultProject: project });
175
+ const mode = getOutputMode(options);
176
+ if (mode === 'json') {
177
+ return success({ provider, defaultProject: project });
178
+ }
179
+ if (mode === 'quiet') {
180
+ return success(project);
181
+ }
182
+ return success({ provider, defaultProject: project }, `Default project set for provider "${provider}": ${project}`);
183
+ }
184
+ // ============================================================================
185
+ // Config Set-Auto-Link Command
186
+ // ============================================================================
187
+ async function configSetAutoLinkHandler(args, options) {
188
+ if (args.length < 1) {
189
+ return failure('Usage: sf external-sync config set-auto-link <provider>', ExitCode.INVALID_ARGUMENTS);
190
+ }
191
+ const [provider] = args;
192
+ // Validate provider name
193
+ if (!VALID_AUTO_LINK_PROVIDERS.includes(provider)) {
194
+ return failure(`Invalid provider "${provider}". Must be one of: ${VALID_AUTO_LINK_PROVIDERS.join(', ')}`, ExitCode.VALIDATION);
195
+ }
196
+ // Check if provider has a token configured
197
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
198
+ let tokenWarning;
199
+ if (!settingsError) {
200
+ const providerConfig = settingsService.getProviderConfig(provider);
201
+ if (!providerConfig?.token) {
202
+ tokenWarning = `Warning: Provider "${provider}" has no token configured. Auto-link will not work until a token is set. Run "sf external-sync config set-token ${provider} <token>".`;
203
+ }
204
+ }
205
+ // Set both autoLink and autoLinkProvider
206
+ setValue('externalSync.autoLink', true);
207
+ setValue('externalSync.autoLinkProvider', provider);
208
+ const mode = getOutputMode(options);
209
+ if (mode === 'json') {
210
+ return success({ autoLink: true, autoLinkProvider: provider, warning: tokenWarning });
211
+ }
212
+ if (mode === 'quiet') {
213
+ return success(provider);
214
+ }
215
+ const lines = [`Auto-link enabled with provider "${provider}".`];
216
+ if (tokenWarning) {
217
+ lines.push('');
218
+ lines.push(tokenWarning);
219
+ }
220
+ return success({ autoLink: true, autoLinkProvider: provider }, lines.join('\n'));
221
+ }
222
+ // ============================================================================
223
+ // Config Disable-Auto-Link Command
224
+ // ============================================================================
225
+ async function configDisableAutoLinkHandler(_args, options) {
226
+ // Set autoLink to false and clear autoLinkProvider
227
+ setValue('externalSync.autoLink', false);
228
+ setValue('externalSync.autoLinkProvider', undefined);
229
+ const mode = getOutputMode(options);
230
+ if (mode === 'json') {
231
+ return success({ autoLink: false, autoLinkProvider: null });
232
+ }
233
+ if (mode === 'quiet') {
234
+ return success('disabled');
235
+ }
236
+ return success({ autoLink: false }, 'Auto-link disabled.');
237
+ }
238
+ const linkOptions = [
239
+ {
240
+ name: 'provider',
241
+ short: 'p',
242
+ description: 'Provider name (default: github)',
243
+ hasValue: true,
244
+ },
245
+ ];
246
+ async function linkHandler(args, options) {
247
+ if (args.length < 2) {
248
+ return failure('Usage: sf external-sync link <taskId> <url-or-issue-number>', ExitCode.INVALID_ARGUMENTS);
249
+ }
250
+ const [taskId, urlOrNumber] = args;
251
+ const provider = options.provider ?? 'github';
252
+ const { api, error } = createAPI(options);
253
+ if (error) {
254
+ return failure(error, ExitCode.GENERAL_ERROR);
255
+ }
256
+ // Resolve task
257
+ let task;
258
+ try {
259
+ task = await api.get(taskId);
260
+ }
261
+ catch {
262
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
263
+ }
264
+ if (!task) {
265
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
266
+ }
267
+ if (task.type !== 'task') {
268
+ return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
269
+ }
270
+ // Determine the external URL
271
+ let externalUrl;
272
+ let externalId;
273
+ if (/^\d+$/.test(urlOrNumber)) {
274
+ // Bare number — construct URL from default project
275
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
276
+ if (settingsError) {
277
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
278
+ }
279
+ const providerConfig = settingsService.getProviderConfig(provider);
280
+ if (!providerConfig?.defaultProject) {
281
+ return failure(`No default project configured for provider "${provider}". ` +
282
+ `Run "sf external-sync config set-project ${provider} <owner/repo>" first, ` +
283
+ `or provide a full URL.`, ExitCode.VALIDATION);
284
+ }
285
+ externalId = urlOrNumber;
286
+ if (provider === 'github') {
287
+ const baseUrl = providerConfig.apiBaseUrl
288
+ ? providerConfig.apiBaseUrl.replace(/\/api\/v3\/?$/, '').replace(/\/$/, '')
289
+ : 'https://github.com';
290
+ externalUrl = `${baseUrl}/${providerConfig.defaultProject}/issues/${urlOrNumber}`;
291
+ }
292
+ else {
293
+ // Generic URL construction for other providers
294
+ externalUrl = `${providerConfig.defaultProject}#${urlOrNumber}`;
295
+ }
296
+ }
297
+ else {
298
+ // Full URL provided
299
+ externalUrl = urlOrNumber;
300
+ // Extract issue number from URL
301
+ const match = urlOrNumber.match(/\/(\d+)\/?$/);
302
+ externalId = match ? match[1] : urlOrNumber;
303
+ }
304
+ // Extract project from URL if possible
305
+ let project;
306
+ const ghMatch = externalUrl.match(/github\.com\/([^/]+\/[^/]+)\//);
307
+ if (ghMatch) {
308
+ project = ghMatch[1];
309
+ }
310
+ // Update task with externalRef and _externalSync metadata
311
+ const syncMetadata = {
312
+ provider,
313
+ project: project ?? '',
314
+ externalId,
315
+ url: externalUrl,
316
+ direction: getValue('externalSync.defaultDirection'),
317
+ adapterType: 'task',
318
+ };
319
+ try {
320
+ const existingMetadata = (task.metadata ?? {});
321
+ await api.update(taskId, {
322
+ externalRef: externalUrl,
323
+ metadata: {
324
+ ...existingMetadata,
325
+ _externalSync: syncMetadata,
326
+ },
327
+ });
328
+ }
329
+ catch (err) {
330
+ const message = err instanceof Error ? err.message : String(err);
331
+ return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
332
+ }
333
+ const mode = getOutputMode(options);
334
+ if (mode === 'json') {
335
+ return success({ taskId, externalUrl, provider, externalId, project });
336
+ }
337
+ if (mode === 'quiet') {
338
+ return success(externalUrl);
339
+ }
340
+ return success({ taskId, externalUrl, provider, externalId }, `Linked task ${taskId} to ${externalUrl}`);
341
+ }
342
+ // ============================================================================
343
+ // Unlink Command
344
+ // ============================================================================
345
+ async function unlinkHandler(args, options) {
346
+ if (args.length < 1) {
347
+ return failure('Usage: sf external-sync unlink <taskId>', ExitCode.INVALID_ARGUMENTS);
348
+ }
349
+ const [taskId] = args;
350
+ const { api, error } = createAPI(options);
351
+ if (error) {
352
+ return failure(error, ExitCode.GENERAL_ERROR);
353
+ }
354
+ // Resolve task
355
+ let task;
356
+ try {
357
+ task = await api.get(taskId);
358
+ }
359
+ catch {
360
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
361
+ }
362
+ if (!task) {
363
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
364
+ }
365
+ if (task.type !== 'task') {
366
+ return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
367
+ }
368
+ if (!task.externalRef) {
369
+ return failure(`Task ${taskId} is not linked to an external issue`, ExitCode.VALIDATION);
370
+ }
371
+ // Clear externalRef and _externalSync metadata
372
+ try {
373
+ const existingMetadata = (task.metadata ?? {});
374
+ const { _externalSync: _, ...restMetadata } = existingMetadata;
375
+ await api.update(taskId, {
376
+ externalRef: undefined,
377
+ metadata: restMetadata,
378
+ });
379
+ }
380
+ catch (err) {
381
+ const message = err instanceof Error ? err.message : String(err);
382
+ return failure(`Failed to update task: ${message}`, ExitCode.GENERAL_ERROR);
383
+ }
384
+ const mode = getOutputMode(options);
385
+ if (mode === 'json') {
386
+ return success({ taskId, unlinked: true });
387
+ }
388
+ if (mode === 'quiet') {
389
+ return success(taskId);
390
+ }
391
+ return success({ taskId, unlinked: true }, `Unlinked task ${taskId} from external issue`);
392
+ }
393
+ const pushOptions = [
394
+ {
395
+ name: 'all',
396
+ short: 'a',
397
+ description: 'Push all linked tasks',
398
+ },
399
+ {
400
+ name: 'force',
401
+ short: 'f',
402
+ description: 'Push all linked tasks regardless of whether they have changed',
403
+ },
404
+ ];
405
+ async function pushHandler(args, options) {
406
+ const { api, error } = createAPI(options);
407
+ if (error) {
408
+ return failure(error, ExitCode.GENERAL_ERROR);
409
+ }
410
+ // Get settings service to create a configured sync engine
411
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
412
+ if (settingsError) {
413
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
414
+ }
415
+ const syncSettings = settingsService.getExternalSyncSettings();
416
+ const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
417
+ if (providerConfigs.length === 0) {
418
+ return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
419
+ }
420
+ // Build sync options
421
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
422
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
423
+ provider: p.provider,
424
+ token: p.token,
425
+ apiBaseUrl: p.apiBaseUrl,
426
+ defaultProject: p.defaultProject,
427
+ })));
428
+ const engine = createSyncEngine({
429
+ api,
430
+ registry,
431
+ settings: settingsService,
432
+ providerConfigs: providerConfigs.map((p) => ({
433
+ provider: p.provider,
434
+ token: p.token,
435
+ apiBaseUrl: p.apiBaseUrl,
436
+ defaultProject: p.defaultProject,
437
+ })),
438
+ });
439
+ // Build push options
440
+ const syncPushOptions = {};
441
+ if (options.all) {
442
+ syncPushOptions.all = true;
443
+ }
444
+ else if (args.length > 0) {
445
+ syncPushOptions.taskIds = args;
446
+ }
447
+ else {
448
+ return failure('Usage: sf external-sync push [taskId...] or sf external-sync push --all', ExitCode.INVALID_ARGUMENTS);
449
+ }
450
+ if (options.force) {
451
+ syncPushOptions.force = true;
452
+ }
453
+ try {
454
+ const result = await engine.push(syncPushOptions);
455
+ const mode = getOutputMode(options);
456
+ const output = {
457
+ success: result.success,
458
+ pushed: result.pushed,
459
+ skipped: result.skipped,
460
+ errors: result.errors,
461
+ conflicts: result.conflicts,
462
+ };
463
+ if (mode === 'json') {
464
+ return success(output);
465
+ }
466
+ if (mode === 'quiet') {
467
+ return success(String(result.pushed));
468
+ }
469
+ const lines = [
470
+ `Push: ${result.pushed} task(s) pushed successfully`,
471
+ '',
472
+ ];
473
+ if (result.skipped > 0) {
474
+ lines.push(`Skipped: ${result.skipped}`);
475
+ }
476
+ if (result.errors.length > 0) {
477
+ lines.push('');
478
+ lines.push(`Errors (${result.errors.length}):`);
479
+ for (const err of result.errors) {
480
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
481
+ }
482
+ }
483
+ if (result.conflicts.length > 0) {
484
+ lines.push('');
485
+ lines.push(`Conflicts (${result.conflicts.length}):`);
486
+ for (const conflict of result.conflicts) {
487
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
488
+ }
489
+ }
490
+ return success(output, lines.join('\n'));
491
+ }
492
+ catch (err) {
493
+ const message = err instanceof Error ? err.message : String(err);
494
+ return failure(`Push failed: ${message}`, ExitCode.GENERAL_ERROR);
495
+ }
496
+ }
497
+ const pullOptions = [
498
+ {
499
+ name: 'provider',
500
+ short: 'p',
501
+ description: 'Provider to pull from (default: all configured)',
502
+ hasValue: true,
503
+ },
504
+ {
505
+ name: 'discover',
506
+ short: 'd',
507
+ description: 'Discover new issues not yet linked',
508
+ },
509
+ ];
510
+ async function pullHandler(_args, options) {
511
+ const { api, error } = createAPI(options);
512
+ if (error) {
513
+ return failure(error, ExitCode.GENERAL_ERROR);
514
+ }
515
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
516
+ if (settingsError) {
517
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
518
+ }
519
+ const settings = settingsService.getExternalSyncSettings();
520
+ const providerNames = options.provider
521
+ ? [options.provider]
522
+ : Object.keys(settings.providers);
523
+ if (providerNames.length === 0) {
524
+ return failure('No providers configured. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.VALIDATION);
525
+ }
526
+ // Validate providers have tokens and build provider configs
527
+ const providerConfigs = [];
528
+ const invalidProviders = [];
529
+ for (const name of providerNames) {
530
+ const config = settings.providers[name];
531
+ if (config?.token) {
532
+ providerConfigs.push({
533
+ provider: config.provider,
534
+ token: config.token,
535
+ apiBaseUrl: config.apiBaseUrl,
536
+ defaultProject: config.defaultProject,
537
+ });
538
+ }
539
+ else {
540
+ invalidProviders.push(name);
541
+ }
542
+ }
543
+ if (providerConfigs.length === 0) {
544
+ return failure('No providers with valid tokens found. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
545
+ }
546
+ // Create sync engine (same pattern as pushHandler)
547
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
548
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
549
+ provider: p.provider,
550
+ token: p.token,
551
+ apiBaseUrl: p.apiBaseUrl,
552
+ defaultProject: p.defaultProject,
553
+ })));
554
+ const engine = createSyncEngine({
555
+ api,
556
+ registry,
557
+ settings: settingsService,
558
+ providerConfigs: providerConfigs.map((p) => ({
559
+ provider: p.provider,
560
+ token: p.token,
561
+ apiBaseUrl: p.apiBaseUrl,
562
+ defaultProject: p.defaultProject,
563
+ })),
564
+ });
565
+ // Build pull options — discover maps to 'all' to create local tasks for unlinked external issues
566
+ const syncPullOptions = {};
567
+ if (options.discover) {
568
+ syncPullOptions.all = true;
569
+ }
570
+ try {
571
+ const result = await engine.pull(syncPullOptions);
572
+ const mode = getOutputMode(options);
573
+ const output = {
574
+ success: result.success,
575
+ pulled: result.pulled,
576
+ skipped: result.skipped,
577
+ errors: result.errors,
578
+ conflicts: result.conflicts,
579
+ invalidProviders,
580
+ };
581
+ if (mode === 'json') {
582
+ return success(output);
583
+ }
584
+ if (mode === 'quiet') {
585
+ return success(String(result.pulled));
586
+ }
587
+ const lines = [
588
+ `Pull: ${result.pulled} task(s) pulled successfully`,
589
+ '',
590
+ ];
591
+ if (result.skipped > 0) {
592
+ lines.push(`Skipped: ${result.skipped}`);
593
+ }
594
+ if (invalidProviders.length > 0) {
595
+ lines.push('');
596
+ lines.push(`Skipped providers (no token): ${invalidProviders.join(', ')}`);
597
+ }
598
+ if (result.errors.length > 0) {
599
+ lines.push('');
600
+ lines.push(`Errors (${result.errors.length}):`);
601
+ for (const err of result.errors) {
602
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
603
+ }
604
+ }
605
+ if (result.conflicts.length > 0) {
606
+ lines.push('');
607
+ lines.push(`Conflicts (${result.conflicts.length}):`);
608
+ for (const conflict of result.conflicts) {
609
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
610
+ }
611
+ }
612
+ return success(output, lines.join('\n'));
613
+ }
614
+ catch (err) {
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ return failure(`Pull failed: ${message}`, ExitCode.GENERAL_ERROR);
617
+ }
618
+ }
619
+ const syncOptions = [
620
+ {
621
+ name: 'dry-run',
622
+ short: 'n',
623
+ description: 'Show what would change without making changes',
624
+ },
625
+ ];
626
+ async function syncHandler(_args, options) {
627
+ const { api, error: apiError } = createAPI(options);
628
+ if (apiError) {
629
+ return failure(apiError, ExitCode.GENERAL_ERROR);
630
+ }
631
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
632
+ if (settingsError) {
633
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
634
+ }
635
+ const syncSettings = settingsService.getExternalSyncSettings();
636
+ const isDryRun = options['dry-run'] ?? false;
637
+ const providerConfigs = Object.values(syncSettings.providers).filter((p) => !!p.token);
638
+ if (providerConfigs.length === 0) {
639
+ return failure('No providers configured with tokens. Run "sf external-sync config set-token <provider> <token>" first.', ExitCode.GENERAL_ERROR);
640
+ }
641
+ // Create sync engine (same pattern as pushHandler)
642
+ const { createSyncEngine, createConfiguredProviderRegistry } = await import('../../external-sync/index.js');
643
+ const registry = createConfiguredProviderRegistry(providerConfigs.map((p) => ({
644
+ provider: p.provider,
645
+ token: p.token,
646
+ apiBaseUrl: p.apiBaseUrl,
647
+ defaultProject: p.defaultProject,
648
+ })));
649
+ const engine = createSyncEngine({
650
+ api,
651
+ registry,
652
+ settings: settingsService,
653
+ providerConfigs: providerConfigs.map((p) => ({
654
+ provider: p.provider,
655
+ token: p.token,
656
+ apiBaseUrl: p.apiBaseUrl,
657
+ defaultProject: p.defaultProject,
658
+ })),
659
+ });
660
+ // Build sync options
661
+ const syncOpts = {};
662
+ if (isDryRun) {
663
+ syncOpts.dryRun = true;
664
+ }
665
+ try {
666
+ const result = await engine.sync(syncOpts);
667
+ const mode = getOutputMode(options);
668
+ const output = {
669
+ success: result.success,
670
+ dryRun: isDryRun,
671
+ pushed: result.pushed,
672
+ pulled: result.pulled,
673
+ skipped: result.skipped,
674
+ errors: result.errors,
675
+ conflicts: result.conflicts,
676
+ };
677
+ if (mode === 'json') {
678
+ return success(output);
679
+ }
680
+ if (mode === 'quiet') {
681
+ return success(`${result.pushed}/${result.pulled}`);
682
+ }
683
+ const lines = [
684
+ isDryRun ? 'Sync (dry run) - showing what would change' : 'Bidirectional Sync Complete',
685
+ '',
686
+ ` Pushed: ${result.pushed} task(s)`,
687
+ ` Pulled: ${result.pulled} task(s)`,
688
+ ];
689
+ if (result.skipped > 0) {
690
+ lines.push(` Skipped: ${result.skipped} (no changes)`);
691
+ }
692
+ if (result.errors.length > 0) {
693
+ lines.push('');
694
+ lines.push(`Errors (${result.errors.length}):`);
695
+ for (const err of result.errors) {
696
+ lines.push(` ${err.elementId ?? 'unknown'}: ${err.message}`);
697
+ }
698
+ }
699
+ if (result.conflicts.length > 0) {
700
+ lines.push('');
701
+ lines.push(`Conflicts (${result.conflicts.length}):`);
702
+ for (const conflict of result.conflicts) {
703
+ lines.push(` ${conflict.elementId} ↔ ${conflict.externalId} (${conflict.strategy}, resolved: ${conflict.resolved})`);
704
+ }
705
+ }
706
+ return success(output, lines.join('\n'));
707
+ }
708
+ catch (err) {
709
+ const message = err instanceof Error ? err.message : String(err);
710
+ return failure(`Sync failed: ${message}`, ExitCode.GENERAL_ERROR);
711
+ }
712
+ }
713
+ // ============================================================================
714
+ // Status Command
715
+ // ============================================================================
716
+ async function statusHandler(_args, options) {
717
+ const { settingsService, error: settingsError } = await createSettingsServiceFromOptions(options);
718
+ if (settingsError) {
719
+ return failure(settingsError, ExitCode.GENERAL_ERROR);
720
+ }
721
+ const { api, error: apiError } = createAPI(options);
722
+ if (apiError) {
723
+ return failure(apiError, ExitCode.GENERAL_ERROR);
724
+ }
725
+ const settings = settingsService.getExternalSyncSettings();
726
+ const enabled = getValue('externalSync.enabled');
727
+ // Count linked tasks and check for conflicts
728
+ let linkedCount = 0;
729
+ let conflictCount = 0;
730
+ const providerCounts = {};
731
+ try {
732
+ const allTasks = await api.list({ type: 'task' });
733
+ for (const t of allTasks) {
734
+ const task = t;
735
+ if (task.externalRef && task.metadata?._externalSync) {
736
+ linkedCount++;
737
+ const syncMeta = task.metadata._externalSync;
738
+ const provider = syncMeta?.provider ?? 'unknown';
739
+ providerCounts[provider] = (providerCounts[provider] ?? 0) + 1;
740
+ }
741
+ if (task.tags?.includes('sync-conflict')) {
742
+ conflictCount++;
743
+ }
744
+ }
745
+ }
746
+ catch (err) {
747
+ const message = err instanceof Error ? err.message : String(err);
748
+ return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
749
+ }
750
+ // Build cursor info
751
+ const cursors = {};
752
+ for (const [key, value] of Object.entries(settings.syncCursors)) {
753
+ cursors[key] = value;
754
+ }
755
+ const mode = getOutputMode(options);
756
+ const statusData = {
757
+ enabled,
758
+ linkedTaskCount: linkedCount,
759
+ conflictCount,
760
+ providerCounts,
761
+ configuredProviders: Object.keys(settings.providers),
762
+ syncCursors: cursors,
763
+ pollIntervalMs: settings.pollIntervalMs,
764
+ defaultDirection: settings.defaultDirection,
765
+ };
766
+ if (mode === 'json') {
767
+ return success(statusData);
768
+ }
769
+ if (mode === 'quiet') {
770
+ return success(`${linkedCount}:${conflictCount}`);
771
+ }
772
+ const lines = [
773
+ 'External Sync Status',
774
+ '',
775
+ ` Enabled: ${enabled ? 'yes' : 'no'}`,
776
+ ` Linked tasks: ${linkedCount}`,
777
+ ` Pending conflicts: ${conflictCount}`,
778
+ ` Poll interval: ${settings.pollIntervalMs}ms`,
779
+ ` Default direction: ${settings.defaultDirection}`,
780
+ '',
781
+ ];
782
+ // Provider breakdown
783
+ const providerEntries = Object.entries(settings.providers);
784
+ if (providerEntries.length > 0) {
785
+ lines.push(' Providers:');
786
+ for (const [name, config] of providerEntries) {
787
+ const count = providerCounts[name] ?? 0;
788
+ const hasToken = config.token ? 'yes' : 'no';
789
+ lines.push(` ${name}: ${count} linked task(s), token: ${hasToken}, project: ${config.defaultProject ?? '(not set)'}`);
790
+ }
791
+ }
792
+ else {
793
+ lines.push(' Providers: (none configured)');
794
+ }
795
+ // Sync cursors
796
+ const cursorEntries = Object.entries(cursors);
797
+ if (cursorEntries.length > 0) {
798
+ lines.push('');
799
+ lines.push(' Last sync cursors:');
800
+ for (const [key, value] of cursorEntries) {
801
+ lines.push(` ${key}: ${value}`);
802
+ }
803
+ }
804
+ if (conflictCount > 0) {
805
+ lines.push('');
806
+ lines.push(` ⚠ ${conflictCount} conflict(s) need resolution. Run "sf external-sync resolve <taskId> --keep local|remote".`);
807
+ }
808
+ return success(statusData, lines.join('\n'));
809
+ }
810
+ const resolveOptions = [
811
+ {
812
+ name: 'keep',
813
+ short: 'k',
814
+ description: 'Which version to keep: local or remote',
815
+ hasValue: true,
816
+ required: true,
817
+ },
818
+ ];
819
+ async function resolveHandler(args, options) {
820
+ if (args.length < 1) {
821
+ return failure('Usage: sf external-sync resolve <taskId> --keep local|remote', ExitCode.INVALID_ARGUMENTS);
822
+ }
823
+ const [taskId] = args;
824
+ const keep = options.keep;
825
+ if (!keep || (keep !== 'local' && keep !== 'remote')) {
826
+ return failure('The --keep flag is required and must be either "local" or "remote"', ExitCode.INVALID_ARGUMENTS);
827
+ }
828
+ const { api, error } = createAPI(options);
829
+ if (error) {
830
+ return failure(error, ExitCode.GENERAL_ERROR);
831
+ }
832
+ // Resolve task
833
+ let task;
834
+ try {
835
+ task = await api.get(taskId);
836
+ }
837
+ catch {
838
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
839
+ }
840
+ if (!task) {
841
+ return failure(`Task not found: ${taskId}`, ExitCode.NOT_FOUND);
842
+ }
843
+ if (task.type !== 'task') {
844
+ return failure(`Element ${taskId} is not a task (type: ${task.type})`, ExitCode.VALIDATION);
845
+ }
846
+ if (!task.tags?.includes('sync-conflict')) {
847
+ return failure(`Task ${taskId} does not have a sync conflict. Only tasks tagged with "sync-conflict" can be resolved.`, ExitCode.VALIDATION);
848
+ }
849
+ // Remove sync-conflict tag and update metadata
850
+ try {
851
+ const newTags = (task.tags ?? []).filter((t) => t !== 'sync-conflict');
852
+ const existingMetadata = (task.metadata ?? {});
853
+ const syncMeta = (existingMetadata._externalSync ?? {});
854
+ // Record resolution in metadata
855
+ const updatedSyncMeta = {
856
+ ...syncMeta,
857
+ lastConflictResolution: {
858
+ resolvedAt: new Date().toISOString(),
859
+ kept: keep,
860
+ },
861
+ };
862
+ // Clear conflict data from metadata
863
+ const { _syncConflict: _, ...restMetadata } = existingMetadata;
864
+ await api.update(taskId, {
865
+ tags: newTags,
866
+ metadata: {
867
+ ...restMetadata,
868
+ _externalSync: updatedSyncMeta,
869
+ },
870
+ });
871
+ }
872
+ catch (err) {
873
+ const message = err instanceof Error ? err.message : String(err);
874
+ return failure(`Failed to resolve conflict: ${message}`, ExitCode.GENERAL_ERROR);
875
+ }
876
+ const mode = getOutputMode(options);
877
+ if (mode === 'json') {
878
+ return success({ taskId, resolved: true, kept: keep });
879
+ }
880
+ if (mode === 'quiet') {
881
+ return success(taskId);
882
+ }
883
+ return success({ taskId, resolved: true, kept: keep }, `Resolved sync conflict for task ${taskId} (kept: ${keep})`);
884
+ }
885
+ const linkAllOptions = [
886
+ {
887
+ name: 'provider',
888
+ short: 'p',
889
+ description: 'Provider to link to (required)',
890
+ hasValue: true,
891
+ required: true,
892
+ },
893
+ {
894
+ name: 'project',
895
+ description: 'Override the default project',
896
+ hasValue: true,
897
+ },
898
+ {
899
+ name: 'status',
900
+ short: 's',
901
+ description: 'Only link tasks with this status (can be repeated)',
902
+ hasValue: true,
903
+ array: true,
904
+ },
905
+ {
906
+ name: 'dry-run',
907
+ short: 'n',
908
+ description: 'List tasks that would be linked without creating external issues',
909
+ },
910
+ {
911
+ name: 'batch-size',
912
+ short: 'b',
913
+ description: 'How many tasks to process concurrently (default: 10)',
914
+ hasValue: true,
915
+ defaultValue: '10',
916
+ },
917
+ {
918
+ name: 'force',
919
+ short: 'f',
920
+ description: 'Re-link tasks that are already linked to a different provider',
921
+ },
922
+ ];
923
+ /**
924
+ * Helper to detect rate limit errors from GitHub or Linear providers.
925
+ * Returns the reset timestamp (epoch seconds) if available, or undefined.
926
+ */
927
+ function isRateLimitError(err) {
928
+ // Try GitHub error shape
929
+ if (err &&
930
+ typeof err === 'object' &&
931
+ 'isRateLimited' in err &&
932
+ err.isRateLimited) {
933
+ const rateLimit = err.rateLimit;
934
+ return { isRateLimit: true, resetAt: rateLimit?.reset };
935
+ }
936
+ // Also check error message for rate limit keywords
937
+ if (err instanceof Error && /rate.limit/i.test(err.message)) {
938
+ return { isRateLimit: true };
939
+ }
940
+ return { isRateLimit: false };
941
+ }
942
+ /**
943
+ * Extracts validation error details from a GitHub API error response.
944
+ * GitHub's 422 responses include an `errors` array with `resource`, `field`,
945
+ * `code`, and sometimes `value` or `message` entries.
946
+ *
947
+ * Example output: "invalid label: sf:priority:high"
948
+ */
949
+ function extractValidationDetail(err) {
950
+ if (!err || typeof err !== 'object')
951
+ return null;
952
+ const responseBody = err.responseBody;
953
+ if (!responseBody || !Array.isArray(responseBody.errors))
954
+ return null;
955
+ const details = responseBody.errors
956
+ .map((e) => {
957
+ const parts = [];
958
+ if (e.code && typeof e.code === 'string')
959
+ parts.push(e.code);
960
+ if (e.field && typeof e.field === 'string')
961
+ parts.push(e.field);
962
+ if (e.value !== undefined)
963
+ parts.push(String(e.value));
964
+ if (e.message && typeof e.message === 'string')
965
+ parts.push(e.message);
966
+ return parts.join(': ');
967
+ })
968
+ .filter(Boolean);
969
+ return details.length > 0 ? details.join('; ') : null;
970
+ }
971
+ /**
972
+ * Creates an ExternalProvider instance from settings for the given provider name.
973
+ * Returns the provider, project, and direction, or an error message.
974
+ */
975
+ async function createProviderFromSettings(providerName, projectOverride, options) {
976
+ const dbPath = resolveDatabasePath(options);
977
+ if (!dbPath) {
978
+ return { error: 'No database found. Run "sf init" to initialize a workspace, or specify --db path' };
979
+ }
980
+ try {
981
+ const backend = createStorage({ path: dbPath, create: true });
982
+ initializeSchema(backend);
983
+ // Dynamic import to handle optional peer dependency
984
+ const { createSettingsService } = await import('@stoneforge/smithy/services');
985
+ const settingsService = createSettingsService(backend);
986
+ const providerConfig = settingsService.getProviderConfig(providerName);
987
+ if (!providerConfig?.token) {
988
+ return { error: `Provider "${providerName}" has no token configured. Run "sf external-sync config set-token ${providerName} <token>" first.` };
989
+ }
990
+ const project = projectOverride ?? providerConfig.defaultProject;
991
+ if (!project) {
992
+ return { error: `No project specified and provider "${providerName}" has no default project configured. Use --project or run "sf external-sync config set-project ${providerName} <project>" first.` };
993
+ }
994
+ let provider;
995
+ if (providerName === 'github') {
996
+ const { createGitHubProvider } = await import('../../external-sync/providers/github/index.js');
997
+ provider = createGitHubProvider({
998
+ provider: 'github',
999
+ token: providerConfig.token,
1000
+ apiBaseUrl: providerConfig.apiBaseUrl,
1001
+ defaultProject: project,
1002
+ });
1003
+ }
1004
+ else if (providerName === 'linear') {
1005
+ const { createLinearProvider } = await import('../../external-sync/providers/linear/index.js');
1006
+ provider = createLinearProvider({
1007
+ apiKey: providerConfig.token,
1008
+ });
1009
+ }
1010
+ else {
1011
+ return { error: `Unsupported provider: "${providerName}". Supported providers: github, linear` };
1012
+ }
1013
+ const direction = (getValue('externalSync.defaultDirection') ?? 'bidirectional');
1014
+ return { provider, project, direction };
1015
+ }
1016
+ catch (err) {
1017
+ const message = err instanceof Error ? err.message : String(err);
1018
+ if (message.includes('Cannot find') || message.includes('MODULE_NOT_FOUND')) {
1019
+ return { error: 'External sync requires @stoneforge/smithy package. Ensure it is installed.' };
1020
+ }
1021
+ return { error: `Failed to initialize provider: ${message}` };
1022
+ }
1023
+ }
1024
+ /**
1025
+ * Process a batch of tasks: create external issues and link them.
1026
+ * Uses the adapter's field mapping to include priority, taskType, and status
1027
+ * labels on the created external issues.
1028
+ */
1029
+ async function processBatch(tasks, adapter, api, providerName, project, direction, progressLines) {
1030
+ let succeeded = 0;
1031
+ let failed = 0;
1032
+ let rateLimited = false;
1033
+ let resetAt;
1034
+ const fieldMapConfig = getFieldMapConfigForProvider(providerName);
1035
+ for (const task of tasks) {
1036
+ try {
1037
+ // Build the complete external task input using field mapping.
1038
+ // This maps priority → sf:priority:* labels, taskType → sf:type:* labels,
1039
+ // status → open/closed state, user tags → labels, and hydrates description.
1040
+ const externalInput = await taskToExternalTask(task, fieldMapConfig, api);
1041
+ // Create the external issue with fully mapped fields
1042
+ const externalTask = await adapter.createIssue(project, externalInput);
1043
+ // Build the ExternalSyncState metadata
1044
+ const syncState = {
1045
+ provider: providerName,
1046
+ project,
1047
+ externalId: externalTask.externalId,
1048
+ url: externalTask.url,
1049
+ direction,
1050
+ adapterType: 'task',
1051
+ };
1052
+ // Update the task with externalRef and _externalSync metadata
1053
+ const existingMetadata = (task.metadata ?? {});
1054
+ await api.update(task.id, {
1055
+ externalRef: externalTask.url,
1056
+ metadata: {
1057
+ ...existingMetadata,
1058
+ _externalSync: syncState,
1059
+ },
1060
+ });
1061
+ progressLines.push(`Linked ${task.id} → ${externalTask.url}`);
1062
+ succeeded++;
1063
+ }
1064
+ catch (err) {
1065
+ // Check for rate limit errors
1066
+ const rlCheck = isRateLimitError(err);
1067
+ if (rlCheck.isRateLimit) {
1068
+ rateLimited = true;
1069
+ resetAt = rlCheck.resetAt;
1070
+ const message = err instanceof Error ? err.message : String(err);
1071
+ progressLines.push(`Rate limit hit while linking ${task.id}: ${message}`);
1072
+ // Stop processing further tasks in this batch
1073
+ break;
1074
+ }
1075
+ // Log warning and continue with next task
1076
+ const message = err instanceof Error ? err.message : String(err);
1077
+ const detail = extractValidationDetail(err);
1078
+ progressLines.push(detail
1079
+ ? `Failed to link ${task.id}: ${message} — ${detail}`
1080
+ : `Failed to link ${task.id}: ${message}`);
1081
+ failed++;
1082
+ }
1083
+ }
1084
+ return { succeeded, failed, rateLimited, resetAt };
1085
+ }
1086
+ async function linkAllHandler(_args, options) {
1087
+ const providerName = options.provider;
1088
+ if (!providerName) {
1089
+ return failure('The --provider flag is required. Usage: sf external-sync link-all --provider <provider>', ExitCode.INVALID_ARGUMENTS);
1090
+ }
1091
+ const isDryRun = options['dry-run'] ?? false;
1092
+ const force = options.force ?? false;
1093
+ const batchSize = parseInt(options['batch-size'] ?? '10', 10);
1094
+ if (isNaN(batchSize) || batchSize < 1) {
1095
+ return failure('--batch-size must be a positive integer', ExitCode.INVALID_ARGUMENTS);
1096
+ }
1097
+ // Parse status filters
1098
+ const statusFilters = [];
1099
+ if (options.status) {
1100
+ if (Array.isArray(options.status)) {
1101
+ statusFilters.push(...options.status);
1102
+ }
1103
+ else {
1104
+ statusFilters.push(options.status);
1105
+ }
1106
+ }
1107
+ // Get API for querying/updating tasks
1108
+ const { api, error: apiError } = createAPI(options);
1109
+ if (apiError) {
1110
+ return failure(apiError, ExitCode.GENERAL_ERROR);
1111
+ }
1112
+ // Query all tasks
1113
+ let allTasks;
1114
+ try {
1115
+ const results = await api.list({ type: 'task' });
1116
+ allTasks = results;
1117
+ }
1118
+ catch (err) {
1119
+ const message = err instanceof Error ? err.message : String(err);
1120
+ return failure(`Failed to list tasks: ${message}`, ExitCode.GENERAL_ERROR);
1121
+ }
1122
+ // Filter tasks: unlinked tasks, plus (with --force) tasks linked to a DIFFERENT provider
1123
+ let relinkedFromProvider;
1124
+ let relinkCount = 0;
1125
+ let tasksToLink = allTasks.filter((task) => {
1126
+ const metadata = (task.metadata ?? {});
1127
+ const syncState = metadata._externalSync;
1128
+ if (!syncState) {
1129
+ // Unlinked task — always include
1130
+ return true;
1131
+ }
1132
+ if (force && syncState.provider !== providerName) {
1133
+ // Force mode: include tasks linked to a DIFFERENT provider
1134
+ relinkedFromProvider = syncState.provider;
1135
+ relinkCount++;
1136
+ return true;
1137
+ }
1138
+ // Already linked (to same provider, or force not set) — skip
1139
+ return false;
1140
+ });
1141
+ // Apply status filter if specified
1142
+ if (statusFilters.length > 0) {
1143
+ tasksToLink = tasksToLink.filter((task) => statusFilters.includes(task.status));
1144
+ }
1145
+ // Skip tombstone tasks by default (soft-deleted)
1146
+ tasksToLink = tasksToLink.filter((task) => task.status !== 'tombstone');
1147
+ const mode = getOutputMode(options);
1148
+ if (tasksToLink.length === 0) {
1149
+ const result = { linked: 0, failed: 0, skipped: 0, total: 0, dryRun: isDryRun };
1150
+ if (mode === 'json') {
1151
+ return success(result);
1152
+ }
1153
+ const hint = force
1154
+ ? 'No tasks found to re-link matching the specified criteria.'
1155
+ : 'No unlinked tasks found. Use --force to re-link tasks from a different provider.';
1156
+ return success(result, hint);
1157
+ }
1158
+ // Dry run — just list tasks that would be linked
1159
+ if (isDryRun) {
1160
+ const taskList = tasksToLink.map((t) => ({
1161
+ id: t.id,
1162
+ title: t.title,
1163
+ status: t.status,
1164
+ }));
1165
+ const jsonResult = {
1166
+ dryRun: true,
1167
+ provider: providerName,
1168
+ total: tasksToLink.length,
1169
+ tasks: taskList,
1170
+ };
1171
+ if (force && relinkCount > 0) {
1172
+ jsonResult.force = true;
1173
+ jsonResult.relinkCount = relinkCount;
1174
+ jsonResult.relinkFromProvider = relinkedFromProvider;
1175
+ }
1176
+ if (mode === 'json') {
1177
+ return success(jsonResult);
1178
+ }
1179
+ if (mode === 'quiet') {
1180
+ return success(String(tasksToLink.length));
1181
+ }
1182
+ const lines = [];
1183
+ if (force && relinkCount > 0) {
1184
+ lines.push(`Dry run: Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1185
+ const newCount = tasksToLink.length - relinkCount;
1186
+ if (newCount > 0) {
1187
+ lines.push(` Plus ${newCount} unlinked task(s) to link`);
1188
+ }
1189
+ }
1190
+ else {
1191
+ lines.push(`Dry run: ${tasksToLink.length} task(s) would be linked to ${providerName}`);
1192
+ }
1193
+ lines.push('');
1194
+ for (const task of tasksToLink) {
1195
+ lines.push(` ${task.id} ${task.status.padEnd(12)} ${task.title}`);
1196
+ }
1197
+ return success(jsonResult, lines.join('\n'));
1198
+ }
1199
+ // Create provider for actual linking (supports DI for testing)
1200
+ const providerFactory = options._providerFactory ?? createProviderFromSettings;
1201
+ const { provider: externalProvider, project, direction, error: providerError, } = await providerFactory(providerName, options.project, options);
1202
+ if (providerError) {
1203
+ return failure(providerError, ExitCode.GENERAL_ERROR);
1204
+ }
1205
+ // Get the task adapter
1206
+ const adapter = externalProvider.getTaskAdapter?.();
1207
+ if (!adapter) {
1208
+ return failure(`Provider "${providerName}" does not support task sync`, ExitCode.GENERAL_ERROR);
1209
+ }
1210
+ const progressLines = [];
1211
+ // Log re-linking info when using --force
1212
+ if (force && relinkCount > 0 && mode !== 'json' && mode !== 'quiet') {
1213
+ progressLines.push(`Re-linking ${relinkCount} task(s) from ${relinkedFromProvider} to ${providerName} (--force)`);
1214
+ const newCount = tasksToLink.length - relinkCount;
1215
+ if (newCount > 0) {
1216
+ progressLines.push(`Linking ${newCount} unlinked task(s)`);
1217
+ }
1218
+ progressLines.push('');
1219
+ }
1220
+ // Process tasks in batches
1221
+ let totalSucceeded = 0;
1222
+ let totalFailed = 0;
1223
+ let rateLimited = false;
1224
+ let rateLimitResetAt;
1225
+ for (let i = 0; i < tasksToLink.length; i += batchSize) {
1226
+ const batch = tasksToLink.slice(i, i + batchSize);
1227
+ const batchResult = await processBatch(batch, adapter, api, providerName, project, direction, progressLines);
1228
+ totalSucceeded += batchResult.succeeded;
1229
+ totalFailed += batchResult.failed;
1230
+ if (batchResult.rateLimited) {
1231
+ rateLimited = true;
1232
+ rateLimitResetAt = batchResult.resetAt;
1233
+ break;
1234
+ }
1235
+ // Small delay between batches to be gentle on the API
1236
+ if (i + batchSize < tasksToLink.length) {
1237
+ await new Promise((resolve) => setTimeout(resolve, 100));
1238
+ }
1239
+ }
1240
+ const skipped = tasksToLink.length - totalSucceeded - totalFailed;
1241
+ // Build result
1242
+ const result = {
1243
+ provider: providerName,
1244
+ project,
1245
+ linked: totalSucceeded,
1246
+ failed: totalFailed,
1247
+ skipped,
1248
+ total: tasksToLink.length,
1249
+ rateLimited,
1250
+ rateLimitResetAt: rateLimitResetAt ? new Date(rateLimitResetAt * 1000).toISOString() : undefined,
1251
+ };
1252
+ if (force && relinkCount > 0) {
1253
+ result.force = true;
1254
+ result.relinkCount = relinkCount;
1255
+ result.relinkFromProvider = relinkedFromProvider;
1256
+ }
1257
+ if (mode === 'json') {
1258
+ return success(result);
1259
+ }
1260
+ if (mode === 'quiet') {
1261
+ return success(String(totalSucceeded));
1262
+ }
1263
+ // Human-readable output
1264
+ const lines = [...progressLines, ''];
1265
+ // Summary
1266
+ const summaryParts = [`Linked ${totalSucceeded} tasks to ${providerName}`];
1267
+ if (totalFailed > 0) {
1268
+ summaryParts.push(`(${totalFailed} failed)`);
1269
+ }
1270
+ if (skipped > 0) {
1271
+ summaryParts.push(`(${skipped} skipped)`);
1272
+ }
1273
+ lines.push(summaryParts.join(' '));
1274
+ if (rateLimited) {
1275
+ lines.push('');
1276
+ if (rateLimitResetAt) {
1277
+ const resetDate = new Date(rateLimitResetAt * 1000);
1278
+ lines.push(`Rate limit reached. Resets at ${resetDate.toISOString()}. Re-run this command after the reset to link remaining tasks.`);
1279
+ }
1280
+ else {
1281
+ lines.push('Rate limit reached. Re-run this command later to link remaining tasks.');
1282
+ }
1283
+ }
1284
+ return success(result, lines.join('\n'));
1285
+ }
1286
+ // ============================================================================
1287
+ // Config Parent Command (for subcommand structure)
1288
+ // ============================================================================
1289
+ const configSetTokenCommand = {
1290
+ name: 'set-token',
1291
+ description: 'Set authentication token for a provider',
1292
+ usage: 'sf external-sync config set-token <provider> <token>',
1293
+ help: `Store an authentication token for an external sync provider.
1294
+
1295
+ The token is stored in the local SQLite database (not git-tracked).
1296
+
1297
+ Arguments:
1298
+ provider Provider name (e.g., github, linear)
1299
+ token Authentication token
1300
+
1301
+ Examples:
1302
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
1303
+ sf external-sync config set-token linear lin_api_xxxxxxxxxxxx`,
1304
+ options: [],
1305
+ handler: configSetTokenHandler,
1306
+ };
1307
+ const configSetProjectCommand = {
1308
+ name: 'set-project',
1309
+ description: 'Set default project for a provider',
1310
+ usage: 'sf external-sync config set-project <provider> <project>',
1311
+ help: `Set the default project (e.g., owner/repo) for an external sync provider.
1312
+
1313
+ This is used when linking tasks with bare issue numbers.
1314
+
1315
+ Arguments:
1316
+ provider Provider name (e.g., github, linear)
1317
+ project Project identifier (e.g., owner/repo for GitHub)
1318
+
1319
+ Examples:
1320
+ sf external-sync config set-project github my-org/my-repo
1321
+ sf external-sync config set-project linear MY-PROJECT`,
1322
+ options: [],
1323
+ handler: configSetProjectHandler,
1324
+ };
1325
+ const configSetAutoLinkCommand = {
1326
+ name: 'set-auto-link',
1327
+ description: 'Enable auto-link with a provider',
1328
+ usage: 'sf external-sync config set-auto-link <provider>',
1329
+ help: `Enable auto-link for new tasks with the specified provider.
1330
+
1331
+ When auto-link is enabled, newly created Stoneforge tasks will automatically
1332
+ get a corresponding external issue created and linked.
1333
+
1334
+ Arguments:
1335
+ provider Provider name (github or linear)
1336
+
1337
+ Examples:
1338
+ sf external-sync config set-auto-link github
1339
+ sf external-sync config set-auto-link linear`,
1340
+ options: [],
1341
+ handler: configSetAutoLinkHandler,
1342
+ };
1343
+ const configDisableAutoLinkCommand = {
1344
+ name: 'disable-auto-link',
1345
+ description: 'Disable auto-link',
1346
+ usage: 'sf external-sync config disable-auto-link',
1347
+ help: `Disable auto-link for new tasks.
1348
+
1349
+ Clears the auto-link provider and disables automatic external issue creation.
1350
+
1351
+ Examples:
1352
+ sf external-sync config disable-auto-link`,
1353
+ options: [],
1354
+ handler: configDisableAutoLinkHandler,
1355
+ };
1356
+ const configParentCommand = {
1357
+ name: 'config',
1358
+ description: 'Show or set provider configuration',
1359
+ usage: 'sf external-sync config [set-token|set-project|set-auto-link|disable-auto-link]',
1360
+ help: `Show current external sync provider configuration.
1361
+
1362
+ Tokens are masked in output for security.
1363
+
1364
+ Subcommands:
1365
+ set-token <provider> <token> Store auth token
1366
+ set-project <provider> <project> Set default project
1367
+ set-auto-link <provider> Enable auto-link with a provider
1368
+ disable-auto-link Disable auto-link
1369
+
1370
+ Examples:
1371
+ sf external-sync config
1372
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
1373
+ sf external-sync config set-project github my-org/my-repo
1374
+ sf external-sync config set-auto-link github
1375
+ sf external-sync config disable-auto-link`,
1376
+ subcommands: {
1377
+ 'set-token': configSetTokenCommand,
1378
+ 'set-project': configSetProjectCommand,
1379
+ 'set-auto-link': configSetAutoLinkCommand,
1380
+ 'disable-auto-link': configDisableAutoLinkCommand,
1381
+ },
1382
+ options: [],
1383
+ handler: configHandler,
1384
+ };
1385
+ // ============================================================================
1386
+ // Link Parent Command
1387
+ // ============================================================================
1388
+ const linkCommand = {
1389
+ name: 'link',
1390
+ description: 'Link a task to an external issue',
1391
+ usage: 'sf external-sync link <taskId> <url-or-issue-number>',
1392
+ help: `Link a Stoneforge task to an external issue (e.g., GitHub issue).
1393
+
1394
+ Sets the task's externalRef and _externalSync metadata. If given a bare
1395
+ issue number, constructs the URL from the provider's default project.
1396
+
1397
+ Arguments:
1398
+ taskId Stoneforge task ID
1399
+ url-or-number Full URL or bare issue number
1400
+
1401
+ Options:
1402
+ -p, --provider Provider name (default: github)
1403
+
1404
+ Examples:
1405
+ sf external-sync link el-abc123 https://github.com/org/repo/issues/42
1406
+ sf external-sync link el-abc123 42
1407
+ sf external-sync link el-abc123 42 --provider github`,
1408
+ options: linkOptions,
1409
+ handler: linkHandler,
1410
+ };
1411
+ // ============================================================================
1412
+ // Link-All Command
1413
+ // ============================================================================
1414
+ const linkAllCommand = {
1415
+ name: 'link-all',
1416
+ description: 'Bulk-link all unlinked tasks to external issues',
1417
+ usage: 'sf external-sync link-all --provider <provider> [--project <project>] [--status <status>] [--dry-run] [--batch-size <n>] [--force]',
1418
+ help: `Create external issues for all unlinked tasks and link them in bulk.
1419
+
1420
+ Finds all tasks that do NOT have external sync metadata and creates
1421
+ a corresponding external issue for each one, then links them.
1422
+
1423
+ Use --force to re-link tasks that are already linked to a different provider.
1424
+ Tasks linked to the same target provider are always skipped.
1425
+
1426
+ Options:
1427
+ -p, --provider <name> Provider to link to (required)
1428
+ --project <project> Override the default project
1429
+ -s, --status <status> Only link tasks with this status (can be repeated)
1430
+ -n, --dry-run List tasks that would be linked without creating issues
1431
+ -b, --batch-size <n> Tasks to process concurrently (default: 10)
1432
+ -f, --force Re-link tasks already linked to a different provider
1433
+
1434
+ Rate Limits:
1435
+ If a rate limit is hit, the command stops gracefully and reports how
1436
+ many tasks were linked. Re-run the command to continue linking.
1437
+
1438
+ Examples:
1439
+ sf external-sync link-all --provider github
1440
+ sf external-sync link-all --provider github --status open
1441
+ sf external-sync link-all --provider github --status open --status in_progress
1442
+ sf external-sync link-all --provider github --dry-run
1443
+ sf external-sync link-all --provider github --project my-org/my-repo
1444
+ sf external-sync link-all --provider linear --batch-size 5
1445
+ sf external-sync link-all --provider linear --force
1446
+ sf external-sync link-all --provider linear --force --dry-run`,
1447
+ options: linkAllOptions,
1448
+ handler: linkAllHandler,
1449
+ };
1450
+ // ============================================================================
1451
+ // Unlink Command
1452
+ // ============================================================================
1453
+ const unlinkCommand = {
1454
+ name: 'unlink',
1455
+ description: 'Remove external link from a task',
1456
+ usage: 'sf external-sync unlink <taskId>',
1457
+ help: `Remove the external link from a Stoneforge task.
1458
+
1459
+ Clears the task's externalRef field and _externalSync metadata.
1460
+
1461
+ Arguments:
1462
+ taskId Stoneforge task ID
1463
+
1464
+ Examples:
1465
+ sf external-sync unlink el-abc123`,
1466
+ options: [],
1467
+ handler: unlinkHandler,
1468
+ };
1469
+ // ============================================================================
1470
+ // Push Command
1471
+ // ============================================================================
1472
+ const pushCommand = {
1473
+ name: 'push',
1474
+ description: 'Push linked tasks to external service',
1475
+ usage: 'sf external-sync push [taskId...] [--all] [--force]',
1476
+ help: `Push specific linked tasks to their external service, or push all linked tasks.
1477
+
1478
+ If specific task IDs are given, pushes only those tasks. With --all,
1479
+ pushes every task that has an external link.
1480
+
1481
+ Use --force to push all linked tasks regardless of whether their local
1482
+ content has changed. This is useful when label generation logic changes
1483
+ and the external representation needs to be refreshed.
1484
+
1485
+ Arguments:
1486
+ taskId... One or more task IDs to push (optional with --all)
1487
+
1488
+ Options:
1489
+ -a, --all Push all linked tasks
1490
+ -f, --force Push all linked tasks regardless of whether they have changed
1491
+
1492
+ Examples:
1493
+ sf external-sync push el-abc123
1494
+ sf external-sync push el-abc123 el-def456
1495
+ sf external-sync push --all
1496
+ sf external-sync push --all --force
1497
+ sf external-sync push el-abc123 --force`,
1498
+ options: pushOptions,
1499
+ handler: pushHandler,
1500
+ };
1501
+ // ============================================================================
1502
+ // Pull Command
1503
+ // ============================================================================
1504
+ const pullCommand = {
1505
+ name: 'pull',
1506
+ description: 'Pull changes from external for linked tasks',
1507
+ usage: 'sf external-sync pull [--provider <name>] [--discover]',
1508
+ help: `Pull changes from external services for all linked tasks.
1509
+
1510
+ Optionally discover new issues not yet linked to Stoneforge tasks.
1511
+
1512
+ Options:
1513
+ -p, --provider <name> Pull from specific provider (default: all configured)
1514
+ -d, --discover Discover new unlinked issues
1515
+
1516
+ Examples:
1517
+ sf external-sync pull
1518
+ sf external-sync pull --provider github
1519
+ sf external-sync pull --discover`,
1520
+ options: pullOptions,
1521
+ handler: pullHandler,
1522
+ };
1523
+ // ============================================================================
1524
+ // Sync Command
1525
+ // ============================================================================
1526
+ const biSyncCommand = {
1527
+ name: 'sync',
1528
+ description: 'Bidirectional sync with external services',
1529
+ usage: 'sf external-sync sync [--dry-run]',
1530
+ help: `Run bidirectional sync between Stoneforge and external services.
1531
+
1532
+ Performs both push and pull operations. In dry-run mode, reports what
1533
+ would change without making any modifications.
1534
+
1535
+ Options:
1536
+ -n, --dry-run Show what would change without making changes
1537
+
1538
+ Examples:
1539
+ sf external-sync sync
1540
+ sf external-sync sync --dry-run`,
1541
+ options: syncOptions,
1542
+ handler: syncHandler,
1543
+ };
1544
+ // ============================================================================
1545
+ // Status Command
1546
+ // ============================================================================
1547
+ const extStatusCommand = {
1548
+ name: 'status',
1549
+ description: 'Show external sync state',
1550
+ usage: 'sf external-sync status',
1551
+ help: `Show the current external sync state.
1552
+
1553
+ Displays linked task count, last sync times, configured providers,
1554
+ and pending conflicts.
1555
+
1556
+ Examples:
1557
+ sf external-sync status
1558
+ sf external-sync status --json`,
1559
+ options: [],
1560
+ handler: statusHandler,
1561
+ };
1562
+ // ============================================================================
1563
+ // Resolve Command
1564
+ // ============================================================================
1565
+ const resolveCommand = {
1566
+ name: 'resolve',
1567
+ description: 'Resolve a sync conflict',
1568
+ usage: 'sf external-sync resolve <taskId> --keep local|remote',
1569
+ help: `Resolve a sync conflict by choosing which version to keep.
1570
+
1571
+ Tasks with sync conflicts are tagged with "sync-conflict". This command
1572
+ resolves the conflict by keeping either the local or remote version.
1573
+
1574
+ Arguments:
1575
+ taskId Task ID with a sync conflict
1576
+
1577
+ Options:
1578
+ -k, --keep <version> Which version to keep: local or remote (required)
1579
+
1580
+ Examples:
1581
+ sf external-sync resolve el-abc123 --keep local
1582
+ sf external-sync resolve el-abc123 --keep remote`,
1583
+ options: resolveOptions,
1584
+ handler: resolveHandler,
1585
+ };
1586
+ // ============================================================================
1587
+ // External Sync Parent Command
1588
+ // ============================================================================
1589
+ export const externalSyncCommand = {
1590
+ name: 'external-sync',
1591
+ description: 'External service sync commands',
1592
+ usage: 'sf external-sync <command> [options]',
1593
+ help: `Manage bidirectional synchronization between Stoneforge and external services
1594
+ (GitHub Issues, Linear, etc.).
1595
+
1596
+ Commands:
1597
+ config Show provider configuration
1598
+ config set-token <provider> <token> Store auth token
1599
+ config set-project <provider> <project> Set default project
1600
+ config set-auto-link <provider> Enable auto-link with a provider
1601
+ config disable-auto-link Disable auto-link
1602
+ link <taskId> <url-or-issue-number> Link task to external issue
1603
+ link-all --provider <name> Bulk-link all unlinked tasks
1604
+ unlink <taskId> Remove external link
1605
+ push [taskId...] [--force] Push linked task(s) to external
1606
+ pull Pull changes from external
1607
+ sync [--dry-run] Bidirectional sync
1608
+ status Show sync state
1609
+ resolve <taskId> --keep local|remote Resolve sync conflict
1610
+
1611
+ Examples:
1612
+ sf external-sync config
1613
+ sf external-sync config set-token github ghp_xxxxxxxxxxxx
1614
+ sf external-sync config set-project github my-org/my-repo
1615
+ sf external-sync config set-auto-link github
1616
+ sf external-sync config disable-auto-link
1617
+ sf external-sync link el-abc123 42
1618
+ sf external-sync link-all --provider github
1619
+ sf external-sync link-all --provider github --dry-run
1620
+ sf external-sync push --all
1621
+ sf external-sync push --all --force
1622
+ sf external-sync pull
1623
+ sf external-sync sync --dry-run
1624
+ sf external-sync status
1625
+ sf external-sync resolve el-abc123 --keep local`,
1626
+ subcommands: {
1627
+ config: configParentCommand,
1628
+ link: linkCommand,
1629
+ 'link-all': linkAllCommand,
1630
+ unlink: unlinkCommand,
1631
+ push: pushCommand,
1632
+ pull: pullCommand,
1633
+ sync: biSyncCommand,
1634
+ status: extStatusCommand,
1635
+ resolve: resolveCommand,
1636
+ },
1637
+ handler: async (_args, options) => {
1638
+ const mode = getOutputMode(options);
1639
+ if (mode === 'json') {
1640
+ return success({
1641
+ commands: ['config', 'link', 'link-all', 'unlink', 'push', 'pull', 'sync', 'status', 'resolve'],
1642
+ });
1643
+ }
1644
+ return failure('Usage: sf external-sync <command>\n\nCommands: config, link, link-all, unlink, push, pull, sync, status, resolve\n\nRun "sf external-sync --help" for more information.', ExitCode.INVALID_ARGUMENTS);
1645
+ },
1646
+ };
1647
+ //# sourceMappingURL=external-sync.js.map