dotmd-cli 0.1.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/src/config.mjs ADDED
@@ -0,0 +1,197 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+
5
+ const CONFIG_FILENAMES = ['dotmd.config.mjs', '.dotmd.config.mjs', 'dotmd.config.js'];
6
+
7
+ const DEFAULTS = {
8
+ root: '.',
9
+ archiveDir: 'archived',
10
+ excludeDirs: [],
11
+
12
+ statuses: {
13
+ order: ['active', 'ready', 'planned', 'research', 'blocked', 'reference', 'archived'],
14
+ staleDays: {
15
+ active: 14,
16
+ ready: 14,
17
+ planned: 30,
18
+ blocked: 30,
19
+ research: 30,
20
+ },
21
+ },
22
+
23
+ lifecycle: {
24
+ archiveStatuses: ['archived'],
25
+ skipStaleFor: ['archived', 'reference'],
26
+ skipWarningsFor: ['archived'],
27
+ },
28
+
29
+ taxonomy: {
30
+ surfaces: null,
31
+ moduleRequiredFor: [],
32
+ },
33
+
34
+ index: null,
35
+
36
+ context: {
37
+ expanded: ['active'],
38
+ listed: ['ready', 'planned'],
39
+ counted: ['blocked', 'research', 'reference', 'archived'],
40
+ recentDays: 3,
41
+ recentStatuses: ['active', 'ready', 'planned'],
42
+ recentLimit: 10,
43
+ truncateNextStep: 80,
44
+ },
45
+
46
+ display: {
47
+ lineWidth: 0,
48
+ truncateTitle: 30,
49
+ truncateNextStep: 80,
50
+ },
51
+
52
+ referenceFields: {
53
+ bidirectional: [],
54
+ unidirectional: [],
55
+ },
56
+
57
+ presets: {
58
+ stale: ['--status', 'active,ready,planned,blocked,research', '--stale', '--sort', 'updated', '--all'],
59
+ actionable: ['--status', 'active,ready', '--has-next-step', '--sort', 'updated', '--all'],
60
+ },
61
+ };
62
+
63
+ function findConfigFile(startDir) {
64
+ let dir = path.resolve(startDir);
65
+ const root = path.parse(dir).root;
66
+
67
+ while (dir !== root) {
68
+ for (const filename of CONFIG_FILENAMES) {
69
+ const candidate = path.join(dir, filename);
70
+ if (existsSync(candidate)) return candidate;
71
+ }
72
+ dir = path.dirname(dir);
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ function deepMerge(defaults, overrides) {
79
+ const result = { ...defaults };
80
+ for (const [key, value] of Object.entries(overrides)) {
81
+ if (value != null && typeof value === 'object' && !Array.isArray(value) &&
82
+ result[key] != null && typeof result[key] === 'object' && !Array.isArray(result[key])) {
83
+ result[key] = deepMerge(result[key], value);
84
+ } else {
85
+ result[key] = value;
86
+ }
87
+ }
88
+ return result;
89
+ }
90
+
91
+ export async function resolveConfig(cwd, explicitConfigPath) {
92
+ const configPath = explicitConfigPath
93
+ ? path.resolve(cwd, explicitConfigPath)
94
+ : findConfigFile(cwd);
95
+
96
+ let userConfig = {};
97
+ let hooks = {};
98
+ let configDir = cwd;
99
+
100
+ if (configPath && existsSync(configPath)) {
101
+ const configUrl = pathToFileURL(configPath).href;
102
+ const mod = await import(configUrl);
103
+
104
+ configDir = path.dirname(configPath);
105
+
106
+ for (const [key, value] of Object.entries(mod)) {
107
+ if (key === 'default') continue;
108
+ if (typeof value === 'function') {
109
+ hooks[key] = value;
110
+ } else {
111
+ userConfig[key] = value;
112
+ }
113
+ }
114
+ }
115
+
116
+ // Backwards compat: `readme` config key maps to `index`
117
+ if (userConfig.readme && !userConfig.index) {
118
+ userConfig.index = userConfig.readme;
119
+ delete userConfig.readme;
120
+ }
121
+
122
+ const config = deepMerge(DEFAULTS, userConfig);
123
+
124
+ const docsRoot = path.resolve(configDir, config.root);
125
+
126
+ // Find repo root by walking up looking for .git
127
+ let repoRoot = configDir;
128
+ {
129
+ let dir = configDir;
130
+ const fsRoot = path.parse(dir).root;
131
+ while (dir !== fsRoot) {
132
+ if (existsSync(path.join(dir, '.git'))) {
133
+ repoRoot = dir;
134
+ break;
135
+ }
136
+ dir = path.dirname(dir);
137
+ }
138
+ }
139
+
140
+ const statusOrder = config.statuses.order;
141
+ const validStatuses = new Set(statusOrder);
142
+ const staleDaysByStatus = {};
143
+ for (const status of statusOrder) {
144
+ staleDaysByStatus[status] = config.statuses.staleDays?.[status] ?? null;
145
+ }
146
+
147
+ const validSurfaces = config.taxonomy.surfaces
148
+ ? new Set(config.taxonomy.surfaces)
149
+ : null;
150
+ const moduleRequiredStatuses = new Set(config.taxonomy.moduleRequiredFor);
151
+
152
+ const indexPath = config.index?.path
153
+ ? path.resolve(repoRoot, config.index.path)
154
+ : null;
155
+
156
+ // Compute docs root relative path for index link stripping
157
+ const docsRootRelative = path.relative(repoRoot, docsRoot).split(path.sep).join('/');
158
+ const docsRootPrefix = docsRootRelative ? docsRootRelative + '/' : '';
159
+
160
+ // Lifecycle config
161
+ const lifecycle = config.lifecycle;
162
+ const archiveStatuses = new Set(lifecycle.archiveStatuses);
163
+ const skipStaleFor = new Set(lifecycle.skipStaleFor);
164
+ const skipWarningsFor = new Set(lifecycle.skipWarningsFor);
165
+
166
+ return {
167
+ raw: config,
168
+
169
+ docsRoot,
170
+ repoRoot,
171
+ configDir,
172
+ configPath: configPath ?? null,
173
+ archiveDir: config.archiveDir,
174
+ excludeDirs: new Set(config.excludeDirs),
175
+ docsRootPrefix,
176
+
177
+ statusOrder,
178
+ validStatuses,
179
+ staleDaysByStatus,
180
+
181
+ lifecycle: { archiveStatuses, skipStaleFor, skipWarningsFor },
182
+
183
+ validSurfaces,
184
+ moduleRequiredStatuses,
185
+
186
+ indexPath,
187
+ indexStartMarker: config.index?.startMarker ?? '<!-- GENERATED:dotmd:start -->',
188
+ indexEndMarker: config.index?.endMarker ?? '<!-- GENERATED:dotmd:end -->',
189
+ archivedHighlightLimit: config.index?.archivedLimit ?? 8,
190
+
191
+ context: config.context,
192
+ display: config.display,
193
+ referenceFields: config.referenceFields,
194
+ presets: config.presets,
195
+ hooks,
196
+ };
197
+ }
@@ -0,0 +1,60 @@
1
+ export function extractFirstHeading(body) {
2
+ return body.match(/^#\s+(.+)$/m)?.[1]?.trim() ?? null;
3
+ }
4
+
5
+ export function extractSummary(body) {
6
+ const blockquoteLines = body
7
+ .split('\n')
8
+ .filter(line => line.startsWith('> '))
9
+ .map(line => line.slice(2).trim())
10
+ .filter(Boolean);
11
+
12
+ const nonStatusLine = blockquoteLines.find(line => !/^Status note\b/i.test(line));
13
+ return nonStatusLine ?? blockquoteLines[0] ?? null;
14
+ }
15
+
16
+ export function extractStatusSnapshot(body) {
17
+ const statusNoteMatch = body.match(/^>\s+Status note(?:\s+\([^)]+\))?:\s*(.+)$/m);
18
+ if (statusNoteMatch) return statusNoteMatch[1].trim();
19
+
20
+ const boldStatusMatch = body.match(/^\*\*Status:\*\*\s*(.+)$/m);
21
+ if (boldStatusMatch) return boldStatusMatch[1].trim();
22
+
23
+ const plainStatusMatch = body.match(/^-\s+Status:\s*(.+)$/m);
24
+ if (plainStatusMatch) return plainStatusMatch[1].trim();
25
+
26
+ return null;
27
+ }
28
+
29
+ export function extractNextStep(body) {
30
+ const match = body.match(/^##+\s+(?:Suggested\s+)?Next Step\s*$([\s\S]*?)(?=^##+\s|\Z)/m);
31
+ if (!match) return null;
32
+
33
+ const lines = match[1]
34
+ .split('\n')
35
+ .map(line => line.trim())
36
+ .filter(Boolean)
37
+ .map(line => line.replace(/^[-*]\s+/, ''));
38
+
39
+ return lines[0] ?? null;
40
+ }
41
+
42
+ export function extractChecklistCounts(body) {
43
+ const matches = [...body.matchAll(/^\s*[-*]\s+\[([ xX])\]\s+/gm)];
44
+ let completed = 0;
45
+ let open = 0;
46
+
47
+ for (const match of matches) {
48
+ if (match[1].toLowerCase() === 'x') {
49
+ completed += 1;
50
+ } else {
51
+ open += 1;
52
+ }
53
+ }
54
+
55
+ return {
56
+ completed,
57
+ open,
58
+ total: completed + open,
59
+ };
60
+ }
@@ -0,0 +1,55 @@
1
+ export function extractFrontmatter(raw) {
2
+ if (!raw.startsWith('---\n')) {
3
+ return { frontmatter: '', body: raw };
4
+ }
5
+
6
+ const endMarker = raw.indexOf('\n---\n', 4);
7
+ if (endMarker === -1) {
8
+ return { frontmatter: '', body: raw };
9
+ }
10
+
11
+ return {
12
+ frontmatter: raw.slice(4, endMarker),
13
+ body: raw.slice(endMarker + 5),
14
+ };
15
+ }
16
+
17
+ export function parseSimpleFrontmatter(text) {
18
+ const data = {};
19
+ let currentArrayKey = null;
20
+
21
+ for (const rawLine of text.split('\n')) {
22
+ const line = rawLine.replace(/\r$/, '');
23
+ if (!line.trim()) continue;
24
+
25
+ const keyMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
26
+ if (keyMatch) {
27
+ const [, key, rawValue] = keyMatch;
28
+ if (!rawValue.trim()) {
29
+ data[key] = [];
30
+ currentArrayKey = key;
31
+ } else {
32
+ data[key] = parseScalar(rawValue.trim());
33
+ currentArrayKey = null;
34
+ }
35
+ continue;
36
+ }
37
+
38
+ if (currentArrayKey) {
39
+ const itemMatch = line.match(/^\s*-\s+(.*)$/);
40
+ if (itemMatch) {
41
+ data[currentArrayKey].push(parseScalar(itemMatch[1].trim()));
42
+ continue;
43
+ }
44
+ }
45
+ }
46
+
47
+ return data;
48
+ }
49
+
50
+ function parseScalar(value) {
51
+ const unquoted = value.replace(/^['"]|['"]$/g, '');
52
+ if (unquoted === 'true') return true;
53
+ if (unquoted === 'false') return false;
54
+ return unquoted;
55
+ }
package/src/git.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export function getGitLastModified(relPath, repoRoot) {
4
+ const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
5
+ cwd: repoRoot,
6
+ encoding: 'utf8',
7
+ });
8
+ if (result.status !== 0 || !result.stdout.trim()) return null;
9
+ return result.stdout.trim();
10
+ }
11
+
12
+ export function gitMv(source, target, repoRoot) {
13
+ const result = spawnSync('git', ['mv', source, target], {
14
+ cwd: repoRoot,
15
+ encoding: 'utf8',
16
+ });
17
+ return { status: result.status, stderr: result.stderr };
18
+ }
@@ -0,0 +1,104 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { capitalize, escapeTable } from './util.mjs';
3
+ import { formatSnapshot } from './render.mjs';
4
+
5
+ export function renderIndexFile(index, config) {
6
+ const current = readFileSync(config.indexPath, 'utf8');
7
+ const start = current.indexOf(config.indexStartMarker);
8
+ const end = current.indexOf(config.indexEndMarker);
9
+
10
+ if (start === -1 || end === -1 || end < start) {
11
+ throw new Error(`${config.indexPath} is missing generated block markers.`);
12
+ }
13
+
14
+ const before = current.slice(0, start + config.indexStartMarker.length);
15
+ const after = current.slice(end);
16
+ const generated = `\n\n${renderGeneratedBlock(index, config)}\n`;
17
+ return `${before}${generated}${after}`;
18
+ }
19
+
20
+ function renderGeneratedBlock(index, config) {
21
+ const lines = [];
22
+ const prefix = config.docsRootPrefix;
23
+
24
+ for (const status of config.statusOrder) {
25
+ const docs = index.docs.filter(doc => doc.status === status);
26
+ if (docs.length === 0) continue;
27
+
28
+ if (config.lifecycle.archiveStatuses.has(status)) {
29
+ lines.push(...renderArchivedSection(docs, config, status));
30
+ lines.push('');
31
+ continue;
32
+ }
33
+
34
+ lines.push(`## ${capitalize(status)}`);
35
+ lines.push('');
36
+ lines.push('| Doc | Status Snapshot |');
37
+ lines.push('|-----|-----------------|');
38
+ for (const doc of docs) {
39
+ const snapshot = formatSnapshot(doc, config);
40
+ const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
41
+ lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(snapshot)} |`);
42
+ }
43
+ lines.push('');
44
+ }
45
+
46
+ return lines.join('\n').trimEnd();
47
+ }
48
+
49
+ function renderArchivedSection(docs, config, status) {
50
+ const lines = [];
51
+ const limit = config.archivedHighlightLimit;
52
+ const prefix = config.docsRootPrefix;
53
+ const highlights = docs
54
+ .filter(doc => doc.currentState && doc.currentState !== 'No current_state set')
55
+ .sort((a, b) => {
56
+ const aUpdated = a.updated ?? '';
57
+ const bUpdated = b.updated ?? '';
58
+ return bUpdated.localeCompare(aUpdated);
59
+ })
60
+ .slice(0, limit);
61
+
62
+ lines.push(`## ${capitalize(status)}`);
63
+ lines.push('');
64
+ lines.push(`${capitalize(status)} docs are indexed by the CLI/JSON output. Showing ${highlights.length} recent or high-signal highlights out of ${docs.length} ${status} docs:`);
65
+ lines.push('');
66
+ lines.push('| Doc | Status Snapshot |');
67
+ lines.push('|-----|-----------------|');
68
+ for (const doc of highlights) {
69
+ const linkPath = prefix ? doc.path.replace(prefix, '') : doc.path;
70
+ lines.push(`| [${escapeTable(doc.title)}](${linkPath}) | ${escapeTable(formatSnapshot(doc, config))} |`);
71
+ }
72
+ lines.push('');
73
+ lines.push('- Use `dotmd list` or `dotmd json` for the full inventory.');
74
+
75
+ return lines;
76
+ }
77
+
78
+ export function writeIndex(content, config) {
79
+ writeFileSync(config.indexPath, content, 'utf8');
80
+ }
81
+
82
+ export function checkIndex(docs, config) {
83
+ const warnings = [];
84
+ const errors = [];
85
+
86
+ if (!config.indexPath) return { warnings, errors };
87
+
88
+ const current = readFileSync(config.indexPath, 'utf8');
89
+ const start = current.indexOf(config.indexStartMarker);
90
+ const end = current.indexOf(config.indexEndMarker);
91
+
92
+ if (start === -1 || end === -1 || end < start) {
93
+ errors.push({ path: config.indexPath, level: 'error', message: 'Missing generated index block markers.' });
94
+ return { warnings, errors };
95
+ }
96
+
97
+ const index = { docs };
98
+ const expected = renderIndexFile(index, config);
99
+ if (expected !== current) {
100
+ errors.push({ path: config.indexPath, level: 'error', message: 'Generated index block is stale. Run `dotmd index`.' });
101
+ }
102
+
103
+ return { warnings, errors };
104
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,151 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
4
+ import { extractFirstHeading, extractSummary, extractStatusSnapshot, extractNextStep, extractChecklistCounts } from './extractors.mjs';
5
+ import { asString, normalizeStringList, normalizeBlockers, mergeUniqueStrings, toRepoPath } from './util.mjs';
6
+ import { validateDoc, checkBidirectionalReferences, checkGitStaleness, computeDaysSinceUpdate, computeIsStale, computeChecklistCompletionRate } from './validate.mjs';
7
+ import { checkIndex } from './index-file.mjs';
8
+
9
+ export function buildIndex(config) {
10
+ const docs = collectDocFiles(config).map(f => parseDocFile(f, config));
11
+ const warnings = [];
12
+ const errors = [];
13
+
14
+ for (const doc of docs) {
15
+ warnings.push(...doc.warnings);
16
+ errors.push(...doc.errors);
17
+ }
18
+
19
+ if (config.hooks.validate) {
20
+ const ctx = { config, allDocs: docs, repoRoot: config.repoRoot };
21
+ for (const doc of docs) {
22
+ const result = config.hooks.validate(doc, ctx);
23
+ if (result?.errors) {
24
+ doc.errors.push(...result.errors);
25
+ errors.push(...result.errors);
26
+ }
27
+ if (result?.warnings) {
28
+ doc.warnings.push(...result.warnings);
29
+ warnings.push(...result.warnings);
30
+ }
31
+ }
32
+ }
33
+
34
+ const transformedDocs = config.hooks.transformDoc
35
+ ? docs.map(d => config.hooks.transformDoc(d) ?? d)
36
+ : docs;
37
+
38
+ const countsByStatus = Object.fromEntries(config.statusOrder.map(status => [
39
+ status,
40
+ transformedDocs.filter(doc => doc.status === status).length,
41
+ ]));
42
+
43
+ if (config.indexPath) {
44
+ const indexCheck = checkIndex(transformedDocs, config);
45
+ warnings.push(...indexCheck.warnings);
46
+ errors.push(...indexCheck.errors);
47
+ }
48
+
49
+ const refCheck = checkBidirectionalReferences(transformedDocs, config);
50
+ warnings.push(...refCheck.warnings);
51
+
52
+ const gitWarnings = checkGitStaleness(transformedDocs, config);
53
+ warnings.push(...gitWarnings);
54
+
55
+ return {
56
+ generatedAt: new Date().toISOString(),
57
+ docs: transformedDocs,
58
+ countsByStatus,
59
+ warnings,
60
+ errors,
61
+ };
62
+ }
63
+
64
+ export function collectDocFiles(config) {
65
+ const files = [];
66
+ const skipPaths = new Set();
67
+ if (config.indexPath) skipPaths.add(config.indexPath);
68
+ walkMarkdownFiles(config.docsRoot, files, config.excludeDirs, skipPaths);
69
+ return files.sort((a, b) => a.localeCompare(b));
70
+ }
71
+
72
+ function walkMarkdownFiles(directory, files, excludedDirs, skipPaths) {
73
+ let entries;
74
+ try {
75
+ entries = readdirSync(directory, { withFileTypes: true });
76
+ } catch {
77
+ return;
78
+ }
79
+ for (const entry of entries) {
80
+ if (entry.isDirectory()) {
81
+ if (excludedDirs && excludedDirs.has(entry.name)) continue;
82
+ walkMarkdownFiles(path.join(directory, entry.name), files, excludedDirs, skipPaths);
83
+ continue;
84
+ }
85
+ const fullPath = path.join(directory, entry.name);
86
+ if (!entry.isFile() || !entry.name.endsWith('.md') || skipPaths.has(fullPath)) continue;
87
+ files.push(fullPath);
88
+ }
89
+ }
90
+
91
+ export function parseDocFile(filePath, config) {
92
+ const relativePath = toRepoPath(filePath, config.repoRoot);
93
+ const raw = readFileSync(filePath, 'utf8');
94
+ const { frontmatter, body } = extractFrontmatter(raw);
95
+ const parsedFrontmatter = parseSimpleFrontmatter(frontmatter);
96
+ const headingTitle = extractFirstHeading(body);
97
+ const title = asString(parsedFrontmatter.title) ?? headingTitle ?? path.basename(filePath, '.md');
98
+ const summary = asString(parsedFrontmatter.summary) ?? extractSummary(body) ?? null;
99
+ const currentState = asString(parsedFrontmatter.current_state) ?? extractStatusSnapshot(body) ?? 'No current_state set';
100
+ const nextStep = asString(parsedFrontmatter.next_step) ?? extractNextStep(body) ?? null;
101
+ const blockers = normalizeBlockers(parsedFrontmatter.blockers);
102
+ const surface = asString(parsedFrontmatter.surface) ?? null;
103
+ const surfaces = normalizeStringList(parsedFrontmatter.surfaces);
104
+ const moduleName = asString(parsedFrontmatter.module) ?? null;
105
+ const modules = normalizeStringList(parsedFrontmatter.modules);
106
+ const domain = asString(parsedFrontmatter.domain) ?? null;
107
+ const audience = asString(parsedFrontmatter.audience) ?? null;
108
+ const executionMode = asString(parsedFrontmatter.execution_mode) ?? null;
109
+ const checklist = extractChecklistCounts(body);
110
+
111
+ // Dynamic reference field extraction
112
+ const refFields = {};
113
+ for (const field of [...(config.referenceFields.bidirectional || []), ...(config.referenceFields.unidirectional || [])]) {
114
+ refFields[field] = normalizeStringList(parsedFrontmatter[field]);
115
+ }
116
+
117
+ const doc = {
118
+ path: relativePath,
119
+ status: asString(parsedFrontmatter.status) ?? null,
120
+ owner: asString(parsedFrontmatter.owner) ?? null,
121
+ surface,
122
+ surfaces: mergeUniqueStrings(surface ? [surface] : [], surfaces),
123
+ module: moduleName,
124
+ modules: mergeUniqueStrings(moduleName ? [moduleName] : [], modules),
125
+ domain,
126
+ audience,
127
+ executionMode,
128
+ title,
129
+ summary,
130
+ currentState,
131
+ nextStep,
132
+ blockers,
133
+ updated: asString(parsedFrontmatter.updated) ?? null,
134
+ created: asString(parsedFrontmatter.created) ?? null,
135
+ audited: asString(parsedFrontmatter.audited) ?? null,
136
+ auditLevel: asString(parsedFrontmatter.audit_level) ?? null,
137
+ sourceOfTruth: asString(parsedFrontmatter.source_of_truth) ?? null,
138
+ checklist,
139
+ refFields,
140
+ checklistCompletionRate: computeChecklistCompletionRate(checklist),
141
+ hasNextStep: Boolean(nextStep),
142
+ hasBlockers: blockers.length > 0,
143
+ daysSinceUpdate: computeDaysSinceUpdate(asString(parsedFrontmatter.updated) ?? null),
144
+ isStale: computeIsStale(asString(parsedFrontmatter.status), asString(parsedFrontmatter.updated) ?? null, config),
145
+ warnings: [],
146
+ errors: [],
147
+ };
148
+
149
+ validateDoc(doc, parsedFrontmatter, headingTitle, config);
150
+ return doc;
151
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { green, dim } from './color.mjs';
4
+
5
+ const STARTER_CONFIG = `// dotmd.config.mjs — document management configuration
6
+ // All exports are optional. See dotmd.config.example.mjs for full reference.
7
+
8
+ export const root = 'docs';
9
+
10
+ export const index = {
11
+ path: 'docs/docs.md',
12
+ startMarker: '<!-- GENERATED:dotmd:start -->',
13
+ endMarker: '<!-- GENERATED:dotmd:end -->',
14
+ archivedLimit: 8,
15
+ };
16
+ `;
17
+
18
+ const STARTER_INDEX = `# Docs
19
+
20
+ <!-- GENERATED:dotmd:start -->
21
+
22
+ _No docs yet. Run \`dotmd list\` after creating your first document._
23
+
24
+ <!-- GENERATED:dotmd:end -->
25
+ `;
26
+
27
+ export function runInit(cwd) {
28
+ const configPath = path.join(cwd, 'dotmd.config.mjs');
29
+ const docsDir = path.join(cwd, 'docs');
30
+ const indexPath = path.join(docsDir, 'docs.md');
31
+
32
+ process.stdout.write('\n');
33
+
34
+ if (existsSync(configPath)) {
35
+ process.stdout.write(` ${dim('exists')} dotmd.config.mjs\n`);
36
+ } else {
37
+ writeFileSync(configPath, STARTER_CONFIG, 'utf8');
38
+ process.stdout.write(` ${green('create')} dotmd.config.mjs\n`);
39
+ }
40
+
41
+ if (existsSync(docsDir)) {
42
+ process.stdout.write(` ${dim('exists')} docs/\n`);
43
+ } else {
44
+ mkdirSync(docsDir, { recursive: true });
45
+ process.stdout.write(` ${green('create')} docs/\n`);
46
+ }
47
+
48
+ if (existsSync(indexPath)) {
49
+ process.stdout.write(` ${dim('exists')} docs/docs.md\n`);
50
+ } else {
51
+ writeFileSync(indexPath, STARTER_INDEX, 'utf8');
52
+ process.stdout.write(` ${green('create')} docs/docs.md\n`);
53
+ }
54
+
55
+ const today = new Date().toISOString().slice(0, 10);
56
+ process.stdout.write(`\nReady. Create your first doc:\n`);
57
+ process.stdout.write(` printf '---\\nstatus: active\\nupdated: ${today}\\n---\\n\\n# My Doc\\n' > docs/my-doc.md\n`);
58
+ process.stdout.write(` dotmd list\n\n`);
59
+ }
60
+