@natjswenson/devlog 0.1.5 → 0.1.7

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