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