@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/CHANGELOG.md +71 -0
- package/README.md +82 -40
- package/SECURITY.md +100 -0
- package/SKILL.md +74 -29
- package/bin/devlog.js +352 -112
- package/examples/react/DevLogPage.jsx +22 -3
- package/examples/react/useDevLogEntries.js +42 -11
- package/package.json +14 -9
- package/preview/App.jsx +46 -5
- package/preview/index.html +14 -0
- package/preview/main.jsx +4 -1
- package/preview/vite.config.js +8 -0
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
|
|
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.
|
|
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.
|
|
157
|
+
log.hint('Run: gh auth login');
|
|
65
158
|
process.exit(1);
|
|
66
159
|
}
|
|
67
160
|
}
|
|
68
161
|
|
|
69
162
|
function detectGhUser() {
|
|
70
|
-
|
|
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 =
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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: '
|
|
110
|
-
message: '
|
|
111
|
-
initial:
|
|
112
|
-
validate:
|
|
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: '
|
|
117
|
-
message: '
|
|
118
|
-
initial:
|
|
119
|
-
validate:
|
|
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: '
|
|
124
|
-
message: '
|
|
125
|
-
initial:
|
|
126
|
-
validate:
|
|
238
|
+
name: 'label',
|
|
239
|
+
message: 'Project display label (optional, defaults to key):',
|
|
240
|
+
initial: '',
|
|
241
|
+
validate: VALIDATORS.label,
|
|
127
242
|
},
|
|
128
243
|
{
|
|
129
|
-
type: '
|
|
130
|
-
name: '
|
|
131
|
-
message: '
|
|
132
|
-
initial:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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('
|
|
362
|
+
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
230
363
|
log.info('Next steps:');
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
425
|
+
config = readConfig();
|
|
248
426
|
} catch (e) {
|
|
249
|
-
log.err(`Failed to
|
|
427
|
+
log.err(`Failed to read config: ${e.message}`);
|
|
250
428
|
process.exit(1);
|
|
251
429
|
}
|
|
252
430
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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
|
-
...
|
|
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
|
|
288
|
-
npx @natjswenson/devlog
|
|
289
|
-
npx @natjswenson/devlog
|
|
290
|
-
npx @natjswenson/devlog
|
|
291
|
-
|
|
292
|
-
|
|
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;
|