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