dotmd-cli 0.69.0 → 0.70.1
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/README.md +144 -964
- package/bin/dotmd.mjs +251 -202
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/check-collapse.mjs +2 -2
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +25 -15
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +9 -7
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +722 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +21 -13
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +596 -294
- package/src/lint.mjs +117 -56
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +24 -11
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +53 -17
- package/src/watch.mjs +6 -1
- 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
|
|
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
|
|
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 =
|
|
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),
|
|
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(
|
|
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:
|
|
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 =
|
|
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
|
|
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:
|
|
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 =
|
|
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 []; }
|