@natjswenson/devlog 0.1.6 → 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 +37 -16
- package/bin/devlog.js +335 -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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@natjswenson/devlog` are documented here.
|
|
4
|
+
|
|
5
|
+
## 0.1.7 (2026-05-01) — security hardening + UX improvements
|
|
6
|
+
|
|
7
|
+
**Security**
|
|
8
|
+
- Tightened `SHELL_METACHARS` to additionally reject whitespace, single-quote, square brackets, equals, and percent
|
|
9
|
+
- Project paths now cannot start with `-` (would be parsed as flag)
|
|
10
|
+
- Added strict validation of the `branch` field (no leading dash, no `..` as a path component)
|
|
11
|
+
- Atomic `config.json` writes (write-to-tmp + rename)
|
|
12
|
+
- CLI no longer forwards arbitrary `VITE_*` env vars to the spawned vite — only `VITE_DEVLOG_*` plus `PATH`/`HOME`/etc.
|
|
13
|
+
- Added `Content-Security-Policy` meta tag to the preview app
|
|
14
|
+
- Explicit `urlTransform` in `react-markdown` rejects `data:`, `blob:`, `javascript:`, `vbscript:`, `file:`, and any non-http(s)/mailto scheme
|
|
15
|
+
- `react-markdown` invoked with `skipHtml` for explicit defense-in-depth
|
|
16
|
+
- `SKILL.md` instructs the LLM to single-quote every interpolated config value (defense-in-depth on top of validation)
|
|
17
|
+
- Production preview builds without env vars show a clear "Setup required" screen instead of attempting demo fetches that would 404
|
|
18
|
+
- `config.json` written with mode `0600`, `~/.claude/skills/devlog/` created with mode `0700`
|
|
19
|
+
- Pinned all dependencies to exact versions (no `^` ranges) to eliminate resolution drift
|
|
20
|
+
|
|
21
|
+
**UX**
|
|
22
|
+
- `init` now loops to register multiple projects in a single setup
|
|
23
|
+
- New `add-project` subcommand: `npx @natjswenson/devlog add-project` — register a project without editing config.json by hand
|
|
24
|
+
- New `config` subcommand: `npx @natjswenson/devlog config` — view current config with validation status
|
|
25
|
+
- Init detects when `gh` is authenticated as a different user than `githubUser` and warns
|
|
26
|
+
- Better next-step messaging after init (color, concrete commands)
|
|
27
|
+
- All error messages now include actionable hints (`log.hint`)
|
|
28
|
+
|
|
29
|
+
**Docs**
|
|
30
|
+
- New `SECURITY.md` — threat model, audit history, ruled-out attack scenarios, vulnerability reporting flow
|
|
31
|
+
- New `CHANGELOG.md` (this file)
|
|
32
|
+
- README updated with new subcommands and security guarantees section
|
|
33
|
+
|
|
34
|
+
## 0.1.6 (2026-05-01) — initial security audit fixes
|
|
35
|
+
|
|
36
|
+
Addressed 1 Critical + 3 High findings from the first round of the 6-agent siege:
|
|
37
|
+
- SKILL.md now requires runtime allowlist validation of every config value before shell interpolation
|
|
38
|
+
- CLI switched from `execSync` with template strings to `spawnSync` with argv arrays for any user-input-bearing call
|
|
39
|
+
- `gh repo create` regex hardened against leading-dash flag injection
|
|
40
|
+
- `gitAuthor` validator now rejects shell metachars
|
|
41
|
+
- Schema validation added for `manifest.json`, `VITE_DEVLOG_PROJECTS`, frontmatter (allowlist + `Object.create(null)`)
|
|
42
|
+
- `optimizeDeps` includes for vite to fix react-markdown / react-dom CJS interop in npx layouts
|
|
43
|
+
- Preview vite server bound to localhost only, CORS disabled
|
|
44
|
+
|
|
45
|
+
## 0.1.5 (2026-05-01)
|
|
46
|
+
|
|
47
|
+
- Corrected live-site URL in README (`natejswenson.com` not `.io`)
|
|
48
|
+
|
|
49
|
+
## 0.1.4 (2026-05-01)
|
|
50
|
+
|
|
51
|
+
- README troubleshooting section, npm + license badges
|
|
52
|
+
- SKILL.md uses `<config.branch || 'main'>` consistently in push/URL output
|
|
53
|
+
|
|
54
|
+
## 0.1.3 (2026-05-01)
|
|
55
|
+
|
|
56
|
+
- Expanded `optimizeDeps.include` to cover react/react-dom for npx-installed layouts
|
|
57
|
+
|
|
58
|
+
## 0.1.2 (2026-05-01)
|
|
59
|
+
|
|
60
|
+
- First `optimizeDeps` fix for `style-to-js` CJS/ESM interop (react-markdown rendering)
|
|
61
|
+
|
|
62
|
+
## 0.1.1 (2026-05-01)
|
|
63
|
+
|
|
64
|
+
- `projects[].label` and `branch` config fields (optional, with safe defaults)
|
|
65
|
+
|
|
66
|
+
## 0.1.0 (2026-05-01)
|
|
67
|
+
|
|
68
|
+
- Initial release
|
|
69
|
+
- CLI: `init`, `preview`
|
|
70
|
+
- React drop-in components: `DevLogPage`, `useDevLogEntries`
|
|
71
|
+
- Standalone deployable Vite preview app with snarky demo mode
|
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@natjswenson/devlog)
|
|
4
4
|
[](./LICENSE)
|
|
5
|
+
[](./SECURITY.md)
|
|
6
|
+
[](#security)
|
|
5
7
|
|
|
6
8
|
A Claude Code skill that turns your daily git commits into a published dev log — and a React example for displaying it on your site.
|
|
7
9
|
|
|
@@ -9,7 +11,7 @@ A Claude Code skill that turns your daily git commits into a published dev log
|
|
|
9
11
|
|
|
10
12
|
## Live example
|
|
11
13
|
|
|
12
|
-
The skill is in production at [natejswenson.com/devlog](https://natejswenson.com/devlog), publishing
|
|
14
|
+
The skill is in production at [natejswenson.com/devlog](https://natejswenson.com/devlog), publishing to [github.com/natejswenson/daily-dev-log](https://github.com/natejswenson/daily-dev-log). What you see on that page is exactly what `npx @natjswenson/devlog preview` renders for you locally.
|
|
13
15
|
|
|
14
16
|
## How it works
|
|
15
17
|
|
|
@@ -24,30 +26,46 @@ npx @natjswenson/devlog init
|
|
|
24
26
|
```
|
|
25
27
|
|
|
26
28
|
That command:
|
|
27
|
-
- Creates `<your-username>/daily-dev-log` on GitHub
|
|
29
|
+
- Creates `<your-username>/daily-dev-log` on GitHub (or uses an existing one)
|
|
28
30
|
- Installs the skill at `~/.claude/skills/devlog/`
|
|
29
31
|
- Writes `~/.claude/skills/devlog/config.json` with your answers
|
|
32
|
+
- Lets you register one or more projects in a single run
|
|
30
33
|
|
|
31
34
|
Then:
|
|
35
|
+
|
|
32
36
|
```sh
|
|
33
37
|
npx @natjswenson/devlog preview
|
|
34
38
|
```
|
|
35
|
-
|
|
39
|
+
|
|
40
|
+
to see your dev log rendered locally at `http://localhost:5173`.
|
|
36
41
|
|
|
37
42
|
## Prerequisites
|
|
38
43
|
|
|
39
44
|
- **Node 18+** — for the CLI and preview app
|
|
40
|
-
- **GitHub CLI** (`gh`), authenticated — used to create your dev-log repo and push entries
|
|
45
|
+
- **GitHub CLI** (`gh`), authenticated with `gh auth login` — used to create your dev-log repo and push entries
|
|
41
46
|
- **Claude Code** — to run the `/devlog` skill
|
|
42
47
|
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
| Command | What it does |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `npx @natjswenson/devlog init` | One-time setup: create dev-log repo, install skill, write config |
|
|
53
|
+
| `npx @natjswenson/devlog add-project` | Register an additional project without editing config.json by hand |
|
|
54
|
+
| `npx @natjswenson/devlog config` | Show your current config with validation status |
|
|
55
|
+
| `npx @natjswenson/devlog preview` | Run a local preview at `http://localhost:5173` |
|
|
56
|
+
| `npx @natjswenson/devlog --help` | Usage |
|
|
57
|
+
| `npx @natjswenson/devlog --version` | Version |
|
|
58
|
+
|
|
59
|
+
> **Tip:** run from any directory *outside* a clone of this repo. Running inside the repo causes a `package.json` name collision and `npx` fails with `command not found`.
|
|
60
|
+
|
|
43
61
|
## What you end up with
|
|
44
62
|
|
|
45
63
|
```
|
|
46
64
|
~/.claude/skills/devlog/
|
|
47
|
-
├── SKILL.md # The slash
|
|
48
|
-
└── config.json # Your settings (
|
|
65
|
+
├── SKILL.md # The /devlog slash-command instructions
|
|
66
|
+
└── config.json # Your settings (mode 0600)
|
|
49
67
|
|
|
50
|
-
github.com/<you>/daily-dev-log/ # Created by
|
|
68
|
+
github.com/<you>/daily-dev-log/ # Created by init, populated by /devlog
|
|
51
69
|
├── myproject/
|
|
52
70
|
│ ├── manifest.json
|
|
53
71
|
│ ├── 2026-05-01.md
|
|
@@ -55,19 +73,14 @@ github.com/<you>/daily-dev-log/ # Created by `init`, populated by /devlog
|
|
|
55
73
|
└── ...
|
|
56
74
|
```
|
|
57
75
|
|
|
58
|
-
## Manual setup (if you
|
|
76
|
+
## Manual setup (if you prefer)
|
|
59
77
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
mkdir -p ~/.claude/skills/devlog
|
|
67
|
-
curl -o ~/.claude/skills/devlog/SKILL.md \
|
|
68
|
-
https://raw.githubusercontent.com/natejswenson/devlog/main/SKILL.md
|
|
69
|
-
```
|
|
70
|
-
3. **Write your config** at `~/.claude/skills/devlog/config.json` — copy [`config.example.json`](./config.example.json) and fill in.
|
|
78
|
+
```sh
|
|
79
|
+
gh repo create <you>/daily-dev-log --public --add-readme
|
|
80
|
+
mkdir -p ~/.claude/skills/devlog
|
|
81
|
+
curl -o ~/.claude/skills/devlog/SKILL.md https://raw.githubusercontent.com/natejswenson/devlog/main/SKILL.md
|
|
82
|
+
# Then copy config.example.json → ~/.claude/skills/devlog/config.json and fill it in
|
|
83
|
+
```
|
|
71
84
|
|
|
72
85
|
## Add to your site
|
|
73
86
|
|
|
@@ -77,7 +90,7 @@ github.com/<you>/daily-dev-log/ # Created by `init`, populated by /devlog
|
|
|
77
90
|
cp -r examples/react/ your-site/src/devlog/
|
|
78
91
|
```
|
|
79
92
|
|
|
80
|
-
Edit `your-site/src/devlog/devlog-config.js` to point at your repo, then
|
|
93
|
+
Edit `your-site/src/devlog/devlog-config.js` to point at your repo, then:
|
|
81
94
|
|
|
82
95
|
```jsx
|
|
83
96
|
import DevLogPage from './devlog/DevLogPage.jsx';
|
|
@@ -89,15 +102,15 @@ Full instructions: [`examples/react/README.md`](./examples/react/README.md).
|
|
|
89
102
|
|
|
90
103
|
### No site yet?
|
|
91
104
|
|
|
92
|
-
The `preview/` directory is a complete deployable Vite app. Set `VITE_DEVLOG_OWNER` / `VITE_DEVLOG_REPO` / `VITE_DEVLOG_PROJECTS` env vars on Vercel, Netlify, or Cloudflare Pages, build with `vite build`, deploy `dist/`.
|
|
105
|
+
The `preview/` directory is a complete deployable Vite app. Set `VITE_DEVLOG_OWNER` / `VITE_DEVLOG_REPO` / `VITE_DEVLOG_PROJECTS` env vars on Vercel, Netlify, or Cloudflare Pages, build with `vite build`, deploy `dist/`. See [`preview/README.md`](./preview/README.md).
|
|
93
106
|
|
|
94
|
-
### Other stacks (Next, Astro, plain HTML
|
|
107
|
+
### Other stacks (Next, Astro, plain HTML)
|
|
95
108
|
|
|
96
109
|
It's static JSON and Markdown on GitHub. Build whatever UI you want — see the **Data contract** below.
|
|
97
110
|
|
|
98
111
|
## Data contract
|
|
99
112
|
|
|
100
|
-
The dev-log repo has this layout, all served as raw files from `https://raw.githubusercontent.com/<owner>/<repo
|
|
113
|
+
The dev-log repo has this layout, all served as raw files from `https://raw.githubusercontent.com/<owner>/<repo>/<branch>/`:
|
|
101
114
|
|
|
102
115
|
```
|
|
103
116
|
<repo>/
|
|
@@ -117,7 +130,13 @@ The dev-log repo has this layout, all served as raw files from `https://raw.gith
|
|
|
117
130
|
}
|
|
118
131
|
```
|
|
119
132
|
|
|
133
|
+
Strict validation rules (entries that don't match are silently dropped by the React example):
|
|
134
|
+
- `date` matches `YYYY-MM-DD`
|
|
135
|
+
- `file` matches `^[a-zA-Z0-9._-]+\.md$`
|
|
136
|
+
- `title` and `summary` are non-empty strings
|
|
137
|
+
|
|
120
138
|
**Entry markdown:**
|
|
139
|
+
|
|
121
140
|
```markdown
|
|
122
141
|
---
|
|
123
142
|
title: "Concise day summary"
|
|
@@ -144,39 +163,62 @@ That's the entire contract.
|
|
|
144
163
|
|
|
145
164
|
| Field | Type | Description |
|
|
146
165
|
|---|---|---|
|
|
147
|
-
| `targetRepo` | `"<owner>/<repo>"` | Repo where dev log entries are published.
|
|
148
|
-
| `branch` | string (optional) | Branch in the dev-log repo. Defaults to `main`. |
|
|
149
|
-
| `gitAuthor` | string | Used as `git log --author=...` to find your commits. |
|
|
150
|
-
| `githubUser` | string | Your GitHub username
|
|
166
|
+
| `targetRepo` | `"<owner>/<repo>"` | Repo where dev log entries are published. Must match `^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$`. |
|
|
167
|
+
| `branch` | string (optional) | Branch in the dev-log repo. Defaults to `main`. Must not contain `..` or start with `-`. |
|
|
168
|
+
| `gitAuthor` | string | Used as `git log --author=...` to find your commits. Whitespace OK; no shell metacharacters. |
|
|
169
|
+
| `githubUser` | string | Your GitHub username. |
|
|
151
170
|
| `projects` | array | One entry per project you want dev logs for. |
|
|
152
|
-
| `projects[].key` | string | Subdirectory name in the dev-log repo. |
|
|
153
|
-
| `projects[].label` | string (optional) | Display name for the
|
|
154
|
-
| `projects[].path` | string | Local filesystem path to the project. |
|
|
155
|
-
| `projects[].remote` | `"<owner>/<repo>"` | The project's GitHub remote
|
|
171
|
+
| `projects[].key` | string | Subdirectory name in the dev-log repo. Strict token: `^[a-z0-9][a-z0-9._-]*$`, no `..`. |
|
|
172
|
+
| `projects[].label` | string (optional) | Display name for the tab. Defaults to `key`. |
|
|
173
|
+
| `projects[].path` | string | Local filesystem path to the project. Whitespace OK. |
|
|
174
|
+
| `projects[].remote` | `"<owner>/<repo>"` | The project's GitHub remote. Used to mark public commits and link them. |
|
|
175
|
+
|
|
176
|
+
See [`config.example.json`](./config.example.json) for a complete template, or run `npx @natjswenson/devlog config` to inspect your current config with validation.
|
|
177
|
+
|
|
178
|
+
## Security
|
|
179
|
+
|
|
180
|
+
The package is designed to be safe to install on a developer's machine and have ambient gh/git credentials. See [SECURITY.md](./SECURITY.md) for the full threat model and audit history.
|
|
156
181
|
|
|
157
|
-
|
|
182
|
+
**At a glance:**
|
|
183
|
+
- ✓ All shell calls in the CLI use `spawnSync` with argv arrays (no shell, no injection surface)
|
|
184
|
+
- ✓ The skill validates every config field against an allowlist before interpolating into shell commands; instructs the LLM to single-quote interpolated values
|
|
185
|
+
- ✓ Markdown rendering uses `react-markdown` with `skipHtml` and an allowlist `urlTransform` (only http(s)/mailto allowed; data:, javascript:, vbscript:, file:, blob: all neutralized)
|
|
186
|
+
- ✓ Frontmatter parser uses `Object.create(null)` + key allowlist (no prototype pollution)
|
|
187
|
+
- ✓ All external JSON is schema-validated before use (manifest, env-var projects array)
|
|
188
|
+
- ✓ Vite dev server bound to `localhost`, CORS off
|
|
189
|
+
- ✓ Demo-mode `window.fetch` override gated to dev builds only
|
|
190
|
+
- ✓ `config.json` written atomically (tmp + rename), mode 0600
|
|
191
|
+
- ✓ All dependencies pinned to exact versions
|
|
192
|
+
- ✓ `npm audit`: 0 known vulnerabilities
|
|
193
|
+
|
|
194
|
+
**To report a vulnerability:** open a [GitHub security advisory](https://github.com/natejswenson/devlog/security/advisories/new). Do not open a public issue.
|
|
158
195
|
|
|
159
196
|
## Customization
|
|
160
197
|
|
|
161
198
|
- **Tweak the entry template:** edit `~/.claude/skills/devlog/SKILL.md` (Step 4 — generate the entry).
|
|
162
|
-
- **Tweak the UI:** override the `--devlog-*` CSS variables in `examples/react/DevLogPage.css` to match your theme.
|
|
163
|
-
- **Add more projects:**
|
|
199
|
+
- **Tweak the UI:** override the `--devlog-*` CSS variables in `examples/react/DevLogPage.css` to match your theme.
|
|
200
|
+
- **Add more projects:** `npx @natjswenson/devlog add-project` (no manual JSON editing required).
|
|
164
201
|
|
|
165
202
|
## Troubleshooting
|
|
166
203
|
|
|
167
|
-
**`sh: devlog: command not found` when running `npx`:** you're
|
|
168
|
-
|
|
169
|
-
**Init prompt shows `78` after the placeholder text:** that's an artifact of capturing terminal output (cursor save/restore escapes); in a real terminal you won't see it.
|
|
204
|
+
**`sh: devlog: command not found` when running `npx`:** you're inside a checkout of this repo. The local `package.json` name collides with the published one. Run `npx` from somewhere else, e.g. `cd ~ && npx @natjswenson/devlog ...`.
|
|
170
205
|
|
|
171
|
-
**
|
|
206
|
+
**Init prompts show `78` after placeholder text:** that's an artifact of how some output capture tools render `\x1b7`/`\x1b8` (cursor save/restore) escape sequences. In a real interactive terminal, you won't see it.
|
|
172
207
|
|
|
173
|
-
|
|
208
|
+
**Preview is blank / no tabs / no entries:** check the browser console. If you see CJS interop errors related to `react-dom/client`, `style-to-js`, or `react-markdown`, you're on a pre-0.1.6 release — upgrade with `npm cache clean --force && rm -rf ~/.npm/_npx && npx --yes @natjswenson/devlog@latest preview`.
|
|
174
209
|
|
|
175
210
|
**Init can't find `gh`:** install via [cli.github.com](https://cli.github.com/) and run `gh auth login`.
|
|
176
211
|
|
|
212
|
+
**`Config validation failed: ...`** — the validator rejected something in your `config.json`. Run `npx @natjswenson/devlog config` for a detailed diagnosis. Common causes:
|
|
213
|
+
- A field has shell metacharacters (`;` `&` `|` `` ` `` `$` etc) — see SKILL.md for the full list
|
|
214
|
+
- A project key contains `..` or `/`
|
|
215
|
+
- A path doesn't exist on disk
|
|
216
|
+
|
|
217
|
+
**Preview shows "Setup required":** you deployed the preview app standalone but didn't set the env vars. Set `VITE_DEVLOG_OWNER`, `VITE_DEVLOG_REPO`, `VITE_DEVLOG_PROJECTS` (JSON-stringified array) in your hosting environment.
|
|
218
|
+
|
|
177
219
|
## Versioning
|
|
178
220
|
|
|
179
|
-
|
|
221
|
+
Releases are documented in [CHANGELOG.md](./CHANGELOG.md). The package follows semver — bug fixes/security patches in patch releases (0.1.x), behavior changes in minor (0.x.0).
|
|
180
222
|
|
|
181
223
|
## License
|
|
182
224
|
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please report security issues privately rather than opening a public GitHub issue.
|
|
6
|
+
|
|
7
|
+
- **Email:** open a GitHub security advisory at https://github.com/natejswenson/devlog/security/advisories/new
|
|
8
|
+
- **Response time:** target within 72 hours
|
|
9
|
+
|
|
10
|
+
Please do not file public issues, post on social media, or share PoCs publicly until a fix has shipped.
|
|
11
|
+
|
|
12
|
+
## Scope
|
|
13
|
+
|
|
14
|
+
In scope:
|
|
15
|
+
- The published npm package `@natjswenson/devlog` (any version)
|
|
16
|
+
- The CLI (`bin/devlog.js`)
|
|
17
|
+
- The Claude Code skill (`SKILL.md`)
|
|
18
|
+
- The Vite preview app (`preview/`)
|
|
19
|
+
- The React drop-in components (`examples/react/`)
|
|
20
|
+
|
|
21
|
+
Out of scope:
|
|
22
|
+
- The user's own `daily-dev-log` repo content (that's on the user)
|
|
23
|
+
- The user's GitHub account, gh CLI, or local dev environment
|
|
24
|
+
- The Claude Code product itself
|
|
25
|
+
- Vulnerabilities that require an attacker to already have arbitrary code execution on the user's machine
|
|
26
|
+
- Social engineering, phishing, or attacks on the npm registry / GitHub itself
|
|
27
|
+
|
|
28
|
+
## Threat model
|
|
29
|
+
|
|
30
|
+
### Trust boundaries
|
|
31
|
+
|
|
32
|
+
The package crosses three trust boundaries:
|
|
33
|
+
|
|
34
|
+
1. **npm → adopter's machine.** The package is distributed via the npm registry. Adopters trust npm not to ship a tampered tarball. Out of our scope; defense relies on npm's signing and provenance.
|
|
35
|
+
|
|
36
|
+
2. **`config.json` → shell commands.** The Claude Code skill (`SKILL.md`) reads `~/.claude/skills/devlog/config.json` and uses values in shell commands run via Claude's bash tool. The skill's Step 0.5 enforces strict allowlist validation on every field BEFORE interpolation, and instructs the LLM to single-quote all values when interpolating. This is the most security-critical boundary.
|
|
37
|
+
|
|
38
|
+
3. **`daily-dev-log` repo content → adopter's deployed site.** Markdown entries published via `/devlog` are fetched from `raw.githubusercontent.com` and rendered by `react-markdown` 9.x. Raw HTML is disabled. URLs are restricted to `http(s)://`, `mailto:`, `#`, and relative paths via an explicit `urlTransform`.
|
|
39
|
+
|
|
40
|
+
### Attacker capabilities
|
|
41
|
+
|
|
42
|
+
| Attacker | Has access to | Out of reach |
|
|
43
|
+
|---|---|---|
|
|
44
|
+
| Random LinkedIn reader | npm tarball, GitHub repo, README | Adopter's machine, gh token, dev-log content |
|
|
45
|
+
| Adopter who edits config.json | Their own filesystem, gh token | Other adopters' machines |
|
|
46
|
+
| Contributor with PR rights to dev-log repo | Markdown content rendered by adopter's site | Adopter's local files, gh token |
|
|
47
|
+
|
|
48
|
+
### What's protected
|
|
49
|
+
|
|
50
|
+
- **Command injection in the CLI:** every shell call that includes user input uses `spawnSync` with argv arrays (no shell). The four hardcoded `execSync` calls (`gh --version`, `gh auth status`, `gh api user --jq .login`, `git config --global user.name`) take no user input.
|
|
51
|
+
|
|
52
|
+
- **Command injection via the skill:** the skill validates every config value against an allowlist before interpolation, instructs the LLM to single-quote every value, and uses `git -C <path>` form rather than `cd <path> && git ...` to reduce shell-composition surface.
|
|
53
|
+
|
|
54
|
+
- **Path traversal:** project keys, manifest filenames, and project paths are all pattern-matched. Project paths additionally cannot start with `-` (would be parsed as a flag). Manifest filenames must match `^[a-zA-Z0-9._-]+\.md$`.
|
|
55
|
+
|
|
56
|
+
- **XSS via markdown:** `react-markdown` 9 with `skipHtml`, plus an explicit `urlTransform` allowlisting only `http(s)://`, `mailto:`, `#`, and relative URLs. Schemes like `data:`, `blob:`, `javascript:`, `vbscript:`, `file:` are replaced with `#`.
|
|
57
|
+
|
|
58
|
+
- **Prototype pollution via frontmatter:** the parser uses `Object.create(null)` and only writes to keys in `{title, date, project, summary}` — so `__proto__: x` in a hostile entry has no effect.
|
|
59
|
+
|
|
60
|
+
- **Schema confusion:** every external JSON document (manifest, parsed env-var projects array) is validated against a strict schema before use. Malformed input is rejected, not "fixed."
|
|
61
|
+
|
|
62
|
+
- **Demo-mode side effects in production:** the global `window.fetch` override is gated to `import.meta.env.DEV`. Production builds without env vars show a clear "Setup required" screen instead of broken-looking entries.
|
|
63
|
+
|
|
64
|
+
- **Env var leakage to client-side:** the CLI passes only `VITE_DEVLOG_*` variables (plus `PATH`/`HOME`/etc. needed for Vite to run) to the spawned vite process. Adopter's other `VITE_*` shell vars do NOT leak into the preview bundle.
|
|
65
|
+
|
|
66
|
+
- **Vite dev server exposure:** explicit `server.host: 'localhost'` + `cors: false`. The dev server cannot be reached from other machines on the LAN by default.
|
|
67
|
+
|
|
68
|
+
- **Atomic config writes:** `config.json` is written to a sibling `.tmp.<pid>` file then renamed atomically. Readers never see partial state.
|
|
69
|
+
|
|
70
|
+
- **Tight `.gitignore`:** excludes `.npmrc`, `.env*`, `*.pem`, `*.key`, `.vscode/`, `.idea/`, `coverage/` so future contributors can't accidentally publish secrets via the `files` whitelist in package.json.
|
|
71
|
+
|
|
72
|
+
- **Pinned dependencies:** every dependency is pinned to an exact version (no `^` ranges) to eliminate resolution drift across installs.
|
|
73
|
+
|
|
74
|
+
### What's NOT protected (intentional risk acceptance)
|
|
75
|
+
|
|
76
|
+
- **Adopter editing config.json with malicious values.** The skill validates at runtime and refuses to run on bad input — but a sufficiently determined user can bypass any client-side guard by directly running shell commands themselves. The package can't protect against the adopter attacking their own machine.
|
|
77
|
+
|
|
78
|
+
- **Adopter installing a typosquatted package.** Always verify the scope is `@natjswenson` (no extra `e`).
|
|
79
|
+
|
|
80
|
+
- **GitHub raw URL paths.** A manifest entry's `file` field is restricted to `^[a-zA-Z0-9._-]+\.md$`, but the project key is part of the URL path. The schema validator on project keys rejects `..`, `/`, and other traversal characters.
|
|
81
|
+
|
|
82
|
+
## Reproducible attack scenarios that have been ruled out
|
|
83
|
+
|
|
84
|
+
Each of the following has been tested against the current code and shown to be ineffective. Reports of these scenarios will be acknowledged but treated as "already addressed."
|
|
85
|
+
|
|
86
|
+
1. `gitAuthor: "Nate\"; rm -rf ~; #"` in config.json → blocked by Step 0.5 validation in skill, rejected by CLI prompt validator.
|
|
87
|
+
2. `path: "/tmp/$(id)"` in config.json → blocked by SHELL_METACHARS regex.
|
|
88
|
+
3. `targetRepo: "-h"` or `--template <evil-repo>` → rejected by `^[a-z0-9][a-z0-9._-]*$` regex requiring leading alphanumeric.
|
|
89
|
+
4. `__proto__: pwned` in entry frontmatter → ignored by allowlist + `Object.create(null)` parser.
|
|
90
|
+
5. `[click me](javascript:alert(1))` in entry markdown → react-markdown's default sanitizer + our `urlTransform` rewrite to `#`.
|
|
91
|
+
6. `[click](data:text/html,<script>...)` in entry markdown → rewritten to `#` by `urlTransform`.
|
|
92
|
+
7. Manifest entry with `file: "../../../other-repo/secret.md"` → rejected by `^[a-zA-Z0-9._-]+\.md$` schema.
|
|
93
|
+
8. Concurrent editor seeing a half-written `config.json` → atomic rename guarantees readers see either the old or new file, never a partial write.
|
|
94
|
+
9. `VITE_API_KEY=secret npx ... preview` leaking through into the preview bundle → blocked by env-var allowlist in CLI.
|
|
95
|
+
|
|
96
|
+
## Audit history
|
|
97
|
+
|
|
98
|
+
| Date | Version | Audit | Result |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| 2026-05-01 | 0.1.5 → 0.1.7 | 6-agent siege (Boundary Attacker, Insider Threat, Infrastructure Prober, Betrayed Consumer, Fresh Attacker, Chain Analyst) | 1 Critical + 3 High + 7 Medium + 4 Low → 0 Critical + 0 High + 0 Medium-Active in 0.1.7 |
|
package/SKILL.md
CHANGED
|
@@ -51,20 +51,38 @@ Validate that `targetRepo`, `gitAuthor`, `githubUser`, and `projects` (non-empty
|
|
|
51
51
|
|
|
52
52
|
**Critical:** every value below gets interpolated into shell commands. If any value contains shell metacharacters or breaks the expected shape, **STOP** and tell the user their config is malformed. Do not "fix" it — refuse to run.
|
|
53
53
|
|
|
54
|
+
The CLI's `init` and `add-project` commands enforce these patterns at write time. The skill MUST re-enforce them at runtime because the user can edit `config.json` by hand at any time.
|
|
55
|
+
|
|
54
56
|
| Field | Required pattern |
|
|
55
57
|
|---|---|
|
|
56
58
|
| `targetRepo` | Matches `^[a-zA-Z0-9][a-zA-Z0-9._-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$` (owner/repo, no leading dash) |
|
|
57
|
-
| `branch` (optional) | Matches `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$` (no leading dash, no `..`); defaults to `main` |
|
|
58
|
-
| `gitAuthor` | Must NOT contain any of: `;` `&`
|
|
59
|
+
| `branch` (optional) | Matches `^[a-zA-Z0-9][a-zA-Z0-9._/-]*$` (no leading dash, no `..` as a path component); defaults to `main` |
|
|
60
|
+
| `gitAuthor` | Must NOT contain any of: `;` `&` `\|` `` ` `` `$` `(` `)` `<` `>` `{` `}` `[` `]` `*` `?` `!` `#` `~` `"` `'` `\` newline, CR. (Whitespace, dots, hyphens, equals, percent are fine — names like "Nate Swenson" and "O.G. Lastname" must validate.) |
|
|
59
61
|
| `githubUser` | Matches `^[a-zA-Z0-9][a-zA-Z0-9-]*$` |
|
|
60
62
|
| `projects[].key` | Matches `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` AND must not contain `..` |
|
|
61
|
-
| `projects[].path` | Must NOT contain shell
|
|
63
|
+
| `projects[].path` | Must NOT contain the shell-quote-break set (same as gitAuthor), MUST NOT start with `-`, AND must point to an existing directory. Whitespace allowed (paths legitimately contain spaces). |
|
|
64
|
+
| `projects[].label` (optional) | Same character constraints as `gitAuthor` — used as display text, never as a shell argument |
|
|
62
65
|
| `projects[].remote` | Same pattern as `targetRepo` |
|
|
63
66
|
|
|
64
67
|
If any field fails validation, stop with:
|
|
65
|
-
> Config field `<field>` failed security validation: `<value>`. Edit `~/.claude/skills/devlog/config.json` and retry.
|
|
68
|
+
> Config field `<field>` failed security validation: `<value>`. Edit `~/.claude/skills/devlog/config.json` and retry, or run `npx @natjswenson/devlog config` to inspect.
|
|
69
|
+
|
|
70
|
+
**Shell-quoting rule (defense-in-depth):**
|
|
71
|
+
|
|
72
|
+
Even with values validated, when interpolating into a shell command, ALWAYS wrap the value in single quotes (`'...'`). Single-quoted shell strings have no metacharacter expansion. Since validation above forbids embedded single quotes, this is always safe. Example:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# Right
|
|
76
|
+
git -C '<project.path>' log --author='<config.gitAuthor>' --since=midnight ...
|
|
77
|
+
|
|
78
|
+
# Also right (separate flags after `=`)
|
|
79
|
+
git -C '<project.path>' log "--author=<config.gitAuthor>" --since=midnight ...
|
|
80
|
+
|
|
81
|
+
# Wrong — no quotes
|
|
82
|
+
git -C <project.path> log ...
|
|
83
|
+
```
|
|
66
84
|
|
|
67
|
-
Once validated, the values are safe to interpolate into the shell commands below. Even so, **prefer `git -C <path>` form over `cd <path> && git ...`** (reduces shell-escape complexity) and **use the Write tool, not bash heredocs, when writing JSON or markdown files** (avoids accidentally re-injecting attacker-controlled content into shell).
|
|
85
|
+
Once validated AND single-quoted, the values are safe to interpolate into the shell commands below. Even so, **prefer `git -C <path>` form over `cd <path> && git ...`** (reduces shell-escape complexity) and **use the Write tool, not bash heredocs, when writing JSON or markdown files** (avoids accidentally re-injecting attacker-controlled content into shell).
|
|
68
86
|
|
|
69
87
|
## Step 1: Determine scope
|
|
70
88
|
|
|
@@ -73,10 +91,10 @@ Once validated, the values are safe to interpolate into the shell commands below
|
|
|
73
91
|
|
|
74
92
|
## Step 2: Gather today's commits
|
|
75
93
|
|
|
76
|
-
For each project in scope, run (use `git -C` to avoid `cd` shell-composition):
|
|
94
|
+
For each project in scope, run (use `git -C` to avoid `cd` shell-composition; single-quote interpolated values):
|
|
77
95
|
|
|
78
96
|
```bash
|
|
79
|
-
git -C <project.path> log --author=<config.gitAuthor> --since=midnight --format
|
|
97
|
+
git -C '<project.path>' log "--author=<config.gitAuthor>" --since=midnight --format='%H|%s|%D' --all
|
|
80
98
|
```
|
|
81
99
|
|
|
82
100
|
If no commits are found for a project, skip it. If no commits are found across all projects, inform the user and stop.
|
|
@@ -86,8 +104,8 @@ If no commits are found for a project, skip it. If no commits are found across a
|
|
|
86
104
|
For each commit, check if it's on the `main` branch and if the remote is public:
|
|
87
105
|
|
|
88
106
|
```bash
|
|
89
|
-
git -C <project.path> remote get-url origin
|
|
90
|
-
git -C <project.path> branch --contains <hash> -r 2>/dev/null | grep -q 'origin/main'
|
|
107
|
+
git -C '<project.path>' remote get-url origin
|
|
108
|
+
git -C '<project.path>' branch --contains <hash> -r 2>/dev/null | grep -q 'origin/main'
|
|
91
109
|
```
|
|
92
110
|
|
|
93
111
|
- If the remote URL matches `<project.remote>` (i.e. `github.com/<project.remote>` or the SSH equivalent) and the commit is on `origin/main`, it's a public commit — include a link using `https://github.com/<project.remote>/commit/<hash>`.
|
|
@@ -154,8 +172,9 @@ Clone the repo once, write all project entries, then push.
|
|
|
154
172
|
mktemp -d
|
|
155
173
|
# → record the printed path, e.g. /var/folders/.../tmp.abc123
|
|
156
174
|
|
|
157
|
-
# Step 6.2: clone (use --depth=1 to limit blast radius if remote is huge
|
|
158
|
-
|
|
175
|
+
# Step 6.2: clone (use --depth=1 to limit blast radius if remote is huge;
|
|
176
|
+
# the targetRepo value has been validated to match <owner>/<repo> already)
|
|
177
|
+
git -C '<abs-tmp-path>' clone --depth=1 'https://github.com/<config.targetRepo>.git'
|
|
159
178
|
```
|
|
160
179
|
|
|
161
180
|
Write entries and manifest using the **Write tool** (not bash heredocs — avoids re-injecting content into shell):
|
|
@@ -166,16 +185,18 @@ Write entries and manifest using the **Write tool** (not bash heredocs — avoid
|
|
|
166
185
|
- Entry object: `{ "date": "YYYY-MM-DD", "file": "YYYY-MM-DD.md", "title": "...", "summary": "..." }`
|
|
167
186
|
- If appending to existing entry, update title/summary only if changed
|
|
168
187
|
- If manifest doesn't exist, create it as `{ "entries": [...] }`
|
|
188
|
+
- **Sanitize fetched title/summary:** if appending to an existing entry, the fetched values are external content — never echo them through bash without escaping. Use the Write tool with the values as JSON literals.
|
|
169
189
|
|
|
170
|
-
Then commit and push:
|
|
190
|
+
Then commit and push (single-quote all interpolated values):
|
|
171
191
|
|
|
172
192
|
```bash
|
|
173
|
-
git -C <abs-tmp-path>/<repo-name> add .
|
|
174
|
-
git -C <abs-tmp-path>/<repo-name> commit -m
|
|
175
|
-
|
|
193
|
+
git -C '<abs-tmp-path>/<repo-name>' add .
|
|
194
|
+
git -C '<abs-tmp-path>/<repo-name>' commit -m 'devlog: add entries for YYYY-MM-DD'
|
|
195
|
+
# Use --no-tags to avoid pushing any local tags that happened to be in the temp clone
|
|
196
|
+
git -C '<abs-tmp-path>/<repo-name>' push --no-tags origin '<config.branch || main>'
|
|
176
197
|
|
|
177
198
|
# Cleanup — pass the absolute path explicitly
|
|
178
|
-
rm -rf <abs-tmp-path>
|
|
199
|
+
rm -rf '<abs-tmp-path>'
|
|
179
200
|
```
|
|
180
201
|
|
|
181
202
|
## Step 7: Confirm
|
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,91 @@ 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
|
+
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
|
+
|
|
62
143
|
async function preflight() {
|
|
63
144
|
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
64
145
|
if (nodeMajor < 18) {
|
|
65
146
|
log.err(`Node 18+ required (you have ${process.versions.node}).`);
|
|
147
|
+
log.hint('Update Node: https://nodejs.org/');
|
|
66
148
|
process.exit(1);
|
|
67
149
|
}
|
|
68
|
-
|
|
69
|
-
const ghVersion = tryExec('gh --version');
|
|
70
|
-
if (!ghVersion) {
|
|
150
|
+
if (!tryExec('gh --version')) {
|
|
71
151
|
log.err('GitHub CLI (`gh`) is not installed.');
|
|
72
|
-
log.
|
|
152
|
+
log.hint('Install: https://cli.github.com/ then run `gh auth login`');
|
|
73
153
|
process.exit(1);
|
|
74
154
|
}
|
|
75
|
-
|
|
76
|
-
const ghAuth = tryExec('gh auth status');
|
|
77
|
-
if (!ghAuth) {
|
|
155
|
+
if (!tryExec('gh auth status')) {
|
|
78
156
|
log.err('GitHub CLI is not authenticated.');
|
|
79
|
-
log.
|
|
157
|
+
log.hint('Run: gh auth login');
|
|
80
158
|
process.exit(1);
|
|
81
159
|
}
|
|
82
160
|
}
|
|
83
161
|
|
|
84
162
|
function detectGhUser() {
|
|
85
|
-
|
|
86
|
-
return out || null;
|
|
163
|
+
return tryExec('gh api user --jq .login') || null;
|
|
87
164
|
}
|
|
88
165
|
|
|
89
166
|
function detectGitName() {
|
|
@@ -104,128 +181,158 @@ async function confirmOverwrite(label, path) {
|
|
|
104
181
|
name: 'ok',
|
|
105
182
|
message: `${label} already exists at ${path}. Overwrite?`,
|
|
106
183
|
initial: false,
|
|
107
|
-
});
|
|
184
|
+
}, { onCancel: () => process.exit(1) });
|
|
108
185
|
return ok === true;
|
|
109
186
|
}
|
|
110
187
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
};
|
|
114
214
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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)) || '';
|
|
120
220
|
|
|
121
221
|
const answers = await prompts([
|
|
122
222
|
{
|
|
123
223
|
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
|
-
},
|
|
224
|
+
name: 'path',
|
|
225
|
+
message: 'Project absolute path:',
|
|
226
|
+
initial: initialPath,
|
|
227
|
+
validate: VALIDATORS.path,
|
|
132
228
|
},
|
|
133
229
|
{
|
|
134
230
|
type: 'text',
|
|
135
|
-
name: '
|
|
136
|
-
message: '
|
|
137
|
-
initial:
|
|
138
|
-
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,
|
|
139
235
|
},
|
|
140
236
|
{
|
|
141
237
|
type: 'text',
|
|
142
|
-
name: '
|
|
143
|
-
message: '
|
|
144
|
-
initial:
|
|
145
|
-
validate:
|
|
238
|
+
name: 'label',
|
|
239
|
+
message: 'Project display label (optional, defaults to key):',
|
|
240
|
+
initial: '',
|
|
241
|
+
validate: VALIDATORS.label,
|
|
146
242
|
},
|
|
147
243
|
{
|
|
148
|
-
type: '
|
|
149
|
-
name: '
|
|
150
|
-
message: '
|
|
151
|
-
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,
|
|
152
249
|
},
|
|
153
250
|
], { onCancel: () => process.exit(1) });
|
|
154
251
|
|
|
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
|
-
|
|
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}`);
|
|
190
298
|
}
|
|
191
299
|
|
|
192
300
|
const targetRepo = `${answers.githubUser}/${answers.targetRepoName}`;
|
|
193
|
-
const config = {
|
|
301
|
+
const config = validateConfig({
|
|
194
302
|
targetRepo,
|
|
303
|
+
branch: 'main',
|
|
195
304
|
gitAuthor: answers.gitAuthor,
|
|
196
305
|
githubUser: answers.githubUser,
|
|
197
|
-
projects
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
+
}
|
|
203
316
|
|
|
204
317
|
log.info('\n' + kleur.bold('Summary:'));
|
|
205
318
|
log.info(` Target repo: ${kleur.cyan(`github.com/${targetRepo}`)}`);
|
|
206
319
|
log.info(` Git author: ${config.gitAuthor}`);
|
|
207
320
|
log.info(` GitHub user: ${config.githubUser}`);
|
|
208
|
-
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(', ')}`);
|
|
209
323
|
log.info(` Skill location: ${CONFIG_DIR}`);
|
|
210
324
|
|
|
211
|
-
const { proceed } = await prompts({
|
|
212
|
-
type: 'confirm',
|
|
213
|
-
name: 'proceed',
|
|
214
|
-
message: 'Continue?',
|
|
215
|
-
initial: true,
|
|
216
|
-
}, { onCancel: () => process.exit(1) });
|
|
325
|
+
const { proceed } = await prompts({ type: 'confirm', name: 'proceed', message: 'Continue?', initial: true }, { onCancel: () => process.exit(1) });
|
|
217
326
|
if (!proceed) process.exit(0);
|
|
218
|
-
|
|
219
327
|
log.info('');
|
|
220
328
|
|
|
329
|
+
// Repo create — argv form, no shell.
|
|
221
330
|
const repoExists = tryExecArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
|
|
222
331
|
if (repoExists) {
|
|
223
332
|
log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
|
|
224
333
|
} else {
|
|
225
334
|
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
|
-
});
|
|
335
|
+
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], { stdio: 'inherit' });
|
|
229
336
|
if (r.status !== 0) {
|
|
230
337
|
log.err('Failed to create repo. Check `gh` permissions.');
|
|
231
338
|
process.exit(1);
|
|
@@ -234,7 +341,7 @@ async function cmdInit() {
|
|
|
234
341
|
}
|
|
235
342
|
|
|
236
343
|
if (!existsSync(CONFIG_DIR)) {
|
|
237
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
344
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
238
345
|
log.ok(`Created ${CONFIG_DIR}`);
|
|
239
346
|
}
|
|
240
347
|
|
|
@@ -246,44 +353,131 @@ async function cmdInit() {
|
|
|
246
353
|
}
|
|
247
354
|
|
|
248
355
|
if (await confirmOverwrite('config.json', CONFIG_PATH)) {
|
|
249
|
-
|
|
356
|
+
atomicWriteJSON(CONFIG_PATH, config);
|
|
250
357
|
log.ok(`Wrote config → ${CONFIG_PATH}`);
|
|
251
358
|
} else {
|
|
252
359
|
log.warn('Skipped config.json');
|
|
253
360
|
}
|
|
254
361
|
|
|
255
|
-
log.info('\n' + kleur.bold().green('
|
|
362
|
+
log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
|
|
256
363
|
log.info('Next steps:');
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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')}`);
|
|
261
372
|
log.info('');
|
|
262
373
|
}
|
|
263
374
|
|
|
264
|
-
|
|
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() {
|
|
265
417
|
if (!existsSync(CONFIG_PATH)) {
|
|
266
418
|
log.err(`No config found at ${CONFIG_PATH}`);
|
|
267
|
-
log.
|
|
419
|
+
log.hint('Run `npx @natjswenson/devlog init` first.');
|
|
268
420
|
process.exit(1);
|
|
269
421
|
}
|
|
270
422
|
|
|
271
423
|
let config;
|
|
272
424
|
try {
|
|
273
|
-
config =
|
|
425
|
+
config = readConfig();
|
|
274
426
|
} catch (e) {
|
|
275
|
-
log.err(`Failed to
|
|
427
|
+
log.err(`Failed to read config: ${e.message}`);
|
|
276
428
|
process.exit(1);
|
|
277
429
|
}
|
|
278
430
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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.');
|
|
282
460
|
process.exit(1);
|
|
283
461
|
}
|
|
284
462
|
|
|
285
|
-
|
|
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.`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const [owner, repo] = config.targetRepo.split('/');
|
|
286
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
|
+
}
|
|
287
481
|
|
|
288
482
|
log.step(`Launching preview against github.com/${config.targetRepo}...`);
|
|
289
483
|
|
|
@@ -291,11 +485,20 @@ async function cmdPreview() {
|
|
|
291
485
|
const vitePkg = JSON.parse(readFileSync(vitePkgPath, 'utf8'));
|
|
292
486
|
const viteBin = resolve(dirname(vitePkgPath), vitePkg.bin?.vite || 'bin/vite.js');
|
|
293
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
|
+
|
|
294
497
|
const proc = spawn(process.execPath, [viteBin], {
|
|
295
498
|
cwd: PREVIEW_DIR,
|
|
296
499
|
stdio: 'inherit',
|
|
297
500
|
env: {
|
|
298
|
-
...
|
|
501
|
+
...safeEnv,
|
|
299
502
|
VITE_DEVLOG_OWNER: owner,
|
|
300
503
|
VITE_DEVLOG_REPO: repo,
|
|
301
504
|
VITE_DEVLOG_BRANCH: branch,
|
|
@@ -305,25 +508,36 @@ async function cmdPreview() {
|
|
|
305
508
|
proc.on('exit', (code) => process.exit(code ?? 0));
|
|
306
509
|
}
|
|
307
510
|
|
|
511
|
+
// ─── help ────────────────────────────────────────────────────────────────────
|
|
308
512
|
function printHelp() {
|
|
309
513
|
console.log(`
|
|
310
|
-
${kleur.bold('@natjswenson/devlog')} — daily dev log generator
|
|
514
|
+
${kleur.bold('@natjswenson/devlog')} v${readPackageVersion()} — daily dev log generator
|
|
311
515
|
|
|
312
516
|
Usage:
|
|
313
|
-
npx @natjswenson/devlog init
|
|
314
|
-
npx @natjswenson/devlog
|
|
315
|
-
npx @natjswenson/devlog
|
|
316
|
-
npx @natjswenson/devlog
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
319
526
|
`);
|
|
320
527
|
}
|
|
321
528
|
|
|
529
|
+
// ─── dispatch ────────────────────────────────────────────────────────────────
|
|
322
530
|
const arg = process.argv[2];
|
|
323
531
|
switch (arg) {
|
|
324
532
|
case 'init':
|
|
325
533
|
cmdInit();
|
|
326
534
|
break;
|
|
535
|
+
case 'add-project':
|
|
536
|
+
cmdAddProject();
|
|
537
|
+
break;
|
|
538
|
+
case 'config':
|
|
539
|
+
cmdConfig();
|
|
540
|
+
break;
|
|
327
541
|
case 'preview':
|
|
328
542
|
cmdPreview();
|
|
329
543
|
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>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@natjswenson/devlog",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Daily dev log generator — Claude Code skill + preview app for publishing git-based dev logs to your site",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nate Swenson",
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
"preview/",
|
|
30
30
|
"examples/",
|
|
31
31
|
"SKILL.md",
|
|
32
|
+
"SECURITY.md",
|
|
33
|
+
"CHANGELOG.md",
|
|
32
34
|
"config.example.json",
|
|
33
35
|
"README.md",
|
|
34
36
|
"LICENSE"
|
|
@@ -36,14 +38,17 @@
|
|
|
36
38
|
"engines": {
|
|
37
39
|
"node": ">=18"
|
|
38
40
|
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"audit": "npm audit --audit-level=moderate"
|
|
43
|
+
},
|
|
39
44
|
"dependencies": {
|
|
40
|
-
"@vitejs/plugin-react": "
|
|
41
|
-
"kleur": "
|
|
42
|
-
"prompts": "
|
|
43
|
-
"react": "
|
|
44
|
-
"react-dom": "
|
|
45
|
-
"react-markdown": "
|
|
46
|
-
"remark-gfm": "
|
|
47
|
-
"vite": "
|
|
45
|
+
"@vitejs/plugin-react": "6.0.1",
|
|
46
|
+
"kleur": "4.1.5",
|
|
47
|
+
"prompts": "2.4.2",
|
|
48
|
+
"react": "18.3.1",
|
|
49
|
+
"react-dom": "18.3.1",
|
|
50
|
+
"react-markdown": "9.1.0",
|
|
51
|
+
"remark-gfm": "4.0.1",
|
|
52
|
+
"vite": "8.0.10"
|
|
48
53
|
}
|
|
49
54
|
}
|
package/preview/App.jsx
CHANGED
|
@@ -6,6 +6,7 @@ const owner = import.meta.env.VITE_DEVLOG_OWNER;
|
|
|
6
6
|
const repo = import.meta.env.VITE_DEVLOG_REPO;
|
|
7
7
|
const branch = import.meta.env.VITE_DEVLOG_BRANCH || 'main';
|
|
8
8
|
const projectsRaw = import.meta.env.VITE_DEVLOG_PROJECTS;
|
|
9
|
+
const isDev = import.meta.env.DEV;
|
|
9
10
|
|
|
10
11
|
const isDemo = !owner || !repo;
|
|
11
12
|
|
|
@@ -60,16 +61,39 @@ function NoProjectsScreen() {
|
|
|
60
61
|
Env vars say you have a repo (<code>{owner}/{repo}</code>) but no projects.
|
|
61
62
|
That's like buying a stage and forgetting to invite a band.
|
|
62
63
|
</p>
|
|
63
|
-
<p>Add
|
|
64
|
+
<p>Add a project the easy way:</p>
|
|
65
|
+
<pre>npx @natjswenson/devlog add-project</pre>
|
|
66
|
+
<p>Or edit your config directly:</p>
|
|
64
67
|
<pre>{`{
|
|
68
|
+
"targetRepo": "${owner}/${repo}",
|
|
69
|
+
"branch": "${branch}",
|
|
65
70
|
"projects": [
|
|
66
|
-
{ "key": "myproject", "path": "...", "remote": "${owner}/myproject" }
|
|
71
|
+
{ "key": "myproject", "label": "My Project", "path": "...", "remote": "${owner}/myproject" }
|
|
67
72
|
]
|
|
68
73
|
}`}</pre>
|
|
74
|
+
<p>Then re-run <code>npx @natjswenson/devlog preview</code>.</p>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Production builds without env vars: show a clear "setup required" screen,
|
|
80
|
+
// not the demo banner with broken fetches (since installDemoFetch is gated to DEV).
|
|
81
|
+
function SetupRequiredScreen() {
|
|
82
|
+
return (
|
|
83
|
+
<div className="empty-screen">
|
|
84
|
+
<h1>Setup required</h1>
|
|
85
|
+
<p>
|
|
86
|
+
This preview was built without the env vars that point it at a dev-log repo.
|
|
87
|
+
Set these in your hosting environment (Vercel/Netlify/Cloudflare/wherever):
|
|
88
|
+
</p>
|
|
89
|
+
<pre>{`VITE_DEVLOG_OWNER=your-github-username
|
|
90
|
+
VITE_DEVLOG_REPO=daily-dev-log
|
|
91
|
+
VITE_DEVLOG_BRANCH=main
|
|
92
|
+
VITE_DEVLOG_PROJECTS=[{"key":"myproject","label":"My Project"}]`}</pre>
|
|
69
93
|
<p>
|
|
70
|
-
|
|
71
|
-
<code> VITE_DEVLOG_PROJECTS</code> directly in <code>preview/.env.local</code>.
|
|
94
|
+
Or, if you're trying this locally, the friendlier path is:
|
|
72
95
|
</p>
|
|
96
|
+
<pre>npx @natjswenson/devlog init && npx @natjswenson/devlog preview</pre>
|
|
73
97
|
</div>
|
|
74
98
|
);
|
|
75
99
|
}
|
|
@@ -77,6 +101,12 @@ function NoProjectsScreen() {
|
|
|
77
101
|
export default function App() {
|
|
78
102
|
const [activeKey, setActiveKey] = useState((projects && projects[0]?.key) || DEMO_PROJECT_KEY);
|
|
79
103
|
|
|
104
|
+
// Production build with no env vars: show actionable setup screen
|
|
105
|
+
// (demo fetch override is DEV-only, so demo entries would 404 here).
|
|
106
|
+
if (isDemo && !isDev) {
|
|
107
|
+
return <SetupRequiredScreen />;
|
|
108
|
+
}
|
|
109
|
+
|
|
80
110
|
if (!isDemo && !projects) {
|
|
81
111
|
return <NoProjectsScreen />;
|
|
82
112
|
}
|
package/preview/index.html
CHANGED
|
@@ -3,6 +3,20 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<!--
|
|
7
|
+
Content-Security-Policy: defense-in-depth for the preview app.
|
|
8
|
+
- 'self' for scripts/styles (no inline JS, no eval)
|
|
9
|
+
- 'unsafe-inline' for styles only — required by Vite's HMR runtime in dev
|
|
10
|
+
and react-markdown's prismjs (none used here, but standard practice)
|
|
11
|
+
- connect to raw.githubusercontent.com for entry fetches; api.github.com
|
|
12
|
+
for any future repo metadata; demo-mode.local for the in-browser fetch
|
|
13
|
+
override that powers demo mode without env vars
|
|
14
|
+
- no objects, no frames, no plugins
|
|
15
|
+
Production build: replace 'unsafe-inline' with hashes/nonces if you
|
|
16
|
+
configure Vite to emit them.
|
|
17
|
+
-->
|
|
18
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://raw.githubusercontent.com https://api.github.com https://demo-mode.local; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'none';" />
|
|
19
|
+
<meta name="referrer" content="no-referrer-when-downgrade" />
|
|
6
20
|
<title>devlog preview</title>
|
|
7
21
|
<style>
|
|
8
22
|
:root {
|