@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 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
  [![npm](https://img.shields.io/npm/v/@natjswenson/devlog?color=blue)](https://www.npmjs.com/package/@natjswenson/devlog)
4
4
  [![license](https://img.shields.io/npm/l/@natjswenson/devlog)](./LICENSE)
5
+ [![security](https://img.shields.io/badge/security-audited-green)](./SECURITY.md)
6
+ [![vulnerabilities](https://img.shields.io/badge/npm%20audit-0%20issues-brightgreen)](#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 entries 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.
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
- to see your dev log rendered locally.
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 command
48
- └── config.json # Your settings (target repo, projects, etc)
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 `init`, populated by /devlog
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'd rather not use the CLI)
76
+ ## Manual setup (if you prefer)
59
77
 
60
- 1. **Create your dev-log repo:**
61
- ```sh
62
- gh repo create <you>/daily-dev-log --public --add-readme
63
- ```
64
- 2. **Install the skill:**
65
- ```sh
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 mount:
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/`. Done — you have a public dev log at your own URL. See [`preview/README.md`](./preview/README.md).
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, anything)
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>/main/`:
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. **You create this — `init` does it for you, or `gh repo create` manually.** |
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 (for constructing commit links). |
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 project tab in the UI. Defaults to `key`. |
154
- | `projects[].path` | string | Local filesystem path to the project. |
155
- | `projects[].remote` | `"<owner>/<repo>"` | The project's GitHub remote, used to mark public commits and link them. |
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
- See [`config.example.json`](./config.example.json) for a complete template.
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. Or build your own UI against the data contract above.
163
- - **Add more projects:** edit `~/.claude/skills/devlog/config.json` directly.
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 probably inside a checkout of this repo. The local `package.json` collides with the published name. Run `npx` from anywhere else (e.g. `cd ~ && npx @natjswenson/devlog ...`).
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
- **Preview is blank / no tabs / no entries:** check the browser console. If you see *"does not provide an export named …"* errors related to `react-dom/client`, `style-to-js`, or `react-markdown`, you're on a pre-0.1.3 release upgrade with `npm cache clean --force && rm -rf ~/.npm/_npx && npx --yes @natjswenson/devlog@latest preview`.
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
- **`npm publish` (if you fork/republish your own copy) fails with 403:** npm requires 2FA or a granular access token with bypass-2FA enabled for write operations. Easiest path: enable 2FA, install an authenticator app, and pass `--otp=<code>` to `npm publish`.
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
- Stable: see [npm](https://www.npmjs.com/package/@natjswenson/devlog). The package follows semver — bug fixes in patch releases (0.1.x), behavior changes in minor (0.x.0).
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: `;` `&` `|` `` ` `` `$` `(` `)` `<` `>` `{` `}` `*` `?` `!` `#` `~` `"` `\` newline, carriage return |
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 metacharacters (same set as gitAuthor) AND must point to an existing directory |
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=%H|%s|%D --all
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
- git -C <abs-tmp-path> clone --depth=1 https://github.com/<config.targetRepo>.git
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 "devlog: add entries for YYYY-MM-DD"
175
- git -C <abs-tmp-path>/<repo-name> push origin <config.branch || 'main'>
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, pathToFileURL } from 'node:url';
6
+ import { fileURLToPath } from 'node:url';
7
7
  import { createRequire } from 'node:module';
8
8
  import prompts from 'prompts';
9
9
  import kleur from 'kleur';
10
10
 
11
- const SHELL_METACHARS = /[;&|`$()<>{}*?!#~"\\\n\r]/;
11
+ // ─── shared validators (single source of truth, also used by SKILL.md guidance) ───
12
+ //
13
+ // SHELL_QUOTE_BREAK matches characters that can break out of a single-quoted
14
+ // shell string OR are dangerous if quoting is omitted. The skill instructs the
15
+ // LLM to single-quote every interpolated value; rejecting these chars upstream
16
+ // guarantees that single-quoting is sufficient. Whitespace, dots, hyphens,
17
+ // equals, and similar are NOT rejected — they're literal inside '...' and are
18
+ // legitimate in human-readable fields like names and paths.
19
+ //
20
+ // For strict-token fields (project keys, repo names, branch names), separate
21
+ // allowlist regexes apply additional structural constraints.
22
+ const SHELL_QUOTE_BREAK = /[;&|`$()<>{}[\]*?!#~"'\\\n\r]/;
23
+ const RE_GH_USER = /^[a-z0-9][a-z0-9-]*$/i;
24
+ const RE_REPO_NAME = /^[a-z0-9][a-z0-9._-]*$/i;
25
+ const RE_OWNER_REPO = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
26
+ const RE_PROJECT_KEY = /^[a-z0-9][a-z0-9._-]*$/i;
27
+ const RE_BRANCH = /^[a-z0-9][a-z0-9._/-]*$/i;
28
+ const FORBIDDEN_BRANCH_PARTS = /(^|\/)\.\.($|\/)/; // reject `..` as a path component
12
29
 
13
30
  const require = createRequire(import.meta.url);
14
31
  const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -24,6 +41,7 @@ const log = {
24
41
  warn: (msg) => console.log(kleur.yellow('! ') + msg),
25
42
  err: (msg) => console.error(kleur.red('✗ ') + msg),
26
43
  step: (msg) => console.log(kleur.cyan('→ ') + msg),
44
+ hint: (msg) => console.log(kleur.dim(' ' + msg)),
27
45
  };
28
46
 
29
47
  function readPackageVersion() {
@@ -31,8 +49,7 @@ function readPackageVersion() {
31
49
  return pkg.version;
32
50
  }
33
51
 
34
- // Runs a hardcoded shell command (no user input). Use tryExecArgs for any
35
- // command whose arguments include user-supplied values.
52
+ // Hardcoded shell command, no user input. Use tryExecArgs for anything user-supplied.
36
53
  function tryExec(cmd) {
37
54
  try {
38
55
  return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
@@ -59,31 +76,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.info('Install: https://cli.github.com/');
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.info('Run: gh auth login');
157
+ log.hint('Run: gh auth login');
80
158
  process.exit(1);
81
159
  }
82
160
  }
83
161
 
84
162
  function detectGhUser() {
85
- const out = tryExec('gh api user --jq .login');
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
- async function cmdInit() {
112
- log.info(kleur.bold('\ndevlog setup\n'));
113
- await preflight();
188
+ // ─── prompt validators (reused across init and add-project) ──────────────────
189
+ const VALIDATORS = {
190
+ gitAuthor: (v) => {
191
+ if (v.trim().length === 0) return 'Required';
192
+ if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
193
+ return true;
194
+ },
195
+ githubUser: (v) => RE_GH_USER.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
196
+ targetRepoName: (v) => RE_REPO_NAME.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
197
+ path: (v) => {
198
+ if (SHELL_QUOTE_BREAK.test(v)) return 'Invalid characters (no quotes, backticks, dollar signs, semicolons, parens, or shell metacharacters)';
199
+ if (v.trim().startsWith('-')) return 'Path cannot start with a dash';
200
+ return existsSync(expandHome(v)) || 'Path does not exist';
201
+ },
202
+ projectKey: (v) => {
203
+ const t = v.trim();
204
+ if (!RE_PROJECT_KEY.test(t)) return 'Invalid key (must start with letter/digit, alphanumeric + ._- only)';
205
+ if (t.includes('..')) return 'Invalid key (no `..`)';
206
+ return true;
207
+ },
208
+ ownerRepo: (v) => RE_OWNER_REPO.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash, alphanumeric + ._- only',
209
+ label: (v) => {
210
+ if (typeof v === 'string' && SHELL_QUOTE_BREAK.test(v)) return 'Label has shell metacharacters (cosmetic field, but kept clean defensively)';
211
+ return true;
212
+ },
213
+ };
114
214
 
115
- const defaults = {
116
- gitAuthor: detectGitName() || '',
117
- githubUser: detectGhUser() || '',
118
- targetRepoName: 'daily-dev-log',
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: 'gitAuthor',
125
- message: 'Your name (used to filter `git log --author`):',
126
- initial: defaults.gitAuthor,
127
- validate: (v) => {
128
- if (v.trim().length === 0) return 'Required';
129
- if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
130
- return true;
131
- },
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: 'githubUser',
136
- message: 'Your GitHub username:',
137
- initial: defaults.githubUser,
138
- validate: (v) => /^[a-z0-9][a-z0-9-]*$/i.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
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: 'targetRepoName',
143
- message: 'Name of the repo where dev logs will be published:',
144
- initial: defaults.targetRepoName,
145
- validate: (v) => /^[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
238
+ name: 'label',
239
+ message: 'Project display label (optional, defaults to key):',
240
+ initial: '',
241
+ validate: VALIDATORS.label,
146
242
  },
147
243
  {
148
- type: 'confirm',
149
- name: 'registerProject',
150
- message: 'Register a project now? (you can add more later by editing config.json)',
151
- initial: true,
244
+ type: 'text',
245
+ name: 'remote',
246
+ message: 'Project GitHub remote (<owner>/<repo>):',
247
+ initial: (_p, values) => detectProjectRemote(expandHome(values.path)) || initialRemote,
248
+ validate: VALIDATORS.ownerRepo,
152
249
  },
153
250
  ], { onCancel: () => process.exit(1) });
154
251
 
155
- let projectAnswers = null;
156
- if (answers.registerProject) {
157
- const cwd = process.cwd();
158
- const cwdRemote = detectProjectRemote(cwd);
159
- projectAnswers = await prompts([
160
- {
161
- type: 'text',
162
- name: 'path',
163
- message: 'Project absolute path:',
164
- initial: cwd,
165
- validate: (v) => {
166
- if (SHELL_METACHARS.test(v)) return 'Invalid characters (no quotes, backticks, or shell metacharacters)';
167
- return existsSync(expandHome(v)) || 'Path does not exist';
168
- },
169
- },
170
- {
171
- type: 'text',
172
- name: 'key',
173
- message: 'Project key (used as dev-log subdir name):',
174
- initial: (prev) => basename(expandHome(prev || cwd)),
175
- validate: (v) => {
176
- const t = v.trim();
177
- if (!/^[a-z0-9][a-z0-9._-]*$/i.test(t)) return 'Invalid key (must start with letter/digit, alphanumeric + ._- only)';
178
- if (t.includes('..')) return 'Invalid key (no `..`)';
179
- return true;
180
- },
181
- },
182
- {
183
- type: 'text',
184
- name: 'remote',
185
- message: 'Project GitHub remote (<owner>/<repo>):',
186
- initial: (_prev, values) => detectProjectRemote(expandHome(values.path)) || cwdRemote || `${answers.githubUser}/${basename(expandHome(values.path))}`,
187
- validate: (v) => /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Expected <owner>/<repo>, no leading dash',
188
- },
189
- ], { onCancel: () => process.exit(1) });
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: projectAnswers ? [{
198
- key: projectAnswers.key,
199
- path: expandHome(projectAnswers.path),
200
- remote: projectAnswers.remote,
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(` Projects: ${config.projects.length === 0 ? '(none — add later)' : config.projects.map(p => p.key).join(', ')}`);
321
+ log.info(` Branch: ${config.branch}`);
322
+ log.info(` Projects: ${config.projects.length === 0 ? '(none — add later with `devlog add-project`)' : config.projects.map((p) => p.key).join(', ')}`);
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
- writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n');
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('Done.') + '\n');
362
+ log.info('\n' + kleur.bold().green('Setup complete.') + '\n');
256
363
  log.info('Next steps:');
257
- log.info(` 1. ${config.projects.length === 0 ? 'Edit config.json to register projects' : '(Optional) edit config.json to register more projects'}`);
258
- log.info(' 2. Make some commits in a registered project');
259
- log.info(' 3. In Claude Code, run: /devlog');
260
- log.info(' 4. Preview locally: npx @natjswenson/devlog preview');
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
- async function cmdPreview() {
375
+ // ─── add-project ─────────────────────────────────────────────────────────────
376
+ async function cmdAddProject() {
377
+ log.info(kleur.bold('\ndevlog add-project\n'));
378
+ if (!existsSync(CONFIG_PATH)) {
379
+ log.err(`No config found at ${CONFIG_PATH}`);
380
+ log.hint('Run `npx @natjswenson/devlog init` first.');
381
+ process.exit(1);
382
+ }
383
+
384
+ let config;
385
+ try {
386
+ config = readConfig();
387
+ validateConfig(config);
388
+ } catch (e) {
389
+ log.err(`Existing config is invalid: ${e.message}`);
390
+ log.hint(`Edit ${CONFIG_PATH} or run \`devlog init\` to recreate.`);
391
+ process.exit(1);
392
+ }
393
+
394
+ if (config.projects.length > 0) {
395
+ log.info(kleur.dim('Currently registered projects:'));
396
+ for (const p of config.projects) log.info(kleur.dim(` - ${p.key} (${p.path})`));
397
+ log.info('');
398
+ }
399
+
400
+ const newProject = await promptForProject();
401
+ if (config.projects.find((p) => p.key === newProject.key)) {
402
+ log.err(`Project key "${newProject.key}" is already registered.`);
403
+ log.hint('Pick a different key, or remove the existing entry first.');
404
+ process.exit(1);
405
+ }
406
+
407
+ const newConfig = validateConfig({ ...config, projects: [...config.projects, newProject] });
408
+ atomicWriteJSON(CONFIG_PATH, newConfig);
409
+ log.ok(`Added "${newProject.key}" to config.`);
410
+ log.info('');
411
+ log.info(`Run ${kleur.cyan('/devlog ' + newProject.key)} in Claude Code to publish an entry for this project.`);
412
+ log.info('');
413
+ }
414
+
415
+ // ─── config (view) ───────────────────────────────────────────────────────────
416
+ async function cmdConfig() {
265
417
  if (!existsSync(CONFIG_PATH)) {
266
418
  log.err(`No config found at ${CONFIG_PATH}`);
267
- log.info('Run `npx @natjswenson/devlog init` first.');
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 = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
425
+ config = readConfig();
274
426
  } catch (e) {
275
- log.err(`Failed to parse config: ${e.message}`);
427
+ log.err(`Failed to read config: ${e.message}`);
276
428
  process.exit(1);
277
429
  }
278
430
 
279
- const [owner, repo] = (config.targetRepo || '').split('/');
280
- if (!owner || !repo) {
281
- log.err('config.targetRepo is not in <owner>/<repo> format');
431
+ let validationStatus;
432
+ try {
433
+ validateConfig(config);
434
+ validationStatus = kleur.green('valid');
435
+ } catch (e) {
436
+ validationStatus = kleur.red('INVALID — ' + e.message);
437
+ }
438
+
439
+ log.info('');
440
+ log.info(kleur.bold(`Config: ${CONFIG_PATH}`));
441
+ log.info(`Status: ${validationStatus}`);
442
+ log.info(`Target repo: ${kleur.cyan(`github.com/${config.targetRepo || '?'}`)}`);
443
+ log.info(`Branch: ${config.branch || 'main'}`);
444
+ log.info(`Git author: ${config.gitAuthor || '?'}`);
445
+ log.info(`GitHub user: ${config.githubUser || '?'}`);
446
+ log.info(`Projects (${(config.projects || []).length}):`);
447
+ for (const p of config.projects || []) {
448
+ log.info(` ${kleur.cyan(p.key)}${p.label ? ` (${p.label})` : ''}`);
449
+ log.info(kleur.dim(` path: ${p.path}`));
450
+ log.info(kleur.dim(` remote: github.com/${p.remote}`));
451
+ }
452
+ log.info('');
453
+ }
454
+
455
+ // ─── preview ─────────────────────────────────────────────────────────────────
456
+ async function cmdPreview() {
457
+ if (!existsSync(CONFIG_PATH)) {
458
+ log.err(`No config found at ${CONFIG_PATH}`);
459
+ log.hint('Run `npx @natjswenson/devlog init` first.');
282
460
  process.exit(1);
283
461
  }
284
462
 
285
- const projects = (config.projects || []).map((p) => ({ key: p.key, label: p.label || p.key }));
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
- ...process.env,
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 Set up the skill, create your dev-log repo, write config
314
- npx @natjswenson/devlog preview Run a local preview of your published dev log
315
- npx @natjswenson/devlog --help
316
- npx @natjswenson/devlog --version
317
-
318
- Docs: https://github.com/natejswenson/devlog
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 remarkPlugins={[remarkGfm]}>
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.6",
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": "^4.3.0",
41
- "kleur": "^4.1.5",
42
- "prompts": "^2.4.2",
43
- "react": "^18.3.0",
44
- "react-dom": "^18.3.0",
45
- "react-markdown": "^9.0.0",
46
- "remark-gfm": "^4.0.0",
47
- "vite": "^5.4.0"
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 at least one project to your config:</p>
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
- Then run <code>npx @natjswenson/devlog preview</code> again, or set
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 &amp;&amp; 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
  }
@@ -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 {