dotmd-cli 0.68.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 +241 -197
  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 -87
  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 +57 -9
  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 +138 -32
  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/journal.mjs CHANGED
@@ -15,6 +15,107 @@ const ERROR_LOG_BACKUP = 'dotmd-errors.log.1';
15
15
 
16
16
  const MISUSE_LOG_FILE = 'dotmd-misuse.log';
17
17
  const MISUSE_LOG_BACKUP = 'dotmd-misuse.log.1';
18
+ const TELEMETRY_SCHEMA = 2;
19
+ const REDACTED = '[redacted]';
20
+ const SENSITIVE_VALUE_FLAGS = new Set([
21
+ '--body', '--message', '--note',
22
+ '--token', '--password', '--passphrase', '--secret', '--api-key', '--apikey',
23
+ '--auth', '--authorization', '--cookie', '--header',
24
+ ]);
25
+
26
+ function sanitizeCredentialShapes(value) {
27
+ return String(value)
28
+ .replace(/\b(Bearer\s+)[^\s'"`]+/gi, `$1${REDACTED}`)
29
+ .replace(/\b([A-Z0-9_]*(?:TOKEN|PASSWORD|PASSPHRASE|SECRET|API_KEY|APIKEY|AUTH|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)[A-Z0-9_]*)=([^\s]+)/gi, `$1=${REDACTED}`)
30
+ .replace(/([a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@]+@/gi, `$1${REDACTED}@`)
31
+ .replace(/(-----BEGIN [^-]*PRIVATE KEY-----)[\s\S]*?(-----END [^-]*PRIVATE KEY-----)/g, `$1${REDACTED}$2`);
32
+ }
33
+
34
+ function sanitizeArgvWithSecrets(args) {
35
+ const input = Array.isArray(args) ? args.map(value => String(value)) : [];
36
+ const output = [...input];
37
+ const secrets = [];
38
+ const redactAt = (index) => {
39
+ if (index < 0 || index >= output.length) return;
40
+ if (output[index] && output[index] !== '-' && !output[index].startsWith('@')) secrets.push(output[index]);
41
+ output[index] = REDACTED;
42
+ };
43
+
44
+ for (let i = 0; i < output.length; i++) {
45
+ const arg = output[i];
46
+ const sensitiveFlag = SENSITIVE_VALUE_FLAGS.has(arg)
47
+ || /^--[^=]*(?:token|password|passphrase|secret|api[-_]?key|auth(?:orization)?|cookie)$/i.test(arg);
48
+ if (sensitiveFlag) {
49
+ redactAt(i + 1);
50
+ i += 1;
51
+ continue;
52
+ }
53
+ const eq = arg.indexOf('=');
54
+ const flagName = eq > 0 ? arg.slice(0, eq) : '';
55
+ if (eq > 0 && (SENSITIVE_VALUE_FLAGS.has(flagName)
56
+ || /^--.*(?:token|password|passphrase|secret|api[-_]?key|auth(?:orization)?|cookie)$/i.test(flagName))) {
57
+ const value = arg.slice(eq + 1);
58
+ if (value) secrets.push(value);
59
+ output[i] = `${arg.slice(0, eq)}=${REDACTED}`;
60
+ continue;
61
+ }
62
+ output[i] = sanitizeCredentialShapes(arg);
63
+ if (output[i] !== arg) secrets.push(arg);
64
+ }
65
+
66
+ const globalValueFlags = new Set(['--config', '--root', '--type']);
67
+ let commandIndex = -1;
68
+ for (let i = 0; i < input.length; i++) {
69
+ if (globalValueFlags.has(input[i])) { i += 1; continue; }
70
+ if (input[i].startsWith('-')) continue;
71
+ commandIndex = i;
72
+ break;
73
+ }
74
+ const command = input[commandIndex];
75
+ const commandArgs = commandIndex >= 0 ? input.slice(commandIndex + 1) : [];
76
+ const commandOffset = commandIndex + 1;
77
+ const collectPositionals = (valueFlags, dashLeadingAfter = null) => {
78
+ const positions = [];
79
+ for (let i = 0; i < commandArgs.length; i++) {
80
+ const arg = commandArgs[i];
81
+ if (valueFlags.has(arg) || globalValueFlags.has(arg)) { i += 1; continue; }
82
+ if (arg.startsWith('-') && arg !== '-'
83
+ && !(dashLeadingAfter !== null && positions.length >= dashLeadingAfter)) continue;
84
+ positions.push(commandOffset + i);
85
+ }
86
+ return positions;
87
+ };
88
+ if (command === 'new') {
89
+ const positions = collectPositionals(new Set(['--status', '--title', '--runlist', '--body', '--message', '--root']), 2);
90
+ if (positions.length >= 3 && input[positions[0]] === 'prompt') positions.slice(2).forEach(redactAt);
91
+ } else if (command === 'prompts' || command === 'prompt') {
92
+ const positions = collectPositionals(new Set(['--status', '--body', '--message', '--title']), 2);
93
+ if (positions.length >= 3 && input[positions[0]] === 'new') positions.slice(2).forEach(redactAt);
94
+ } else if (command === 'baton') {
95
+ const positions = collectPositionals(new Set(['--status', '--note', '--body', '--message']));
96
+ if (positions.length >= 2) positions.slice(1).forEach(redactAt);
97
+ }
98
+
99
+ const argv = commandIndex > 0
100
+ ? [output[commandIndex], ...output.slice(commandIndex + 1), ...output.slice(0, commandIndex)]
101
+ : output;
102
+ return { argv, secrets };
103
+ }
104
+
105
+ export function sanitizeTelemetryArgv(args) {
106
+ return sanitizeArgvWithSecrets(args).argv;
107
+ }
108
+
109
+ export function sanitizeTelemetryText(value, secrets = []) {
110
+ let text = String(value ?? '');
111
+ for (const secret of [...new Set(secrets.filter(Boolean))].sort((a, b) => b.length - a.length)) {
112
+ text = text.split(secret).join(REDACTED);
113
+ }
114
+ return sanitizeCredentialShapes(text)
115
+ .replace(/((?:--body|--message|--note|--token|--password|--passphrase|--secret|--api-key|--apikey|--auth|--authorization|--cookie|--header)(?:=|\s+))([^\s]+)/gi, `$1${REDACTED}`)
116
+ .replace(/(git\s+commit\b[^\n]*?(?:\s-m|\s--message)(?:=|\s+))((?:"[^"]*")|(?:'[^']*')|[^\s]+)/gi, `$1${REDACTED}`)
117
+ .replace(/((?:sed|perl|g?awk)\b[^\n]*?\s(?:-e\s+)?)((?:"[^"]*")|(?:'[^']*'))/gi, `$1${REDACTED}`);
118
+ }
18
119
 
19
120
  export function isJournalEnabled(config) {
20
121
  if (process.env.DOTMD_JOURNAL === '1') return true;
@@ -30,14 +131,14 @@ export function journalBackupPath(config) {
30
131
  return path.join(config.repoRoot, JOURNAL_DIR, JOURNAL_BACKUP);
31
132
  }
32
133
 
33
- function firstEntryVersion(file) {
134
+ function firstEntry(file) {
34
135
  try {
35
136
  const sample = readFileSync(file, 'utf8');
36
137
  const nl = sample.indexOf('\n');
37
138
  const first = nl >= 0 ? sample.slice(0, nl) : sample;
38
139
  if (!first.trim()) return null;
39
140
  const obj = JSON.parse(first);
40
- return obj?.v == null ? null : String(obj.v);
141
+ return obj;
41
142
  } catch {
42
143
  return null;
43
144
  }
@@ -45,11 +146,20 @@ function firstEntryVersion(file) {
45
146
 
46
147
  function maybeRotate(file, backup, nextEntry = null) {
47
148
  pruneStaleBackup(backup);
149
+ if (nextEntry?.schema != null) {
150
+ for (const candidate of [file, backup]) {
151
+ if (!existsSync(candidate)) continue;
152
+ const existingSchema = firstEntry(candidate)?.schema;
153
+ if (existingSchema !== nextEntry.schema) {
154
+ try { unlinkSync(candidate); } catch {}
155
+ }
156
+ }
157
+ }
48
158
  if (!existsSync(file)) return;
49
159
  let st;
50
160
  try { st = statSync(file); } catch { return; }
51
161
  if (nextEntry?.v) {
52
- const existingVersion = firstEntryVersion(file);
162
+ const existingVersion = firstEntry(file)?.v;
53
163
  if (existingVersion !== String(nextEntry.v)) {
54
164
  try { renameSync(file, backup); } catch {}
55
165
  return;
@@ -89,21 +199,38 @@ export function appendJournalEntry(config, entry) {
89
199
  if (!isJournalEnabled(config)) return;
90
200
  if (!config?.repoRoot) return;
91
201
  try {
202
+ const sanitized = sanitizeArgvWithSecrets(entry?.argv);
203
+ const safeEntry = {
204
+ ...entry,
205
+ schema: TELEMETRY_SCHEMA,
206
+ ...(Array.isArray(entry?.argv) ? { argv: sanitized.argv } : {}),
207
+ ...(entry?.err ? { err: sanitizeTelemetryText(entry.err, sanitized.secrets) } : {}),
208
+ };
92
209
  const dir = path.join(config.repoRoot, JOURNAL_DIR);
93
210
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
94
211
  const file = journalFilePath(config);
95
- maybeRotate(file, journalBackupPath(config), entry);
212
+ maybeRotate(file, journalBackupPath(config), safeEntry);
96
213
  // O_APPEND is atomic for writes under PIPE_BUF (4KB on Linux, 512B on
97
214
  // macOS). Entries are well under either threshold, so concurrent CLI
98
215
  // invocations interleave cleanly without locking.
99
- appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
216
+ appendFileSync(file, JSON.stringify(safeEntry) + '\n', { flag: 'a' });
100
217
  } catch {
101
218
  // Journal write must never break a command.
102
219
  }
103
220
  }
104
221
 
222
+ function purgeLegacyTelemetry(file, backup) {
223
+ for (const candidate of [file, backup]) {
224
+ if (!existsSync(candidate)) continue;
225
+ if (firstEntry(candidate)?.schema !== TELEMETRY_SCHEMA) {
226
+ try { unlinkSync(candidate); } catch {}
227
+ }
228
+ }
229
+ }
230
+
105
231
  export function readJournalEntries(config) {
106
232
  const file = journalFilePath(config);
233
+ purgeLegacyTelemetry(file, journalBackupPath(config));
107
234
  if (!existsSync(file)) return [];
108
235
  let raw;
109
236
  try { raw = readFileSync(file, 'utf8'); } catch { return []; }
@@ -117,11 +244,13 @@ export function readJournalEntries(config) {
117
244
 
118
245
  export function recordCliInvocation({ config, startMs, args, err, version }) {
119
246
  if (!config) return;
247
+ const sanitized = sanitizeArgvWithSecrets(args);
120
248
  const entry = {
249
+ schema: TELEMETRY_SCHEMA,
121
250
  ts: new Date().toISOString(),
122
251
  sid: currentSessionId(),
123
252
  pid: process.pid,
124
- argv: args,
253
+ argv: sanitized.argv,
125
254
  exit: process.exitCode ?? 0,
126
255
  ms: Date.now() - startMs,
127
256
  v: version,
@@ -130,7 +259,7 @@ export function recordCliInvocation({ config, startMs, args, err, version }) {
130
259
  // Normalize whitespace so multi-line error messages (e.g. unknown-command
131
260
  // hints) render as a single line in `dotmd journal --tail`. Cap at 200
132
261
  // chars so a stray stack trace can't bloat the journal.
133
- const flat = String(err.message ?? err).replace(/\s+/g, ' ').trim();
262
+ const flat = sanitizeTelemetryText(err.message ?? err, sanitized.secrets).replace(/\s+/g, ' ').trim();
134
263
  entry.err = flat.length > 200 ? flat.slice(0, 197) + '...' : flat;
135
264
  }
136
265
  appendJournalEntry(config, entry);
@@ -155,13 +284,15 @@ export function globalErrorLogBackupPath() {
155
284
 
156
285
  export function recordGlobalError({ config, startMs, args, err, version }) {
157
286
  if (!err) return;
158
- const flatMsg = String(err.message ?? err).replace(/\s+/g, ' ').trim();
287
+ const sanitized = sanitizeArgvWithSecrets(args);
288
+ const flatMsg = sanitizeTelemetryText(err.message ?? err, sanitized.secrets).replace(/\s+/g, ' ').trim();
159
289
  const entry = {
290
+ schema: TELEMETRY_SCHEMA,
160
291
  ts: new Date().toISOString(),
161
292
  repo: config?.repoRoot || process.cwd(),
162
293
  sid: currentSessionId(),
163
294
  pid: process.pid,
164
- argv: args,
295
+ argv: sanitized.argv,
165
296
  exit: process.exitCode ?? 1,
166
297
  ms: typeof startMs === 'number' ? Date.now() - startMs : null,
167
298
  v: version,
@@ -171,7 +302,7 @@ export function recordGlobalError({ config, startMs, args, err, version }) {
171
302
  if (err && err.stack) {
172
303
  // Keep the first few frames; stacks for DotmdError are short anyway and
173
304
  // for unexpected exceptions five frames is usually enough to localize.
174
- const stack = String(err.stack).split('\n').slice(0, 6).join('\n');
305
+ const stack = sanitizeTelemetryText(err.stack, sanitized.secrets).split('\n').slice(0, 6).join('\n');
175
306
  entry.stack = stack.length > 1000 ? stack.slice(0, 997) + '...' : stack;
176
307
  }
177
308
  try {
@@ -203,6 +334,7 @@ export function globalMisuseLogBackupPath() {
203
334
  export function recordGuardEvent(event) {
204
335
  if (!event) return;
205
336
  const entry = {
337
+ schema: TELEMETRY_SCHEMA,
206
338
  ts: new Date().toISOString(),
207
339
  repo: event.repo || process.cwd(),
208
340
  sid: currentSessionId(),
@@ -211,7 +343,7 @@ export function recordGuardEvent(event) {
211
343
  rule: event.rule ?? null,
212
344
  decision: event.decision ?? null,
213
345
  detail: typeof event.detail === 'string'
214
- ? (event.detail.length > 300 ? event.detail.slice(0, 297) + '...' : event.detail)
346
+ ? (sanitizeTelemetryText(event.detail).length > 300 ? sanitizeTelemetryText(event.detail).slice(0, 297) + '...' : sanitizeTelemetryText(event.detail))
215
347
  : null,
216
348
  v: event.version ?? null,
217
349
  };
@@ -219,7 +351,7 @@ export function recordGuardEvent(event) {
219
351
  const dir = globalErrorLogDir();
220
352
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
221
353
  const file = globalMisuseLogPath();
222
- maybeRotate(file, globalMisuseLogBackupPath());
354
+ maybeRotate(file, globalMisuseLogBackupPath(), entry);
223
355
  appendFileSync(file, JSON.stringify(entry) + '\n', { flag: 'a' });
224
356
  } catch {
225
357
  // Logging must never break the hook.
@@ -228,6 +360,7 @@ export function recordGuardEvent(event) {
228
360
 
229
361
  export function readMisuseEntries() {
230
362
  const file = globalMisuseLogPath();
363
+ purgeLegacyTelemetry(file, globalMisuseLogBackupPath());
231
364
  if (!existsSync(file)) return [];
232
365
  let raw;
233
366
  try { raw = readFileSync(file, 'utf8'); } catch { return []; }