@dotdotgod/cli 0.2.14 → 0.2.17

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.
@@ -1,205 +0,0 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
- import { rel } from '../common/paths.mjs';
4
- import { matchPathPattern, readMemoryConfig, trelloSyncPaths } from '../memory/config.mjs';
5
- import { parseTrelloMetadata } from './metadata.mjs';
6
- import { resolveGitHubFileUrl } from './github-url.mjs';
7
- import { buildLinkedDocsData, summarizeTraceability } from './summary.mjs';
8
- import { formatTrelloDryRunReport, formatTrelloWriteReport } from './report.mjs';
9
- import { createTrelloClient, resolveTrelloCredentials } from './client.mjs';
10
- import { customFieldTextValue, dotdotgodCustomFieldName, mergeLinkedDocsCustomFieldValue } from './custom-fields.mjs';
11
-
12
- function shouldSkipDir(path) {
13
- return path === '.git' || path === 'node_modules' || path === '.dotdotgod';
14
- }
15
-
16
- function walkMarkdown(root, dir = root, files = []) {
17
- if (!existsSync(dir)) return files;
18
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
19
- if (entry.isDirectory()) {
20
- if (!shouldSkipDir(entry.name)) walkMarkdown(root, join(dir, entry.name), files);
21
- } else if (entry.isFile() && entry.name.endsWith('.md')) files.push(join(dir, entry.name));
22
- }
23
- return files;
24
- }
25
-
26
- function matchedByPatterns(path, patterns) {
27
- return patterns.some((pattern) => matchPathPattern(path, pattern));
28
- }
29
-
30
- function fileReport({ file, trelloUrl = '', shortLink = '', githubUrl = '', repositoryKey = '', syncStatus = 'blocked', traceability = { state: 'not_required', details: [] }, warnings = [], errors = [], linkedDocsData = null }) {
31
- return { file, trelloUrl, shortLink, githubUrl, repositoryKey, plannedAttachmentAction: errors.length > 0 ? 'blocked' : 'add-or-verify', plannedCustomFieldAction: errors.length > 0 ? 'blocked' : 'sync-linked-docs', syncStatus, traceability, linkedDocsData, warnings, errors };
32
- }
33
-
34
- function actionableError(error, fallback = 'Trello API request failed. Fix: check credentials, card access, and Trello response details, then retry.') {
35
- if (error && typeof error === 'object' && error.code && error.message) return error;
36
- return { code: 'TRELLO_API_FAILED', message: error?.message ? `${error.message} Fix: check credentials, card access, and Trello response details, then retry.` : fallback };
37
- }
38
-
39
- function duplicateErrors(files) {
40
- const byShortLink = new Map();
41
- for (const file of files.filter((item) => item.syncStatus === 'planned')) {
42
- byShortLink.set(file.shortLink, [...(byShortLink.get(file.shortLink) ?? []), file]);
43
- }
44
- const duplicateShortLinks = new Set([...byShortLink.entries()].filter(([, group]) => group.length > 1).map(([shortLink]) => shortLink));
45
- for (const shortLink of duplicateShortLinks) {
46
- const paths = byShortLink.get(shortLink).map((file) => file.file).join(', ');
47
- for (const file of byShortLink.get(shortLink)) {
48
- file.errors.push({ code: 'TRELLO_DUPLICATE_CARD', message: `Multiple markdown files point to Trello card ${shortLink}: ${paths}. Fix: keep only one markdown file linked to this Trello card, then retry sync.` });
49
- file.syncStatus = 'conflict';
50
- file.plannedAttachmentAction = 'conflict';
51
- file.plannedCustomFieldAction = 'conflict';
52
- }
53
- }
54
- return duplicateShortLinks.size;
55
- }
56
-
57
- export function planTrelloDryRun(rootInput = '.', options = {}) {
58
- const root = resolve(rootInput);
59
- const config = options.config ?? readMemoryConfig(root);
60
- const scanPaths = trelloSyncPaths(config);
61
- const reportWarnings = [];
62
- const reportErrors = [];
63
- if (config.errors?.length) reportErrors.push(...config.errors.map((error) => ({ code: error.code, message: error.message })));
64
- const allFiles = walkMarkdown(root).map((file) => ({ abs: file, rel: rel(root, file) })).filter((file) => matchedByPatterns(file.rel, scanPaths)).sort((a, b) => a.rel.localeCompare(b.rel));
65
- const files = [];
66
- if (allFiles.length === 0) reportWarnings.push({ code: 'TRELLO_NO_MATCHING_DOCS', message: 'No markdown files matched Trello sync paths.' });
67
- for (const item of allFiles) {
68
- const content = readFileSync(item.abs, 'utf8');
69
- const metadata = parseTrelloMetadata(content);
70
- const warnings = [...metadata.warnings];
71
- const errors = [...metadata.errors];
72
- let githubUrl = '';
73
- let repositoryKey = '';
74
- if (errors.length === 0) {
75
- const resolved = resolveGitHubFileUrl({ root, file: item.rel, env: options.env ?? process.env, mode: options.mode ?? 'dry-run', ...(options.github ?? {}) });
76
- warnings.push(...resolved.warnings);
77
- if (resolved.ok) {
78
- githubUrl = resolved.url;
79
- repositoryKey = resolved.repositoryKey;
80
- } else errors.push(...resolved.errors);
81
- }
82
- const traceability = summarizeTraceability({ content, root, file: item.rel, config });
83
- if (traceability.errors.length > 0) errors.push(...traceability.errors);
84
- const syncStatus = errors.length > 0 ? 'blocked' : 'planned';
85
- const linkedDocsData = buildLinkedDocsData({ file: item.rel, content, githubUrl, trelloUrl: metadata.trelloUrl, syncStatus, traceability, repositoryKey });
86
- files.push(fileReport({ file: item.rel, trelloUrl: metadata.trelloUrl, shortLink: metadata.shortLink, githubUrl, repositoryKey, syncStatus, traceability: { state: traceability.state, details: traceability.details }, warnings, errors, linkedDocsData }));
87
- }
88
- duplicateErrors(files);
89
- const warningCount = reportWarnings.length + files.reduce((sum, file) => sum + file.warnings.length, 0);
90
- const errorCount = reportErrors.length + files.reduce((sum, file) => sum + file.errors.length, 0);
91
- const report = { ok: errorCount === 0, command: 'trello sync', mode: 'dry-run', root, scanPaths, totals: { matchedFiles: files.length, plannedUpdates: files.filter((file) => file.syncStatus === 'planned').length, warnings: warningCount, errors: errorCount }, warnings: reportWarnings, errors: reportErrors, files };
92
- return { report, exitCode: errorCount > 0 ? 1 : 0 };
93
- }
94
-
95
- function trustedCiWriteContext(env = process.env) {
96
- const defaultBranch = env.GITHUB_DEFAULT_BRANCH || env.DOTDOTGOD_DEFAULT_BRANCH || env.GITHUB_EVENT_REPOSITORY_DEFAULT_BRANCH;
97
- return env.GITHUB_ACTIONS === 'true' && env.GITHUB_EVENT_NAME === 'push' && Boolean(defaultBranch) && env.GITHUB_REF === `refs/heads/${defaultBranch}`;
98
- }
99
-
100
- async function runTrelloWrite(rootInput = '.', options = {}) {
101
- const env = options.env ?? process.env;
102
- const { report: dryRunReport } = planTrelloDryRun(rootInput, { ...options, mode: 'write', env });
103
- const report = { ...dryRunReport, mode: 'write', customFieldName: dotdotgodCustomFieldName({ env, name: options.customFieldName }), files: dryRunReport.files.map((file) => ({ ...file, attachment: { status: 'not-run' }, customField: { status: 'not-run' }, finalStatus: file.syncStatus === 'blocked' ? 'blocked' : 'pending' })) };
104
-
105
- if (!trustedCiWriteContext(env)) {
106
- return { exitCode: 2, stderr: 'Trello write mode is only allowed from the trusted GitHub Actions default-branch push workflow. Fix: run dotdotgod trello sync <root> --dry-run locally or merge docs to the default branch so the workflow can write to Trello.', report };
107
- }
108
-
109
- if (report.files.length === 0) {
110
- report.totals = { matchedFiles: 0, written: 0, unchanged: 0, blocked: 0, conflict: 0, apiFailed: 0, warnings: report.totals.warnings, errors: report.totals.errors };
111
- report.ok = report.totals.errors === 0;
112
- return { report, exitCode: report.ok ? 0 : 1 };
113
- }
114
-
115
- const credentials = options.credentials ?? resolveTrelloCredentials({ env });
116
- if (!credentials.ok) return { exitCode: 2, stderr: credentials.error.message, report };
117
-
118
- const client = options.trelloClient ?? createTrelloClient({ credentials: credentials.credentials, fetchImpl: options.fetch, baseUrl: options.trelloBaseUrl });
119
-
120
- for (const file of report.files) {
121
- if (file.syncStatus === 'blocked') {
122
- file.finalStatus = 'blocked';
123
- continue;
124
- }
125
- if (file.syncStatus === 'conflict') {
126
- file.finalStatus = 'conflict';
127
- continue;
128
- }
129
- let step = 'card';
130
- try {
131
- const card = await client.getCard(file.shortLink);
132
- step = 'attachments';
133
- const attachments = await client.listAttachments(file.shortLink);
134
- if (attachments.some((attachment) => attachment?.url === file.githubUrl)) {
135
- file.attachment.status = 'unchanged';
136
- } else {
137
- step = 'attachment-create';
138
- await client.createAttachment(file.shortLink, file.githubUrl);
139
- file.attachment.status = 'written';
140
- }
141
- step = 'custom-field-definition';
142
- const fields = await client.listCustomFields(card.idBoard);
143
- let field = fields.find((item) => item?.name === report.customFieldName && item?.type === 'text');
144
- if (!field) field = await client.createCustomField(card.idBoard, { name: report.customFieldName, type: 'text' });
145
- if (!field?.id) throw { code: 'TRELLO_CUSTOM_FIELD_INVALID', message: 'Trello custom field creation returned no field id. Fix: check board custom field permissions and retry.' };
146
- const merged = mergeLinkedDocsCustomFieldValue(customFieldTextValue(card, field.id), file.linkedDocsData);
147
- if (!merged.ok) {
148
- file.customField.status = 'conflict';
149
- file.errors.push(merged.error);
150
- file.finalStatus = 'conflict';
151
- continue;
152
- }
153
- file.customField.action = merged.action;
154
- file.customField.preservedRepositoryEntries = merged.preservedRepositoryEntries ?? 0;
155
- file.customField.name = report.customFieldName;
156
- if (merged.status === 'unchanged') {
157
- file.customField.status = 'unchanged';
158
- } else {
159
- step = 'custom-field-update';
160
- await client.updateCardCustomFieldText(card.id, field.id, merged.text);
161
- file.customField.status = 'written';
162
- }
163
- file.finalStatus = file.attachment.status === 'written' || file.customField.status === 'written' ? 'written' : 'unchanged';
164
- } catch (error) {
165
- const normalized = actionableError(error);
166
- if (step === 'attachment-create') file.attachment.status = 'failed';
167
- else if (step.startsWith('custom-field')) file.customField.status = 'failed';
168
- file.finalStatus = 'api-failed';
169
- file.errors.push(normalized);
170
- }
171
- }
172
-
173
- const counts = { written: 0, unchanged: 0, blocked: 0, conflict: 0, apiFailed: 0 };
174
- for (const file of report.files) {
175
- if (file.finalStatus === 'written') counts.written += 1;
176
- else if (file.finalStatus === 'unchanged') counts.unchanged += 1;
177
- else if (file.finalStatus === 'blocked') counts.blocked += 1;
178
- else if (file.finalStatus === 'conflict') counts.conflict += 1;
179
- else if (file.finalStatus === 'api-failed') counts.apiFailed += 1;
180
- }
181
- const warningCount = report.warnings.length + report.files.reduce((sum, file) => sum + file.warnings.length, 0);
182
- const errorCount = report.errors.length + report.files.reduce((sum, file) => sum + file.errors.length, 0);
183
- report.totals = { matchedFiles: report.files.length, ...counts, warnings: warningCount, errors: errorCount };
184
- report.ok = counts.blocked === 0 && counts.conflict === 0 && counts.apiFailed === 0 && errorCount === 0;
185
- return { report, exitCode: report.ok ? 0 : 1 };
186
- }
187
-
188
- export function runTrelloSync(argv = [], options = {}) {
189
- if (argv[0] !== 'sync') return { exitCode: 2, stderr: `Unknown Trello command: ${argv[0] ?? ''}`.trim() };
190
- const args = argv.slice(1);
191
- let root = '.';
192
- let dryRun = false;
193
- for (const arg of args) {
194
- if (arg === '--dry-run') dryRun = true;
195
- else if (arg.startsWith('-')) return { exitCode: 2, stderr: `Unknown option: ${arg}` };
196
- else root = arg;
197
- }
198
- const resolvedRoot = resolve(root);
199
- if (!existsSync(resolvedRoot) || !statSync(resolvedRoot).isDirectory()) return { exitCode: 2, stderr: `Project root not found: ${resolvedRoot}` };
200
- if (dryRun) {
201
- const { report, exitCode } = planTrelloDryRun(resolvedRoot, options);
202
- return { exitCode, stdout: formatTrelloDryRunReport(report), report };
203
- }
204
- return runTrelloWrite(resolvedRoot, options).then((result) => ({ ...result, stdout: result.stdout ?? (result.report ? formatTrelloWriteReport(result.report) : '') }));
205
- }