dotmd-cli 0.41.1 → 0.42.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/bin/dotmd.mjs +27 -0
- package/package.json +1 -1
- package/src/claude-commands.mjs +19 -18
- package/src/commands.mjs +1 -0
- package/src/ship.mjs +149 -0
package/bin/dotmd.mjs
CHANGED
|
@@ -55,6 +55,7 @@ Lifecycle:
|
|
|
55
55
|
set <status> [<file>] Unified transition: archive/release/transition in one verb
|
|
56
56
|
archive <file> Archive (status + move + update refs)
|
|
57
57
|
bulk archive <f1> <f2> ... Archive multiple files at once
|
|
58
|
+
ship [patch|minor|major] Regen + commit + bump in one step (default: patch)
|
|
58
59
|
bulk-tag [files...] Tag pre-existing untagged .md files
|
|
59
60
|
touch <file> Bump updated date
|
|
60
61
|
touch --git Bulk-sync dates from git history
|
|
@@ -295,6 +296,31 @@ status. With no file, releases every lease owned by the current session.
|
|
|
295
296
|
Identical behavior to \`dotmd unpickup\`; both names route to the same
|
|
296
297
|
implementation. See \`dotmd unpickup --help\` for full option list.`,
|
|
297
298
|
|
|
299
|
+
ship: `dotmd ship [patch|minor|major] — regen + commit + bump in one step
|
|
300
|
+
|
|
301
|
+
Bundles the multi-step release dance into a single command:
|
|
302
|
+
1. Regenerate \`.claude/commands/*.md\` with the TARGET version stamp
|
|
303
|
+
(the post-bump version, so the slash-command files match the new
|
|
304
|
+
release and no dirty tree lingers after).
|
|
305
|
+
2. Auto-stage every dirty file matching the release allowlist
|
|
306
|
+
(src/, test/, bin/, docs/, .claude/commands/, package*.json,
|
|
307
|
+
dotmd.config*.mjs, README.md, CLAUDE.md, .gitignore). Anything
|
|
308
|
+
outside the allowlist is left dirty — secrets, sibling-session
|
|
309
|
+
WIP, etc. never get bundled in.
|
|
310
|
+
3. Commit with an auto-generated message including the held plan
|
|
311
|
+
title (if any).
|
|
312
|
+
4. Run \`npm version <bump>\` to bump package.json, tag, push, run
|
|
313
|
+
the publish workflow, and reinstall locally.
|
|
314
|
+
|
|
315
|
+
Options:
|
|
316
|
+
--dry-run, -n Show what would happen without staging or bumping.
|
|
317
|
+
|
|
318
|
+
Defaults to patch. Pass \`minor\` or \`major\` to bump those instead.
|
|
319
|
+
|
|
320
|
+
Network failures mid-bump (e.g. \`git push\` fails) leave the local
|
|
321
|
+
commit + tag intact. Inspect with \`git log -1\` and rerun
|
|
322
|
+
\`git push origin main --tags\` to recover.`,
|
|
323
|
+
|
|
298
324
|
set: `dotmd set <status> [<file>] — unified status-transition verb
|
|
299
325
|
|
|
300
326
|
Routes to the right plumbing based on the target status:
|
|
@@ -1150,6 +1176,7 @@ async function main() {
|
|
|
1150
1176
|
if (command === 'handoff') { die('`dotmd handoff` was removed in 0.31.0. Use `dotmd prompts new <name>` to create a saved prompt instead. The .dotmd/handoffs/ sidecar mechanism no longer exists; see CHANGELOG.'); }
|
|
1151
1177
|
if (command === 'status') { const { runStatus } = await import('../src/lifecycle.mjs'); await runStatus(restArgs, config, { dryRun }); return; }
|
|
1152
1178
|
if (command === 'set') { const { runSet } = await import('../src/lifecycle.mjs'); await runSet(restArgs, config, { dryRun }); return; }
|
|
1179
|
+
if (command === 'ship') { const { runShip } = await import('../src/ship.mjs'); await runShip(restArgs, config, { dryRun }); return; }
|
|
1153
1180
|
if (command === 'archive') { const { runArchive } = await import('../src/lifecycle.mjs'); runArchive(restArgs, config, { dryRun }); return; }
|
|
1154
1181
|
if (command === 'bulk' && restArgs[0] === 'archive') { const { runBulkArchive } = await import('../src/lifecycle.mjs'); runBulkArchive(restArgs.slice(1), config, { dryRun }); return; }
|
|
1155
1182
|
if (command === 'bulk' && restArgs[0] === 'tag') { const { runBulkTag } = await import('../src/bulk-tag.mjs'); runBulkTag(restArgs.slice(1), config, { dryRun }); return; }
|
package/package.json
CHANGED
package/src/claude-commands.mjs
CHANGED
|
@@ -5,13 +5,13 @@ import { green, dim, yellow } from './color.mjs';
|
|
|
5
5
|
|
|
6
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
8
|
-
const VERSION_MARKER = `<!-- dotmd-generated: ${pkg.version} -->`;
|
|
9
8
|
// Marker is no longer pinned to line 1 — it now lives below the YAML
|
|
10
9
|
// frontmatter that Claude Code surfaces as the slash command's description.
|
|
11
10
|
// The regex is intentionally non-anchored so getInstalledVersion finds it
|
|
12
11
|
// wherever it sits, and the marker string is specific enough that a false
|
|
13
12
|
// positive elsewhere in a user-edited file is not a realistic concern.
|
|
14
13
|
const VERSION_REGEX = /<!-- dotmd-generated: ([\d.]+) -->/;
|
|
14
|
+
function markerFor(version) { return `<!-- dotmd-generated: ${version} -->`; }
|
|
15
15
|
|
|
16
16
|
// Trigger sentences surfaced by Claude Code's available-skills system reminder.
|
|
17
17
|
// Front-load the "when to reach for it" cue so Claude can route to the right
|
|
@@ -55,11 +55,11 @@ function frontmatterFor(name, config) {
|
|
|
55
55
|
return ['---', `description: ${description}`, '---'];
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
function generatePlansCommand(config) {
|
|
59
|
-
const lines = [...frontmatterFor('plans', config),
|
|
58
|
+
function generatePlansCommand(config, version) {
|
|
59
|
+
const lines = [...frontmatterFor('plans', config), markerFor(version), ''];
|
|
60
60
|
lines.push('Run `dotmd context` to get the current plans briefing, then use it to orient yourself.');
|
|
61
61
|
lines.push('');
|
|
62
|
-
lines.push(`Plans are managed by **dotmd** (v${
|
|
62
|
+
lines.push(`Plans are managed by **dotmd** (v${version}). Config at \`dotmd.config.mjs\`. Always use \`dotmd\` directly.`);
|
|
63
63
|
lines.push('');
|
|
64
64
|
lines.push('Plan-specific commands:');
|
|
65
65
|
lines.push('- `dotmd context` — briefing with active/paused/ready plans, age tags, next steps');
|
|
@@ -94,8 +94,8 @@ function generatePlansCommand(config) {
|
|
|
94
94
|
return lines.join('\n');
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
function generateBatonCommand(config) {
|
|
98
|
-
const lines = [...frontmatterFor('baton', config),
|
|
97
|
+
function generateBatonCommand(config, version) {
|
|
98
|
+
const lines = [...frontmatterFor('baton', config), markerFor(version), ''];
|
|
99
99
|
lines.push('Wrap this session. Minimum required (two commands):');
|
|
100
100
|
lines.push('');
|
|
101
101
|
lines.push('1. **Save the resume prompt.** `dotmd new prompt resume-<plan-slug>` with a 10-20 line body via heredoc: the next concrete decision plus any gotchas. NOT a recap of the plan body. The saved prompt IS the handoff — never print it into chat for copy-paste.');
|
|
@@ -114,12 +114,12 @@ function generateBatonCommand(config) {
|
|
|
114
114
|
return lines.join('\n');
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
function generateDocsCommand(config) {
|
|
117
|
+
function generateDocsCommand(config, version) {
|
|
118
118
|
const roots = Array.isArray(config.raw?.root) ? config.raw.root : [config.raw?.root ?? 'docs'];
|
|
119
119
|
const rootCount = roots.length;
|
|
120
120
|
|
|
121
|
-
const lines = [...frontmatterFor('docs', config),
|
|
122
|
-
lines.push(`All documentation in this repo is managed by **dotmd** (v${
|
|
121
|
+
const lines = [...frontmatterFor('docs', config), markerFor(version), ''];
|
|
122
|
+
lines.push(`All documentation in this repo is managed by **dotmd** (v${version}). Docs across ${rootCount} root${rootCount > 1 ? 's' : ''}: ${roots.join(', ')}. Config at \`dotmd.config.mjs\`.`);
|
|
123
123
|
lines.push('');
|
|
124
124
|
|
|
125
125
|
// Document types from config
|
|
@@ -177,7 +177,7 @@ function getInstalledVersion(filePath) {
|
|
|
177
177
|
}
|
|
178
178
|
|
|
179
179
|
export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
180
|
-
const { dryRun = false } = opts;
|
|
180
|
+
const { dryRun = false, version = pkg.version } = opts;
|
|
181
181
|
const claudeDir = path.join(cwd, '.claude');
|
|
182
182
|
if (!existsSync(claudeDir)) return [];
|
|
183
183
|
|
|
@@ -185,16 +185,16 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
|
185
185
|
const results = [];
|
|
186
186
|
|
|
187
187
|
const files = [
|
|
188
|
-
{ name: 'plans.md', generate: () => generatePlansCommand(config) },
|
|
189
|
-
{ name: 'docs.md', generate: () => generateDocsCommand(config) },
|
|
190
|
-
{ name: 'baton.md', generate: () => generateBatonCommand(config) },
|
|
188
|
+
{ name: 'plans.md', generate: () => generatePlansCommand(config, version) },
|
|
189
|
+
{ name: 'docs.md', generate: () => generateDocsCommand(config, version) },
|
|
190
|
+
{ name: 'baton.md', generate: () => generateBatonCommand(config, version) },
|
|
191
191
|
];
|
|
192
192
|
|
|
193
193
|
for (const { name, generate } of files) {
|
|
194
194
|
const filePath = path.join(commandsDir, name);
|
|
195
195
|
const installedVersion = getInstalledVersion(filePath);
|
|
196
196
|
|
|
197
|
-
if (installedVersion ===
|
|
197
|
+
if (installedVersion === version) {
|
|
198
198
|
results.push({ name, action: 'current' });
|
|
199
199
|
} else if (installedVersion) {
|
|
200
200
|
// Outdated — regenerate
|
|
@@ -202,7 +202,7 @@ export function scaffoldClaudeCommands(cwd, config, opts = {}) {
|
|
|
202
202
|
mkdirSync(commandsDir, { recursive: true });
|
|
203
203
|
writeFileSync(filePath, generate(), 'utf8');
|
|
204
204
|
}
|
|
205
|
-
results.push({ name, action: 'updated', from: installedVersion, to:
|
|
205
|
+
results.push({ name, action: 'updated', from: installedVersion, to: version });
|
|
206
206
|
} else if (!existsSync(filePath)) {
|
|
207
207
|
// New — create
|
|
208
208
|
if (!dryRun) {
|
|
@@ -231,7 +231,8 @@ export function refreshStaleSlashCommands(config) {
|
|
|
231
231
|
return results.filter(r => r.action === 'updated');
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
export function checkClaudeCommands(cwd) {
|
|
234
|
+
export function checkClaudeCommands(cwd, opts = {}) {
|
|
235
|
+
const { version = pkg.version } = opts;
|
|
235
236
|
const commandsDir = path.join(cwd, '.claude', 'commands');
|
|
236
237
|
if (!existsSync(commandsDir)) return [];
|
|
237
238
|
|
|
@@ -239,11 +240,11 @@ export function checkClaudeCommands(cwd) {
|
|
|
239
240
|
for (const name of ['plans.md', 'docs.md', 'baton.md']) {
|
|
240
241
|
const filePath = path.join(commandsDir, name);
|
|
241
242
|
const installedVersion = getInstalledVersion(filePath);
|
|
242
|
-
if (installedVersion && installedVersion !==
|
|
243
|
+
if (installedVersion && installedVersion !== version) {
|
|
243
244
|
warnings.push({
|
|
244
245
|
path: `.claude/commands/${name}`,
|
|
245
246
|
level: 'warning',
|
|
246
|
-
message: `Claude command outdated (v${installedVersion} → v${
|
|
247
|
+
message: `Claude command outdated (v${installedVersion} → v${version}). Run \`dotmd doctor\` to update.`,
|
|
247
248
|
});
|
|
248
249
|
}
|
|
249
250
|
}
|
package/src/commands.mjs
CHANGED
package/src/ship.mjs
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { die, warn, toRepoPath } from './util.mjs';
|
|
5
|
+
import { green, dim, yellow } from './color.mjs';
|
|
6
|
+
import { scaffoldClaudeCommands } from './claude-commands.mjs';
|
|
7
|
+
import { readLeases, currentSessionId } from './lease.mjs';
|
|
8
|
+
|
|
9
|
+
// Files dotmd ship will auto-stage when they're dirty. Anything outside this
|
|
10
|
+
// allowlist stays in the working tree — user has to `git add` it explicitly,
|
|
11
|
+
// so secrets / .env / sibling-session WIP never get bundled into a release.
|
|
12
|
+
const ALLOWLIST_PATTERNS = [
|
|
13
|
+
/^src\//,
|
|
14
|
+
/^test\//,
|
|
15
|
+
/^bin\//,
|
|
16
|
+
/^docs\//,
|
|
17
|
+
/^\.claude\/commands\//,
|
|
18
|
+
/^dotmd\.config\.example\.mjs$/,
|
|
19
|
+
/^dotmd\.config\.mjs$/,
|
|
20
|
+
/^package(?:-lock)?\.json$/,
|
|
21
|
+
/^README\.md$/,
|
|
22
|
+
/^CLAUDE\.md$/,
|
|
23
|
+
/^\.gitignore$/,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function bumpVersion(current, bump) {
|
|
27
|
+
const parts = current.split('.').map(Number);
|
|
28
|
+
if (parts.length !== 3 || parts.some(Number.isNaN)) {
|
|
29
|
+
die(`Cannot parse current version: ${current}`);
|
|
30
|
+
}
|
|
31
|
+
const [maj, min, pat] = parts;
|
|
32
|
+
if (bump === 'major') return `${maj + 1}.0.0`;
|
|
33
|
+
if (bump === 'minor') return `${maj}.${min + 1}.0`;
|
|
34
|
+
if (bump === 'patch') return `${maj}.${min}.${pat + 1}`;
|
|
35
|
+
die(`Invalid bump: ${bump}. Use patch | minor | major.`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isAllowed(repoPath) {
|
|
39
|
+
return ALLOWLIST_PATTERNS.some(re => re.test(repoPath));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listDirtyFiles(repoRoot) {
|
|
43
|
+
// -u expands untracked directories into individual file entries; without it,
|
|
44
|
+
// a fresh `docs/` shows up as a single `?? docs/` line and the allowlist
|
|
45
|
+
// check sees no per-file paths to whitelist.
|
|
46
|
+
const result = spawnSync('git', ['status', '--porcelain', '-u'], { cwd: repoRoot, encoding: 'utf8' });
|
|
47
|
+
if (result.status !== 0) die(`git status failed: ${result.stderr}`);
|
|
48
|
+
return result.stdout
|
|
49
|
+
.split('\n')
|
|
50
|
+
.filter(Boolean)
|
|
51
|
+
.map(line => ({
|
|
52
|
+
status: line.slice(0, 2),
|
|
53
|
+
path: line.slice(3),
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function findHeldPlanTitle(config) {
|
|
58
|
+
const leases = readLeases(config);
|
|
59
|
+
const sid = currentSessionId();
|
|
60
|
+
const owned = Object.entries(leases).filter(([_, l]) => l.session === sid);
|
|
61
|
+
if (owned.length !== 1) return null;
|
|
62
|
+
return path.basename(owned[0][0], '.md');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function runShip(argv, config, opts = {}) {
|
|
66
|
+
const { dryRun } = opts;
|
|
67
|
+
const positional = argv.filter(a => !a.startsWith('-'));
|
|
68
|
+
const bump = positional[0] ?? 'patch';
|
|
69
|
+
if (!['patch', 'minor', 'major'].includes(bump)) {
|
|
70
|
+
die(`Invalid bump: ${bump}. Usage: dotmd ship [patch|minor|major]`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const pkgPath = path.join(config.repoRoot, 'package.json');
|
|
74
|
+
if (!existsSync(pkgPath)) die(`No package.json at ${toRepoPath(pkgPath, config.repoRoot)}`);
|
|
75
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
76
|
+
const current = pkg.version;
|
|
77
|
+
const target = bumpVersion(current, bump);
|
|
78
|
+
|
|
79
|
+
process.stdout.write(`${green('→')} Shipping ${current} → ${target} (${bump})\n`);
|
|
80
|
+
|
|
81
|
+
// 1. Regen slash commands at the *target* version so the resulting commit
|
|
82
|
+
// matches the post-bump state and no dirty tree lingers after release.
|
|
83
|
+
const regenResults = scaffoldClaudeCommands(config.repoRoot, config, { version: target, dryRun });
|
|
84
|
+
const refreshed = regenResults.filter(r => r.action === 'updated' || r.action === 'created');
|
|
85
|
+
if (refreshed.length > 0) {
|
|
86
|
+
const verb = dryRun ? 'Would regenerate' : 'Regenerated';
|
|
87
|
+
process.stdout.write(`${green('→')} ${verb} slash commands @ ${target}: ${refreshed.map(r => r.name).join(', ')}\n`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2. Identify dirty tracked files. Anything matching the allowlist gets
|
|
91
|
+
// staged; everything else is left dirty so the user can handle it.
|
|
92
|
+
const dirty = listDirtyFiles(config.repoRoot);
|
|
93
|
+
const untracked = dirty.filter(d => d.status === '??');
|
|
94
|
+
const tracked = dirty.filter(d => d.status !== '??');
|
|
95
|
+
|
|
96
|
+
const toStage = tracked.filter(d => isAllowed(d.path)).map(d => d.path);
|
|
97
|
+
const skipped = tracked.filter(d => !isAllowed(d.path)).map(d => d.path);
|
|
98
|
+
|
|
99
|
+
// Untracked files matching the allowlist (e.g. a fresh new plan) are also
|
|
100
|
+
// safe to add — that's the common case of "scaffolded a plan, now shipping."
|
|
101
|
+
const newAllowed = untracked.filter(d => isAllowed(d.path)).map(d => d.path);
|
|
102
|
+
const newSkipped = untracked.filter(d => !isAllowed(d.path)).map(d => d.path);
|
|
103
|
+
|
|
104
|
+
const allToStage = [...toStage, ...newAllowed];
|
|
105
|
+
const allSkipped = [...skipped, ...newSkipped];
|
|
106
|
+
|
|
107
|
+
if (allSkipped.length > 0) {
|
|
108
|
+
process.stderr.write(`${dim(`Not staging (outside allowlist): ${allSkipped.join(', ')}`)}\n`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (dryRun) {
|
|
112
|
+
process.stdout.write(`${dim('[dry-run]')} Would stage ${allToStage.length} file(s):\n`);
|
|
113
|
+
for (const p of allToStage) process.stdout.write(` ${p}\n`);
|
|
114
|
+
process.stdout.write(`${dim('[dry-run]')} Would commit and run \`npm version ${bump}\`\n`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (allToStage.length > 0) {
|
|
119
|
+
const add = spawnSync('git', ['add', '--', ...allToStage], { cwd: config.repoRoot, encoding: 'utf8' });
|
|
120
|
+
if (add.status !== 0) die(`git add failed: ${add.stderr}`);
|
|
121
|
+
|
|
122
|
+
const planTitle = findHeldPlanTitle(config);
|
|
123
|
+
const subject = planTitle
|
|
124
|
+
? `chore: release ${target} (${planTitle})`
|
|
125
|
+
: `chore: release ${target}`;
|
|
126
|
+
const body = `Auto-staged by \`dotmd ship\`:\n${allToStage.map(p => `- ${p}`).join('\n')}`;
|
|
127
|
+
const commitMsg = `${subject}\n\n${body}`;
|
|
128
|
+
const commit = spawnSync('git', ['commit', '-m', commitMsg], { cwd: config.repoRoot, encoding: 'utf8' });
|
|
129
|
+
if (commit.status !== 0) die(`git commit failed: ${commit.stderr || commit.stdout}`);
|
|
130
|
+
process.stdout.write(`${green('→')} Committed: ${subject}\n`);
|
|
131
|
+
} else {
|
|
132
|
+
process.stdout.write(`${dim('→ Nothing to commit before bump.')}\n`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 3. npm version <bump> — handles package.json bump, tag, push, GH release,
|
|
136
|
+
// npm publish, and local reinstall via the existing pre/postversion
|
|
137
|
+
// scripts. We stream its output so the user sees CI progress live.
|
|
138
|
+
process.stdout.write(`${green('→')} Running \`npm version ${bump}\`…\n`);
|
|
139
|
+
const npmResult = spawnSync('npm', ['version', bump], {
|
|
140
|
+
cwd: config.repoRoot,
|
|
141
|
+
stdio: 'inherit',
|
|
142
|
+
});
|
|
143
|
+
if (npmResult.status !== 0) {
|
|
144
|
+
warn('`npm version` failed. The bump commit + tag may already exist locally. Inspect with `git log -1` and `git tag --sort=-creatordate | head` before retrying.');
|
|
145
|
+
process.exit(npmResult.status ?? 1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
process.stdout.write(`${green('✓')} Shipped ${target}\n`);
|
|
149
|
+
}
|