dotmd-cli 0.69.0 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +238 -195
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +3 -3
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +134 -75
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/notion.mjs DELETED
@@ -1,528 +0,0 @@
1
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
- import { buildIndex } from './index.mjs';
5
- import { asString, toRepoPath, die, warn } from './util.mjs';
6
- import { bold, green, yellow, dim } from './color.mjs';
7
-
8
- let notionClient;
9
- let notionToMd;
10
-
11
- async function loadDeps() {
12
- if (notionClient) return;
13
- try {
14
- const { Client } = await import('@notionhq/client');
15
- const { NotionToMarkdown } = await import('notion-to-md');
16
- notionClient = Client;
17
- notionToMd = NotionToMarkdown;
18
- } catch {
19
- die('Notion integration needs its optional dependencies. Run: npm install @notionhq/client notion-to-md\n(These ship as optionalDependencies — a plain `npm i -g dotmd-cli` installs them unless you passed `--omit=optional`.)');
20
- }
21
- }
22
-
23
- function getClient(config) {
24
- const token = config.raw?.notion?.token ?? process.env.NOTION_TOKEN;
25
- if (!token) die('No Notion token. Set NOTION_TOKEN env var or add notion.token to your config.');
26
- return new notionClient({ auth: token });
27
- }
28
-
29
- function getDbId(argv, config) {
30
- const positional = [];
31
- for (let i = 0; i < argv.length; i++) {
32
- if (argv[i] === '--config') { i++; continue; }
33
- if (argv[i].startsWith('-')) continue;
34
- positional.push(argv[i]);
35
- }
36
- const id = positional[0] ?? config.raw?.notion?.database;
37
- if (!id) die('No database ID. Pass as argument or set notion.database in config.');
38
- return id;
39
- }
40
-
41
- function hasFlag(argv, flag) {
42
- return argv.includes(flag);
43
- }
44
-
45
- // ── Property mapping: Notion → frontmatter ─────────────────────────────
46
-
47
- function mapPropertiesToFrontmatter(properties, config) {
48
- const fm = {};
49
- const map = config.raw?.notion?.propertyMap ?? {};
50
-
51
- for (const [name, prop] of Object.entries(properties)) {
52
- const key = map[name] ?? name.toLowerCase().replace(/\s+/g, '_');
53
- switch (prop.type) {
54
- case 'title':
55
- fm.title = prop.title.map(t => t.plain_text).join('');
56
- break;
57
- case 'rich_text':
58
- fm[key] = prop.rich_text.map(t => t.plain_text).join('');
59
- break;
60
- case 'select':
61
- fm[key] = prop.select?.name ?? null;
62
- break;
63
- case 'multi_select':
64
- fm[key] = prop.multi_select.map(o => o.name);
65
- break;
66
- case 'date':
67
- fm[key] = prop.date?.start ?? null;
68
- break;
69
- case 'checkbox':
70
- fm[key] = prop.checkbox;
71
- break;
72
- case 'number':
73
- fm[key] = prop.number;
74
- break;
75
- case 'url':
76
- fm[key] = prop.url;
77
- break;
78
- case 'email':
79
- fm[key] = prop.email;
80
- break;
81
- case 'status':
82
- fm[key] = prop.status?.name?.toLowerCase() ?? null;
83
- break;
84
- case 'people':
85
- fm[key] = prop.people.map(p => p.name ?? p.id);
86
- break;
87
- case 'relation':
88
- // Store as page IDs for now; could resolve titles
89
- fm[key] = prop.relation.map(r => r.id);
90
- break;
91
- case 'formula':
92
- case 'rollup':
93
- case 'created_time':
94
- case 'last_edited_time':
95
- case 'created_by':
96
- case 'last_edited_by':
97
- break; // skip computed/system
98
- }
99
- }
100
- return fm;
101
- }
102
-
103
- // ── Property mapping: frontmatter → Notion ─────────────────────────────
104
-
105
- function mapFrontmatterToProperties(doc, dbProperties, config) {
106
- const reverseMap = {};
107
- const map = config.raw?.notion?.propertyMap ?? {};
108
- for (const [notionName, fmKey] of Object.entries(map)) {
109
- reverseMap[fmKey] = notionName;
110
- }
111
-
112
- const properties = {};
113
-
114
- for (const [notionName, propDef] of Object.entries(dbProperties)) {
115
- const fmKey = map[notionName] ?? notionName.toLowerCase().replace(/\s+/g, '_');
116
-
117
- // Title property
118
- if (propDef.type === 'title') {
119
- properties[notionName] = {
120
- title: [{ type: 'text', text: { content: doc.title ?? '' } }],
121
- };
122
- continue;
123
- }
124
-
125
- // Check if we have a frontmatter value for this property
126
- const value = doc[fmKey] ?? null;
127
- if (value === null || value === undefined) continue;
128
-
129
- switch (propDef.type) {
130
- case 'rich_text':
131
- properties[notionName] = {
132
- rich_text: [{ type: 'text', text: { content: String(value) } }],
133
- };
134
- break;
135
- case 'select':
136
- if (typeof value === 'string' && value) {
137
- properties[notionName] = { select: { name: value } };
138
- }
139
- break;
140
- case 'multi_select':
141
- if (Array.isArray(value)) {
142
- properties[notionName] = { multi_select: value.map(v => ({ name: String(v) })) };
143
- }
144
- break;
145
- case 'date':
146
- if (typeof value === 'string' && value) {
147
- properties[notionName] = { date: { start: value } };
148
- }
149
- break;
150
- case 'checkbox':
151
- properties[notionName] = { checkbox: Boolean(value) };
152
- break;
153
- case 'number':
154
- if (typeof value === 'number') {
155
- properties[notionName] = { number: value };
156
- }
157
- break;
158
- case 'url':
159
- if (typeof value === 'string' && value) {
160
- properties[notionName] = { url: value };
161
- }
162
- break;
163
- case 'email':
164
- if (typeof value === 'string' && value) {
165
- properties[notionName] = { email: value };
166
- }
167
- break;
168
- case 'status':
169
- if (typeof value === 'string' && value) {
170
- properties[notionName] = { status: { name: value } };
171
- }
172
- break;
173
- // Skip: formula, rollup, relation (complex), created_time, etc.
174
- }
175
- }
176
-
177
- return properties;
178
- }
179
-
180
- // ── Serialization helpers ──────────────────────────────────────────────
181
-
182
- function serializeFrontmatter(fm) {
183
- const lines = [];
184
- for (const [key, value] of Object.entries(fm)) {
185
- if (value === null || value === undefined) continue;
186
- if (Array.isArray(value)) {
187
- if (value.length === 0) continue;
188
- lines.push(`${key}:`);
189
- for (const item of value) lines.push(` - ${item}`);
190
- } else {
191
- lines.push(`${key}: ${value}`);
192
- }
193
- }
194
- return lines.join('\n');
195
- }
196
-
197
- function slugify(text) {
198
- return text.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'untitled';
199
- }
200
-
201
- function loadBody(doc, config) {
202
- const raw = readFileSync(path.join(config.repoRoot, doc.path), 'utf8');
203
- const { body } = extractFrontmatter(raw);
204
- return body ?? '';
205
- }
206
-
207
- // ── Paginated query helper ─────────────────────────────────────────────
208
-
209
- async function queryAllPages(client, dbId) {
210
- const pages = [];
211
- let cursor;
212
- do {
213
- const response = await client.databases.query({
214
- database_id: dbId,
215
- start_cursor: cursor,
216
- });
217
- pages.push(...response.results);
218
- cursor = response.has_more ? response.next_cursor : undefined;
219
- } while (cursor);
220
- return pages;
221
- }
222
-
223
- async function findPageByTitle(client, dbId, title) {
224
- const response = await client.databases.query({
225
- database_id: dbId,
226
- filter: { property: 'title', title: { equals: title } },
227
- page_size: 1,
228
- });
229
- return response.results[0] ?? null;
230
- }
231
-
232
- // ── Import: Notion → local .md ─────────────────────────────────────────
233
-
234
- export async function runNotionImport(argv, config, opts = {}) {
235
- await loadDeps();
236
- const client = getClient(config);
237
- const n2m = new notionToMd({ notionClient: client });
238
- const dbId = getDbId(argv, config);
239
- const force = hasFlag(argv, '--force');
240
- const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
241
-
242
- process.stdout.write(`Importing from Notion database ${dim(dbId)}...\n`);
243
-
244
- const pages = await queryAllPages(client, dbId);
245
- process.stdout.write(`Found ${pages.length} pages.\n\n`);
246
-
247
- let created = 0, skipped = 0, updated = 0;
248
- const prefix = dryRun ? dim('[dry-run] ') : '';
249
-
250
- for (const page of pages) {
251
- const fm = mapPropertiesToFrontmatter(page.properties, config);
252
- const title = fm.title ?? 'Untitled';
253
- delete fm.title; // title goes in heading, not frontmatter
254
-
255
- // Add notion_id for sync tracking
256
- fm.notion_id = page.id;
257
- if (!fm.updated) fm.updated = page.last_edited_time?.slice(0, 10) ?? new Date().toISOString().slice(0, 10);
258
-
259
- const slug = slugify(title);
260
- const filePath = path.join(config.docsRoot, slug + '.md');
261
- const repoPath = toRepoPath(filePath, config.repoRoot);
262
-
263
- if (existsSync(filePath) && !force) {
264
- process.stdout.write(`${prefix}${dim('skip')} ${repoPath} (exists, use --force to overwrite)\n`);
265
- skipped++;
266
- continue;
267
- }
268
-
269
- // Convert blocks to markdown
270
- let body = '';
271
- try {
272
- const mdBlocks = await n2m.pageToMarkdown(page.id);
273
- body = n2m.toMarkdownString(mdBlocks).parent ?? '';
274
- } catch (err) {
275
- warn(`Failed to convert blocks for "${title}": ${err.message}`);
276
- }
277
-
278
- const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
279
-
280
- if (!dryRun) {
281
- writeFileSync(filePath, content, 'utf8');
282
- }
283
-
284
- const action = existsSync(filePath) ? 'update' : 'create';
285
- process.stdout.write(`${prefix}${green(action)} ${repoPath}\n`);
286
- if (action === 'update') updated++;
287
- else created++;
288
- }
289
-
290
- process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated, ${skipped} skipped.\n`);
291
- }
292
-
293
- // ── Export: local docs → Notion ─────────────────────────────────────────
294
-
295
- export async function runNotionExport(argv, config, opts = {}) {
296
- await loadDeps();
297
- const client = getClient(config);
298
- const dbId = getDbId(argv, config);
299
- const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
300
-
301
- process.stdout.write(`Exporting to Notion database ${dim(dbId)}...\n`);
302
-
303
- // Get database schema for property mapping
304
- const dbInfo = await client.databases.retrieve({ database_id: dbId });
305
- const dbProperties = dbInfo.properties;
306
-
307
- const index = buildIndex(config);
308
- process.stdout.write(`Found ${index.docs.length} local docs.\n\n`);
309
-
310
- let created = 0, updated = 0;
311
- const prefix = dryRun ? dim('[dry-run] ') : '';
312
-
313
- for (const doc of index.docs) {
314
- const properties = mapFrontmatterToProperties(doc, dbProperties, config);
315
- const body = loadBody(doc, config);
316
-
317
- const existing = await findPageByTitle(client, dbId, doc.title);
318
-
319
- if (dryRun) {
320
- process.stdout.write(`${prefix}${existing ? 'update' : 'create'} ${doc.path}\n`);
321
- if (existing) updated++;
322
- else created++;
323
- continue;
324
- }
325
-
326
- try {
327
- if (existing) {
328
- await client.pages.update({
329
- page_id: existing.id,
330
- properties,
331
- markdown: body,
332
- });
333
- process.stdout.write(`${green('update')} ${doc.path}\n`);
334
- updated++;
335
- } else {
336
- await client.pages.create({
337
- parent: { database_id: dbId },
338
- properties,
339
- markdown: body,
340
- });
341
- process.stdout.write(`${green('create')} ${doc.path}\n`);
342
- created++;
343
- }
344
- } catch (err) {
345
- warn(`Failed to export "${doc.title}": ${err.message}`);
346
- }
347
- }
348
-
349
- process.stdout.write(`\n${prefix}Done: ${created} created, ${updated} updated.\n`);
350
- }
351
-
352
- // ── Sync: bidirectional merge ───────────────────────────────────────────
353
-
354
- export async function runNotionSync(argv, config, opts = {}) {
355
- await loadDeps();
356
- const client = getClient(config);
357
- const n2m = new notionToMd({ notionClient: client });
358
- const dbId = getDbId(argv, config);
359
- const dryRun = opts.dryRun || hasFlag(argv, '--dry-run') || hasFlag(argv, '-n');
360
-
361
- process.stdout.write(`Syncing with Notion database ${dim(dbId)}...\n\n`);
362
-
363
- // Get database schema
364
- const dbInfo = await client.databases.retrieve({ database_id: dbId });
365
- const dbProperties = dbInfo.properties;
366
-
367
- // Get remote pages
368
- const remotePages = await queryAllPages(client, dbId);
369
- const remoteBySlug = new Map();
370
- for (const page of remotePages) {
371
- const title = page.properties.title?.title?.map(t => t.plain_text).join('') ??
372
- Object.values(page.properties).find(p => p.type === 'title')?.title?.map(t => t.plain_text).join('') ?? '';
373
- const slug = slugify(title);
374
- remoteBySlug.set(slug, { page, title });
375
- }
376
-
377
- // Get local docs
378
- const index = buildIndex(config);
379
- const localBySlug = new Map();
380
- for (const doc of index.docs) {
381
- const slug = path.basename(doc.path, '.md');
382
- localBySlug.set(slug, doc);
383
- }
384
-
385
- const allSlugs = new Set([...remoteBySlug.keys(), ...localBySlug.keys()]);
386
- let pulled = 0, pushed = 0, conflicts = 0, skipped = 0;
387
- const prefix = dryRun ? dim('[dry-run] ') : '';
388
-
389
- for (const slug of allSlugs) {
390
- const remote = remoteBySlug.get(slug);
391
- const local = localBySlug.get(slug);
392
-
393
- if (remote && !local) {
394
- // New in Notion → pull
395
- const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
396
- const title = fm.title ?? remote.title;
397
- delete fm.title;
398
- fm.notion_id = remote.page.id;
399
- if (!fm.updated) fm.updated = remote.page.last_edited_time?.slice(0, 10);
400
-
401
- let body = '';
402
- try {
403
- const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
404
- body = n2m.toMarkdownString(mdBlocks).parent ?? '';
405
- } catch (err) { warn(`Could not convert Notion page body for "${title}": ${err.message}`); }
406
-
407
- const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
408
- const filePath = path.join(config.docsRoot, slug + '.md');
409
-
410
- if (!dryRun) writeFileSync(filePath, content, 'utf8');
411
- process.stdout.write(`${prefix}${green('pull')} ${slug} (new in Notion)\n`);
412
- pulled++;
413
- continue;
414
- }
415
-
416
- if (local && !remote) {
417
- // New locally → push
418
- const properties = mapFrontmatterToProperties(local, dbProperties, config);
419
- const body = loadBody(local, config);
420
-
421
- if (!dryRun) {
422
- try {
423
- await client.pages.create({
424
- parent: { database_id: dbId },
425
- properties,
426
- markdown: body,
427
- });
428
- } catch (err) {
429
- warn(`Failed to push "${local.title}": ${err.message}`);
430
- continue;
431
- }
432
- }
433
- process.stdout.write(`${prefix}${green('push')} ${slug} (new locally)\n`);
434
- pushed++;
435
- continue;
436
- }
437
-
438
- // Both exist — compare timestamps
439
- const remoteTime = remote.page.last_edited_time?.slice(0, 10) ?? '';
440
- const localTime = local.updated ?? '';
441
-
442
- if (remoteTime === localTime) {
443
- skipped++;
444
- continue;
445
- }
446
-
447
- if (remoteTime > localTime) {
448
- // Notion is newer → pull
449
- const fm = mapPropertiesToFrontmatter(remote.page.properties, config);
450
- const title = fm.title ?? remote.title;
451
- delete fm.title;
452
- fm.notion_id = remote.page.id;
453
- fm.updated = remoteTime;
454
-
455
- let body = '';
456
- try {
457
- const mdBlocks = await n2m.pageToMarkdown(remote.page.id);
458
- body = n2m.toMarkdownString(mdBlocks).parent ?? '';
459
- } catch (err) { warn(`Could not convert Notion page body for "${title}": ${err.message}`); }
460
-
461
- const content = `---\n${serializeFrontmatter(fm)}\n---\n\n# ${title}\n\n${body}`;
462
- const filePath = path.join(config.repoRoot, local.path);
463
-
464
- if (!dryRun) writeFileSync(filePath, content, 'utf8');
465
- process.stdout.write(`${prefix}${green('pull')} ${slug} (Notion newer: ${remoteTime} > ${localTime})\n`);
466
- pulled++;
467
- } else {
468
- // Local is newer → push
469
- const properties = mapFrontmatterToProperties(local, dbProperties, config);
470
- const body = loadBody(local, config);
471
-
472
- if (!dryRun) {
473
- try {
474
- await client.pages.update({
475
- page_id: remote.page.id,
476
- properties,
477
- markdown: body,
478
- });
479
- } catch (err) {
480
- warn(`Failed to push "${local.title}": ${err.message}`);
481
- continue;
482
- }
483
- }
484
- process.stdout.write(`${prefix}${green('push')} ${slug} (local newer: ${localTime} > ${remoteTime})\n`);
485
- pushed++;
486
- }
487
- }
488
-
489
- process.stdout.write(`\n${prefix}Done: ${pulled} pulled, ${pushed} pushed, ${skipped} unchanged.\n`);
490
- }
491
-
492
- // ── CLI dispatcher ──────────────────────────────────────────────────────
493
-
494
- export async function runNotion(argv, config, opts = {}) {
495
- // Filter out global flags before finding subcommand
496
- const filtered = [];
497
- for (let i = 0; i < argv.length; i++) {
498
- if (argv[i] === '--config') { i++; continue; }
499
- if (argv[i] === '--verbose') continue;
500
- filtered.push(argv[i]);
501
- }
502
- const subcommand = filtered[0];
503
- const restArgs = filtered.slice(1);
504
-
505
- if (!subcommand || subcommand === '--help' || subcommand === '-h') {
506
- process.stdout.write(`dotmd notion — Notion database integration
507
-
508
- Subcommands:
509
- import <database-id> Pull Notion database → local .md files
510
- export <database-id> Push local docs → Notion database rows
511
- sync <database-id> Bidirectional sync (merge by slug)
512
-
513
- Options:
514
- --force Overwrite existing files on import
515
- --dry-run, -n Preview without changes
516
-
517
- Requires NOTION_TOKEN env var or notion.token in config.
518
- Database ID can be set in config as notion.database.
519
- `);
520
- return;
521
- }
522
-
523
- if (subcommand === 'import') return runNotionImport(restArgs, config, opts);
524
- if (subcommand === 'export') return runNotionExport(restArgs, config, opts);
525
- if (subcommand === 'sync') return runNotionSync(restArgs, config, opts);
526
-
527
- die(`Unknown notion subcommand: ${subcommand}\nRun \`dotmd notion --help\` for usage.`);
528
- }