dotmd-cli 0.76.7 → 0.77.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.
@@ -0,0 +1,123 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ renameSync,
7
+ rmSync,
8
+ rmdirSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { processOwnerLiveness } from './atomic-mutation.mjs';
14
+ import { LEGACY_STATE_DIR, STATE_DIR } from './naming.mjs';
15
+
16
+ export { stateDir } from './naming.mjs';
17
+
18
+ const MIGRATION_MARKER = 'migrated-from-dotmd.json';
19
+ const DROP_ENTRIES = new Set(['handoffs']);
20
+ const TERMINAL_TRANSACTION_STATUSES = new Set(['committed', 'rolled-back', 'failed-manual']);
21
+
22
+ function heldLocks(lockRoot) {
23
+ if (!existsSync(lockRoot)) return [];
24
+ let entries;
25
+ try { entries = readdirSync(lockRoot); }
26
+ catch { return [{ lock: path.basename(lockRoot), status: 'unreadable-directory' }]; }
27
+ const held = [];
28
+ for (const entry of entries) {
29
+ const ownerPath = path.join(lockRoot, entry, 'owner.json');
30
+ if (!existsSync(ownerPath)) {
31
+ held.push({ lock: entry, status: 'owner-missing' });
32
+ continue;
33
+ }
34
+ let owner;
35
+ try { owner = JSON.parse(readFileSync(ownerPath, 'utf8')); }
36
+ catch {
37
+ held.push({ lock: entry, status: 'owner-unreadable' });
38
+ continue;
39
+ }
40
+ if (processOwnerLiveness(owner) !== 'dead') {
41
+ held.push({ lock: entry, status: 'held-or-unverifiable', pid: owner.pid, hostname: owner.hostname });
42
+ }
43
+ }
44
+ return held;
45
+ }
46
+
47
+ function unresolvedTransactions(transactionRoot) {
48
+ if (!existsSync(transactionRoot)) return [];
49
+ let entries;
50
+ try { entries = readdirSync(transactionRoot); }
51
+ catch { return [{ id: path.basename(transactionRoot), status: 'unreadable-directory' }]; }
52
+ const open = [];
53
+ for (const entry of entries) {
54
+ const manifestPath = path.join(transactionRoot, entry, 'manifest.json');
55
+ if (!existsSync(manifestPath)) {
56
+ open.push({ id: entry, status: 'manifest-missing' });
57
+ continue;
58
+ }
59
+ let manifest;
60
+ try { manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); }
61
+ catch { open.push({ id: entry, status: 'unreadable' }); continue; }
62
+ if (!TERMINAL_TRANSACTION_STATUSES.has(manifest.status)) {
63
+ open.push({ id: entry, status: manifest.status ?? 'unknown' });
64
+ }
65
+ }
66
+ return open;
67
+ }
68
+
69
+ function moveEntry(from, to) {
70
+ if (existsSync(to)) {
71
+ if (statSync(to).isDirectory() && statSync(from).isDirectory()) {
72
+ for (const child of readdirSync(from)) moveEntry(path.join(from, child), path.join(to, child));
73
+ rmSync(from, { recursive: true, force: true });
74
+ return;
75
+ }
76
+ // Current state wins an exact-name collision. The legacy copy is stale by
77
+ // definition and retaining both would make the next migration ambiguous.
78
+ rmSync(from, { recursive: true, force: true });
79
+ return;
80
+ }
81
+ renameSync(from, to);
82
+ }
83
+
84
+ export function migrateStateDirectory(repoRoot, options = {}) {
85
+ const root = path.resolve(repoRoot);
86
+ const from = path.join(root, LEGACY_STATE_DIR);
87
+ const to = path.join(root, STATE_DIR);
88
+ if (!existsSync(from)) return { status: 'noop', reason: 'nothing-to-migrate', from, to };
89
+
90
+ const held = heldLocks(path.join(from, 'locks'));
91
+ if (held.length) {
92
+ return {
93
+ status: 'refused', reason: 'locks-held', detail: held, from, to,
94
+ message: `Legacy state has ${held.length} lock${held.length === 1 ? '' : 's'} that ${held.length === 1 ? 'is' : 'are'} not provably abandoned. State migration refused; retry after active work finishes or inspect with \`runlist doctor --transactions\`.`,
95
+ };
96
+ }
97
+
98
+ const open = unresolvedTransactions(path.join(from, 'transactions'));
99
+ if (open.length) {
100
+ return {
101
+ status: 'refused', reason: 'transactions-unresolved', detail: open, from, to,
102
+ message: `${open.length} transaction${open.length === 1 ? ' is' : 's are'} not terminal (${open.map(item => `${item.id}: ${item.status}`).join(', ')}). State migration refused. Resolve with \`runlist doctor --transactions --apply\`, then retry.`,
103
+ };
104
+ }
105
+
106
+ if (options.dryRun) return { status: 'noop', reason: 'dry-run', from, to };
107
+
108
+ mkdirSync(to, { recursive: true });
109
+ for (const entry of readdirSync(from)) {
110
+ if (DROP_ENTRIES.has(entry)) {
111
+ rmSync(path.join(from, entry), { recursive: true, force: true });
112
+ continue;
113
+ }
114
+ moveEntry(path.join(from, entry), path.join(to, entry));
115
+ }
116
+ try { if (readdirSync(from).length === 0) rmdirSync(from); } catch {}
117
+
118
+ writeFileSync(
119
+ path.join(to, MIGRATION_MARKER),
120
+ JSON.stringify({ from: LEGACY_STATE_DIR, to: STATE_DIR, migratedAt: new Date().toISOString() }, null, 2) + '\n',
121
+ );
122
+ return { status: 'migrated', from, to };
123
+ }
package/src/util.mjs CHANGED
@@ -22,6 +22,7 @@ import { dim } from './color.mjs';
22
22
  // tell two of them apart. `dotmd doctor --session` reports that distinction, and
23
23
  // it is the whole reason `dotmd install opencode` exists.
24
24
  const SESSION_ID_SOURCES = [
25
+ { variable: 'RUNLIST_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
25
26
  { variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
26
27
  { variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
27
28
  { variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
package/src/watch.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
5
5
  import { dim } from './color.mjs';
6
6
 
7
7
  export function watchCliPath(moduleUrl = import.meta.url) {
8
- return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'dotmd.mjs');
8
+ return path.resolve(path.dirname(fileURLToPath(moduleUrl)), '..', 'bin', 'runlist.mjs');
9
9
  }
10
10
 
11
11
  export function runWatch(argv, config) {