@natjswenson/devlog 0.1.6 → 0.1.8

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/devlog.js CHANGED
@@ -1,14 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn, spawnSync, execSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
4
- import { homedir } from 'node:os';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync, renameSync, unlinkSync } from 'node:fs';
4
+ import { homedir, tmpdir } from 'node:os';
5
5
  import { dirname, join, resolve, basename } from 'node:path';
6
- import { fileURLToPath, pathToFileURL } from 'node:url';
6
+ import { fileURLToPath } from 'node:url';
7
7
  import { createRequire } from 'node:module';
8
8
  import prompts from 'prompts';
9
9
  import kleur from 'kleur';
10
10
 
11
- const SHELL_METACHARS = /[;&|`$()<>{}*?!#~"\\\n\r]/;
11
+ // ─── shared validators (single source of truth, also used by SKILL.md guidance) ───
12
+ //
13
+ // SHELL_QUOTE_BREAK matches characters that can break out of a single-quoted
14
+ // shell string OR are dangerous if quoting is omitted. The skill instructs the
15
+ // LLM to single-quote every interpolated value; rejecting these chars upstream
16
+ // guarantees that single-quoting is sufficient. Whitespace, dots, hyphens,
17
+ // equals, and similar are NOT rejected — they're literal inside '...' and are
18
+ // legitimate in human-readable fields like names and paths.
19
+ //
20
+ // For strict-token fields (project keys, repo names, branch names), separate
21
+ // allowlist regexes apply additional structural constraints.
22
+ const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
23
+ const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
24
+ const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
25
+ const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
26
+ const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
27
+ const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
28
+ const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
12
29
 
13
30
  const require = createRequire(import.meta.url);
14
31
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -24,6 +41,7 @@ const log = {
24
41
  warn: (msg) => console.log(kleur.yellow('! ') + msg),
25
42
  err: (msg) => console.error(kleur.red('✗ ') + msg),
26
43
  step: (msg) => console.log(kleur.cyan('→ ') + msg),
44
+ hint: (msg) => console.log(kleur.dim(' ' + msg)),
27
45
  };
28
46
 
29
47
  function readPackageVersion() {
@@ -31,8 +49,7 @@ function readPackageVersion() {
31
49
  return pkg.version;
32
50
  }
33
51
 
34
- // Runs a hardcoded shell command (no user input). Use tryExecArgs for any
35
- // command whose arguments include user-supplied values.
52
+ // Hardcoded shell command, no user input. Use tryExecArgs for anything user-supplied.
36
53
  function tryExec(cmd) {
37
54
  try {
38
55
  return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
@@ -59,31 +76,102 @@ function expandHome(p) {
59
76
  return p;
60
77
  }
61
78
 
79
+ // Atomic write: write to sibling tmp file then rename.
80
+ // Prevents readers from seeing a half-written config if process is killed mid-write.
81
+ // Uses `wx` (exclusive create) flag to prevent symlink-attack on shared filesystems
82
+ // — if an attacker pre-creates the tmp file, our write fails rather than following
83
+ // the symlink to a sensitive target.
84
+ function atomicWriteJSON(path, data) {
85
+ const tmp = path + '.tmp.' + process.pid + '.' + Date.now();
86
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
87
+ try {
88
+ renameSync(tmp, path);
89
+ } catch (e) {
90
+ try { unlinkSync(tmp); } catch {}
91
+ throw e;
92
+ }
93
+ }
94
+
95
+ // Validate a config object before writing. Throws with a user-facing message on failure.
96
+ function validateConfig(config) {
97
+ if (!config || typeof config !== 'object') throw new Error('Config must be an object');
98
+ const required = ['targetRepo', 'gitAuthor', 'githubUser', 'projects'];
99
+ for (const k of required) {
100
+ if (!(k in config)) throw new Error(`Missing required field: ${k}`);
101
+ }
102
+ if (!RE_OWNER_REPO.test(config.targetRepo)) {
103
+ throw new Error(`targetRepo must match <owner>/<repo>: got ${JSON.stringify(config.targetRepo)}`);
104
+ }
105
+ if (typeof config.gitAuthor !== 'string' || config.gitAuthor.length === 0 || SHELL_QUOTE_BREAK.test(config.gitAuthor)) {
106
+ throw new Error(`gitAuthor must be non-empty and contain no shell metacharacters: got ${JSON.stringify(config.gitAuthor)}`);
107
+ }
108
+ if (!RE_GH_USER.test(config.githubUser)) {
109
+ throw new Error(`githubUser must match GitHub username pattern: got ${JSON.stringify(config.githubUser)}`);
110
+ }
111
+ if ('branch' in config) {
112
+ if (!RE_BRANCH.test(config.branch) || FORBIDDEN_BRANCH_PARTS.test(config.branch)) {
113
+ throw new Error(`branch must be a valid git branch name (no leading dash, no '..'): got ${JSON.stringify(config.branch)}`);
114
+ }
115
+ }
116
+ if (!Array.isArray(config.projects)) {
117
+ throw new Error('projects must be an array');
118
+ }
119
+ const seenKeys = new Set();
120
+ for (const p of config.projects) {
121
+ if (!p || typeof p !== 'object') throw new Error('Each project must be an object');
122
+ if (!RE_PROJECT_KEY.test(p.key) || p.key.includes('..')) {
123
+ throw new Error(`project.key invalid: ${JSON.stringify(p.key)}`);
124
+ }
125
+ if (seenKeys.has(p.key)) throw new Error(`Duplicate project key: ${JSON.stringify(p.key)}`);
126
+ seenKeys.add(p.key);
127
+ if (typeof p.path !== 'string' || SHELL_QUOTE_BREAK.test(p.path)) {
128
+ throw new Error(`project.path invalid (must contain no shell metacharacters): ${JSON.stringify(p.path)}`);
129
+ }
130
+ if (!RE_OWNER_REPO.test(p.remote)) {
131
+ throw new Error(`project.remote must match <owner>/<repo>: ${JSON.stringify(p.remote)}`);
132
+ }
133
+ if ('label' in p) {
134
+ // Label is rendered as React text content only — never shell-interpolated,
135
+ // never used in URLs, never used as a filesystem path. React escapes all
136
+ // text content. Therefore: any string is safe. Apostrophes (e.g.
137
+ // "Mom I'm Bored") and unicode are legitimate label content.
138
+ // INVARIANT: if a future change makes label flow into shell or innerHTML,
139
+ // tighten this validation to SHELL_QUOTE_BREAK at the same time.
140
+ if (typeof p.label !== 'string') throw new Error(`project.label must be a string if present`);
141
+ if (p.label.length > 200) throw new Error(`project.label too long (max 200 chars)`);
142
+ if (/[\x00-\x1f]/.test(p.label)) throw new Error(`project.label contains control characters`);
143
+ }
144
+ }
145
+ return config;
146
+ }
147
+
148
+ function readConfig() {
149
+ if (!existsSync(CONFIG_PATH)) return null;
150
+ const raw = readFileSync(CONFIG_PATH, 'utf8');
151
+ return JSON.parse(raw);
152
+ }
153
+
62
154
  async function preflight() {
63
155
  const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
64
156
  if (nodeMajor < 18) {
65
157
  log.err(`Node 18+ required (you have ${process.versions.node}).`);
158
+ log.hint('Update Node: https://nodejs.org/');
66
159
  process.exit(1);
67
160
  }
68
-
69
- const ghVersion = tryExec('gh --version');
70
- if (!ghVersion) {
161
+ if (!tryExec('gh --version')) {
71
162
  log.err('GitHub CLI (`gh`) is not installed.');
72
- log.info('Install: https://cli.github.com/');
163
+ log.hint('Install: https://cli.github.com/ then run `gh auth login`');
73
164
  process.exit(1);
74
165
  }
75
-
76
- const ghAuth = tryExec('gh auth status');
77
- if (!ghAuth) {
166
+ if (!tryExec('gh auth status')) {
78
167
  log.err('GitHub CLI is not authenticated.');
79
- log.info('Run: gh auth login');
168
+ log.hint('Run: gh auth login');
80
169
  process.exit(1);
81
170
  }
82
171
  }
83
172
 
84
173
  function detectGhUser() {
85
- const out = tryExec('gh api user --jq .login');
86
- return out || null;
174
+ return tryExec('gh api user --jq .login') || null;
87
175
  }
88
176
 
89
177
  function detectGitName() {
@@ -104,128 +192,162 @@ async function confirmOverwrite(label, path) {
104
192
  name: 'ok',
105
193
  message: `${label} already exists at ${path}. Overwrite?`,
106
194
  initial: false,
107
- });
195
+ }, { onCancel: () => process.exit(1) });
108
196
  return ok === true;
109
197
  }
110
198
 
111
- async function cmdInit() {
112
- log.info(kleur.bold('\ndevlog setup\n'));
113
- await preflight();
199
+ // ─── prompt validators (reused across init and add-project) ──────────────────
200
+ const VALIDATORS = {
201
+ gitAuthor: (v) => {
202
+ if (v.trim().length === 0) return 'Required';
203
+ if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
204
+ return true;
205
+ },
206
+ githubUser: (v) => RE_GH_USER.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
207
+ targetRepoName: (v) => RE_REPO_NAME.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
208
+ path: (v) => {
209
+ if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
210
+ if (v.trim().startsWith('-')) return 'Path cannot start with a dash';
211
+ return existsSync(expandHome(v)) || 'Path does not exist';
212
+ },
213
+ projectKey: (v) => {
214
+ const t = v.trim();
215
+ if (!RE_PROJECT_KEY.test(t)) return 'Invalid key (must start with letter/digit, alphanumeric + ._- only)';
216
+ if (t.includes('..')) return 'Invalid key (no `..`)';
217
+ return true;
218
+ },
219
+ ownerRepo: (v) => RE_OWNER_REPO.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash, alphanumeric + ._- only',
220
+ label: (v) => {
221
+ // Label is React text content only — apostrophes and most punctuation are fine.
222
+ // Reject only control chars and overlong values.
223
+ if (typeof v !== 'string') return true; // optional field
224
+ if (v.length > 200) return 'Label too long (max 200 chars)';
225
+ if (/[\x00-\x1f]/.test(v)) return 'Label contains control characters';
226
+ return true;
227
+ },
228
+ };
114
229
 
115
- const defaults = {
116
- gitAuthor: detectGitName() || '',
117
- githubUser: detectGhUser() || '',
118
- targetRepoName: 'daily-dev-log',
119
- };
230
+ // Prompt for a single project's fields. Returns { key, path, remote, label } or null on cancel.
231
+ async function promptForProject(defaults = {}) {
232
+ const initialPath = defaults.path || process.cwd();
233
+ const initialKey = defaults.key || basename(expandHome(initialPath));
234
+ const initialRemote = defaults.remote || detectProjectRemote(expandHome(initialPath)) || '';
120
235
 
121
236
  const answers = await prompts([
122
237
  {
123
238
  type: 'text',
124
- name: 'gitAuthor',
125
- message: 'Your name (used to filter `git log --author`):',
126
- initial: defaults.gitAuthor,
127
- validate: (v) => {
128
- if (v.trim().length === 0) return 'Required';
129
- if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
130
- return true;
131
- },
239
+ name: 'path',
240
+ message: 'Project absolute path:',
241
+ initial: initialPath,
242
+ validate: VALIDATORS.path,
132
243
  },
133
244
  {
134
245
  type: 'text',
135
- name: 'githubUser',
136
- message: 'Your GitHub username:',
137
- initial: defaults.githubUser,
138
- validate: (v) => /^[a-z0-9][a-z0-9-]*$/i.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
246
+ name: 'key',
247
+ message: 'Project key (used as dev-log subdir name):',
248
+ initial: (_p, values) => basename(expandHome(values.path || initialKey)),
249
+ validate: VALIDATORS.projectKey,
139
250
  },
140
251
  {
141
252
  type: 'text',
142
- name: 'targetRepoName',
143
- message: 'Name of the repo where dev logs will be published:',
144
- initial: defaults.targetRepoName,
145
- validate: (v) => /^[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
253
+ name: 'label',
254
+ message: 'Project display label (optional, defaults to key):',
255
+ initial: '',
256
+ validate: VALIDATORS.label,
146
257
  },
147
258
  {
148
- type: 'confirm',
149
- name: 'registerProject',
150
- message: 'Register a project now? (you can add more later by editing config.json)',
151
- initial: true,
259
+ type: 'text',
260
+ name: 'remote',
261
+ message: 'Project GitHub remote (<owner>/<repo>):',
262
+ initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
263
+ validate: VALIDATORS.ownerRepo,
152
264
  },
153
265
  ], { onCancel: () => process.exit(1) });
154
266
 
155
- let projectAnswers = null;
156
- if (answers.registerProject) {
157
- const cwd = process.cwd();
158
- const cwdRemote = detectProjectRemote(cwd);
159
- projectAnswers = await prompts([
160
- {
161
- type: 'text',
162
- name: 'path',
163
- message: 'Project absolute path:',
164
- initial: cwd,
165
- validate: (v) => {
166
- if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
167
- return existsSync(expandHome(v)) || 'Path does not exist';
168
- },
169
- },
170
- {
171
- type: 'text',
172
- name: 'key',
173
- message: 'Project key (used as dev-log subdir name):',
174
- initial: (prev) => basename(expandHome(prev || cwd)),
175
- validate: (v) => {
176
- const t = v.trim();
177
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(t)) return 'Invalid key (must start with letter/digit, alphanumeric + ._- only)';
178
- if (t.includes('..')) return 'Invalid key (no `..`)';
179
- return true;
180
- },
181
- },
182
- {
183
- type: 'text',
184
- name: 'remote',
185
- message: 'Project GitHub remote (<owner>/<repo>):',
186
- initial: (_prev, values) => detectProjectRemote(expandHome(values.path)) || cwdRemote || `${answers.githubUser}/${basename(expandHome(values.path))}`,
187
- validate: (v) => /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash',
188
- },
189
- ], { onCancel: () => process.exit(1) });
267
+ const out = {
268
+ key: answers.key.trim(),
269
+ path: expandHome(answers.path),
270
+ remote: answers.remote.trim(),
271
+ };
272
+ if (answers.label && answers.label.trim()) out.label = answers.label.trim();
273
+ return out;
274
+ }
275
+
276
+ // ─── init ────────────────────────────────────────────────────────────────────
277
+ async function cmdInit() {
278
+ log.info(kleur.bold('\ndevlog setup\n'));
279
+ await preflight();
280
+
281
+ const defaults = {
282
+ gitAuthor: detectGitName() || '',
283
+ githubUser: detectGhUser() || '',
284
+ targetRepoName: 'daily-dev-log',
285
+ };
286
+
287
+ const answers = await prompts([
288
+ { type: 'text', name: 'gitAuthor', message: 'Your name (used to filter `git log --author`):', initial: defaults.gitAuthor, validate: VALIDATORS.gitAuthor },
289
+ { type: 'text', name: 'githubUser', message: 'Your GitHub username:', initial: defaults.githubUser, validate: VALIDATORS.githubUser },
290
+ { type: 'text', name: 'targetRepoName', message: 'Name of the repo where dev logs will be published:', initial: defaults.targetRepoName, validate: VALIDATORS.targetRepoName },
291
+ ], { onCancel: () => process.exit(1) });
292
+
293
+ // Optionally register projects in a loop. First time defaults to "yes".
294
+ const projects = [];
295
+ let registerAnother = true;
296
+ let firstPrompt = true;
297
+ while (registerAnother) {
298
+ const { add } = await prompts({
299
+ type: 'confirm',
300
+ name: 'add',
301
+ message: firstPrompt ? 'Register a project now?' : 'Register another project?',
302
+ initial: firstPrompt,
303
+ }, { onCancel: () => process.exit(1) });
304
+ firstPrompt = false;
305
+ if (!add) break;
306
+ const p = await promptForProject();
307
+ if (projects.find((x) => x.key === p.key)) {
308
+ log.warn(`Skipped (duplicate key): ${p.key}`);
309
+ continue;
310
+ }
311
+ projects.push(p);
312
+ log.ok(`Registered: ${p.key}`);
190
313
  }
191
314
 
192
315
  const targetRepo = `${answers.githubUser}/${answers.targetRepoName}`;
193
- const config = {
316
+ const config = validateConfig({
194
317
  targetRepo,
318
+ branch: 'main',
195
319
  gitAuthor: answers.gitAuthor,
196
320
  githubUser: answers.githubUser,
197
- projects: projectAnswers ? [{
198
- key: projectAnswers.key,
199
- path: expandHome(projectAnswers.path),
200
- remote: projectAnswers.remote,
201
- }] : [],
202
- };
321
+ projects,
322
+ });
323
+
324
+ // Sanity check: warn if the gh-authenticated user differs from githubUser.
325
+ // Common mistake on machines with multiple gh logins.
326
+ const ghUser = detectGhUser();
327
+ if (ghUser && ghUser !== config.githubUser) {
328
+ log.warn(`gh is authenticated as "${ghUser}" but config.githubUser is "${config.githubUser}".`);
329
+ log.hint('Run `gh auth login` to switch accounts, or update config.githubUser.');
330
+ }
203
331
 
204
332
  log.info('\n' + kleur.bold('Summary:'));
205
333
  log.info(` Target repo: ${kleur.cyan(`github.com/${targetRepo}`)}`);
206
334
  log.info(` Git author: ${config.gitAuthor}`);
207
335
  log.info(` GitHub user: ${config.githubUser}`);
208
- log.info(` Projects: ${config.projects.length === 0 ? '(none — add later)' : config.projects.map(p => p.key).join(', ')}`);
336
+ log.info(` Branch: ${config.branch}`);
337
+ log.info(` Projects: ${config.projects.length === 0 ? '(none — add later with `devlog add-project`)' : config.projects.map((p) => p.key).join(', ')}`);
209
338
  log.info(` Skill location: ${CONFIG_DIR}`);
210
339
 
211
- const { proceed } = await prompts({
212
- type: 'confirm',
213
- name: 'proceed',
214
- message: 'Continue?',
215
- initial: true,
216
- }, { onCancel: () => process.exit(1) });
340
+ const { proceed } = await prompts({ type: 'confirm', name: 'proceed', message: 'Continue?', initial: true }, { onCancel: () => process.exit(1) });
217
341
  if (!proceed) process.exit(0);
218
-
219
342
  log.info('');
220
343
 
344
+ // Repo create — argv form, no shell.
221
345
  const repoExists = tryExecArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
222
346
  if (repoExists) {
223
347
  log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
224
348
  } else {
225
349
  log.step(`Creating github.com/${targetRepo}...`);
226
- const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], {
227
- stdio: 'inherit',
228
- });
350
+ const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], { stdio: 'inherit' });
229
351
  if (r.status !== 0) {
230
352
  log.err('Failed to create repo. Check `gh` permissions.');
231
353
  process.exit(1);
@@ -234,7 +356,7 @@ async function cmdInit() {
234
356
  }
235
357
 
236
358
  if (!existsSync(CONFIG_DIR)) {
237
- mkdirSync(CONFIG_DIR, { recursive: true });
359
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
238
360
  log.ok(`Created ${CONFIG_DIR}`);
239
361
  }
240
362
 
@@ -246,44 +368,131 @@ async function cmdInit() {
246
368
  }
247
369
 
248
370
  if (await confirmOverwrite('config.json', CONFIG_PATH)) {
249
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
371
+ atomicWriteJSON(CONFIG_PATH, config);
250
372
  log.ok(`Wrote config → ${CONFIG_PATH}`);
251
373
  } else {
252
374
  log.warn('Skipped config.json');
253
375
  }
254
376
 
255
- log.info('\n' + kleur.bold().green('Done.') + '\n');
377
+ log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
256
378
  log.info('Next steps:');
257
- log.info(` 1. ${config.projects.length === 0 ? 'Edit config.json to register projects' : '(Optional) edit config.json to register more projects'}`);
258
- log.info(' 2. Make some commits in a registered project');
259
- log.info(' 3. In Claude Code, run: /devlog');
260
- log.info(' 4. Preview locally: npx @natjswenson/devlog preview');
379
+ if (config.projects.length === 0) {
380
+ log.info(` 1. Add a project: ${kleur.cyan('npx @natjswenson/devlog add-project')}`);
381
+ log.info(' 2. Make some commits in the project');
382
+ } else {
383
+ log.info(' 1. Make some commits in a registered project');
384
+ }
385
+ log.info(` 2. In Claude Code, run: ${kleur.cyan('/devlog')}`);
386
+ log.info(` 3. Preview locally: ${kleur.cyan('npx @natjswenson/devlog preview')}`);
261
387
  log.info('');
262
388
  }
263
389
 
264
- async function cmdPreview() {
390
+ // ─── add-project ─────────────────────────────────────────────────────────────
391
+ async function cmdAddProject() {
392
+ log.info(kleur.bold('\ndevlog add-project\n'));
393
+ if (!existsSync(CONFIG_PATH)) {
394
+ log.err(`No config found at ${CONFIG_PATH}`);
395
+ log.hint('Run `npx @natjswenson/devlog init` first.');
396
+ process.exit(1);
397
+ }
398
+
399
+ let config;
400
+ try {
401
+ config = readConfig();
402
+ validateConfig(config);
403
+ } catch (e) {
404
+ log.err(`Existing config is invalid: ${e.message}`);
405
+ log.hint(`Edit ${CONFIG_PATH} or run \`devlog init\` to recreate.`);
406
+ process.exit(1);
407
+ }
408
+
409
+ if (config.projects.length > 0) {
410
+ log.info(kleur.dim('Currently registered projects:'));
411
+ for (const p of config.projects) log.info(kleur.dim(` - ${p.key} (${p.path})`));
412
+ log.info('');
413
+ }
414
+
415
+ const newProject = await promptForProject();
416
+ if (config.projects.find((p) => p.key === newProject.key)) {
417
+ log.err(`Project key "${newProject.key}" is already registered.`);
418
+ log.hint('Pick a different key, or remove the existing entry first.');
419
+ process.exit(1);
420
+ }
421
+
422
+ const newConfig = validateConfig({ ...config, projects: [...config.projects, newProject] });
423
+ atomicWriteJSON(CONFIG_PATH, newConfig);
424
+ log.ok(`Added "${newProject.key}" to config.`);
425
+ log.info('');
426
+ log.info(`Run ${kleur.cyan('/devlog ' + newProject.key)} in Claude Code to publish an entry for this project.`);
427
+ log.info('');
428
+ }
429
+
430
+ // ─── config (view) ───────────────────────────────────────────────────────────
431
+ async function cmdConfig() {
265
432
  if (!existsSync(CONFIG_PATH)) {
266
433
  log.err(`No config found at ${CONFIG_PATH}`);
267
- log.info('Run `npx @natjswenson/devlog init` first.');
434
+ log.hint('Run `npx @natjswenson/devlog init` first.');
268
435
  process.exit(1);
269
436
  }
270
437
 
271
438
  let config;
272
439
  try {
273
- config = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
440
+ config = readConfig();
274
441
  } catch (e) {
275
- log.err(`Failed to parse config: ${e.message}`);
442
+ log.err(`Failed to read config: ${e.message}`);
276
443
  process.exit(1);
277
444
  }
278
445
 
279
- const [owner, repo] = (config.targetRepo || '').split('/');
280
- if (!owner || !repo) {
281
- log.err('config.targetRepo is not in <owner>/<repo> format');
446
+ let validationStatus;
447
+ try {
448
+ validateConfig(config);
449
+ validationStatus = kleur.green('valid');
450
+ } catch (e) {
451
+ validationStatus = kleur.red('INVALID — ' + e.message);
452
+ }
453
+
454
+ log.info('');
455
+ log.info(kleur.bold(`Config: ${CONFIG_PATH}`));
456
+ log.info(`Status: ${validationStatus}`);
457
+ log.info(`Target repo: ${kleur.cyan(`github.com/${config.targetRepo || '?'}`)}`);
458
+ log.info(`Branch: ${config.branch || 'main'}`);
459
+ log.info(`Git author: ${config.gitAuthor || '?'}`);
460
+ log.info(`GitHub user: ${config.githubUser || '?'}`);
461
+ log.info(`Projects (${(config.projects || []).length}):`);
462
+ for (const p of config.projects || []) {
463
+ log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
464
+ log.info(kleur.dim(` path: ${p.path}`));
465
+ log.info(kleur.dim(` remote: github.com/${p.remote}`));
466
+ }
467
+ log.info('');
468
+ }
469
+
470
+ // ─── preview ─────────────────────────────────────────────────────────────────
471
+ async function cmdPreview() {
472
+ if (!existsSync(CONFIG_PATH)) {
473
+ log.err(`No config found at ${CONFIG_PATH}`);
474
+ log.hint('Run `npx @natjswenson/devlog init` first.');
282
475
  process.exit(1);
283
476
  }
284
477
 
285
- const projects = (config.projects || []).map((p) => ({ key: p.key, label: p.label || p.key }));
478
+ let config;
479
+ try {
480
+ config = readConfig();
481
+ validateConfig(config);
482
+ } catch (e) {
483
+ log.err(`Config validation failed: ${e.message}`);
484
+ log.hint(`Edit ${CONFIG_PATH} or run \`devlog config\` to inspect.`);
485
+ process.exit(1);
486
+ }
487
+
488
+ const [owner, repo] = config.targetRepo.split('/');
286
489
  const branch = config.branch || 'main';
490
+ const projects = config.projects.map((p) => ({ key: p.key, label: p.label || p.key }));
491
+
492
+ if (projects.length === 0) {
493
+ log.warn('No projects registered. The preview will show an empty state.');
494
+ log.hint(`Run \`npx @natjswenson/devlog add-project\` to register one.`);
495
+ }
287
496
 
288
497
  log.step(`Launching preview against github.com/${config.targetRepo}...`);
289
498
 
@@ -291,11 +500,20 @@ async function cmdPreview() {
291
500
  const vitePkg = JSON.parse(readFileSync(vitePkgPath, 'utf8'));
292
501
  const viteBin = resolve(dirname(vitePkgPath), vitePkg.bin?.vite || 'bin/vite.js');
293
502
 
503
+ // Filter env to only PATH/HOME/etc plus VITE_DEVLOG_* we set explicitly.
504
+ // This prevents adopters' arbitrary VITE_* vars (e.g. VITE_API_KEY for an
505
+ // unrelated project in their shell) from being inlined into preview source.
506
+ const SAFE_ENV_KEYS = ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'TMPDIR', 'NODE_PATH', 'NODE_OPTIONS'];
507
+ const safeEnv = {};
508
+ for (const k of SAFE_ENV_KEYS) {
509
+ if (process.env[k] !== undefined) safeEnv[k] = process.env[k];
510
+ }
511
+
294
512
  const proc = spawn(process.execPath, [viteBin], {
295
513
  cwd: PREVIEW_DIR,
296
514
  stdio: 'inherit',
297
515
  env: {
298
- ...process.env,
516
+ ...safeEnv,
299
517
  VITE_DEVLOG_OWNER: owner,
300
518
  VITE_DEVLOG_REPO: repo,
301
519
  VITE_DEVLOG_BRANCH: branch,
@@ -305,25 +523,36 @@ async function cmdPreview() {
305
523
  proc.on('exit', (code) => process.exit(code ?? 0));
306
524
  }
307
525
 
526
+ // ─── help ────────────────────────────────────────────────────────────────────
308
527
  function printHelp() {
309
528
  console.log(`
310
- ${kleur.bold('@natjswenson/devlog')} — daily dev log generator
529
+ ${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — daily dev log generator
311
530
 
312
531
  Usage:
313
- npx @natjswenson/devlog init Set up the skill, create your dev-log repo, write config
314
- npx @natjswenson/devlog preview Run a local preview of your published dev log
315
- npx @natjswenson/devlog --help
316
- npx @natjswenson/devlog --version
317
-
318
- Docs: https://github.com/natejswenson/devlog
532
+ ${kleur.cyan('npx @natjswenson/devlog init')} One-time setup: create your dev-log repo, install the skill, write config
533
+ ${kleur.cyan('npx @natjswenson/devlog add-project')} Register an additional project in your config
534
+ ${kleur.cyan('npx @natjswenson/devlog config')} Show your current config (with validation)
535
+ ${kleur.cyan('npx @natjswenson/devlog preview')} Run a local preview of your published dev log
536
+ ${kleur.cyan('npx @natjswenson/devlog --help')}
537
+ ${kleur.cyan('npx @natjswenson/devlog --version')}
538
+
539
+ Docs: https://github.com/natejswenson/devlog
540
+ Issues: https://github.com/natejswenson/devlog/issues
319
541
  `);
320
542
  }
321
543
 
544
+ // ─── dispatch ────────────────────────────────────────────────────────────────
322
545
  const arg = process.argv[2];
323
546
  switch (arg) {
324
547
  case 'init':
325
548
  cmdInit();
326
549
  break;
550
+ case 'add-project':
551
+ cmdAddProject();
552
+ break;
553
+ case 'config':
554
+ cmdConfig();
555
+ break;
327
556
  case 'preview':
328
557
  cmdPreview();
329
558
  break;
@@ -7,6 +7,17 @@ import './DevLogPage.css';
7
7
 
8
8
  const ENTRIES_PER_PAGE = 10;
9
9
 
10
+ // Strict allowlist of URL schemes permitted in markdown links/images.
11
+ // react-markdown 9's default sanitizer already blocks `javascript:`,
12
+ // `vbscript:`, `file:`. We narrow further: only http/https/mailto.
13
+ // Anything else (data:, blob:, ftp:, custom schemes) is replaced with `#`.
14
+ const SAFE_URL_SCHEME = /^(https?:|mailto:|#|\/|\.\.?\/|[^:]*$)/i;
15
+ function safeUrlTransform(url) {
16
+ if (typeof url !== 'string') return '#';
17
+ if (SAFE_URL_SCHEME.test(url)) return url;
18
+ return '#';
19
+ }
20
+
10
21
  function formatDate(dateStr) {
11
22
  if (typeof dateStr !== 'string') return '';
12
23
  const parts = dateStr.split('-');
@@ -143,7 +154,11 @@ export default function DevLogPage({
143
154
  <div className="devlog-content-inner">
144
155
  {isExpanded && content && (
145
156
  <div className="devlog-content" onClick={(e) => e.stopPropagation()}>
146
- <ReactMarkdown remarkPlugins={[remarkGfm]}>
157
+ <ReactMarkdown
158
+ remarkPlugins={[remarkGfm]}
159
+ urlTransform={safeUrlTransform}
160
+ skipHtml
161
+ >
147
162
  {content}
148
163
  </ReactMarkdown>
149
164
  </div>