@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/CHANGELOG.md +82 -0
- package/README.md +82 -40
- package/SECURITY.md +100 -0
- package/SKILL.md +42 -20
- package/bin/devlog.js +350 -121
- package/examples/react/DevLogPage.jsx +16 -1
- package/package.json +14 -9
- package/preview/App.jsx +34 -4
- package/preview/index.html +14 -0
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
168
|
+
log.hint('Run: gh auth login');
|
|
80
169
|
process.exit(1);
|
|
81
170
|
}
|
|
82
171
|
}
|
|
83
172
|
|
|
84
173
|
function detectGhUser() {
|
|
85
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: '
|
|
125
|
-
message: '
|
|
126
|
-
initial:
|
|
127
|
-
validate:
|
|
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: '
|
|
136
|
-
message: '
|
|
137
|
-
initial:
|
|
138
|
-
validate:
|
|
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: '
|
|
143
|
-
message: '
|
|
144
|
-
initial:
|
|
145
|
-
validate:
|
|
253
|
+
name: 'label',
|
|
254
|
+
message: 'Project display label (optional, defaults to key):',
|
|
255
|
+
initial: '',
|
|
256
|
+
validate: VALIDATORS.label,
|
|
146
257
|
},
|
|
147
258
|
{
|
|
148
|
-
type: '
|
|
149
|
-
name: '
|
|
150
|
-
message: '
|
|
151
|
-
initial:
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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(`
|
|
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
|
-
|
|
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('
|
|
377
|
+
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
256
378
|
log.info('Next steps:');
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
440
|
+
config = readConfig();
|
|
274
441
|
} catch (e) {
|
|
275
|
-
log.err(`Failed to
|
|
442
|
+
log.err(`Failed to read config: ${e.message}`);
|
|
276
443
|
process.exit(1);
|
|
277
444
|
}
|
|
278
445
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
314
|
-
npx @natjswenson/devlog
|
|
315
|
-
npx @natjswenson/devlog
|
|
316
|
-
npx @natjswenson/devlog
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
157
|
+
<ReactMarkdown
|
|
158
|
+
remarkPlugins={[remarkGfm]}
|
|
159
|
+
urlTransform={safeUrlTransform}
|
|
160
|
+
skipHtml
|
|
161
|
+
>
|
|
147
162
|
{content}
|
|
148
163
|
</ReactMarkdown>
|
|
149
164
|
</div>
|