@natjswenson/devlog 0.1.4 → 0.1.6
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/README.md +1 -1
- package/SKILL.md +53 -29
- package/bin/devlog.js +41 -15
- package/examples/react/DevLogPage.jsx +6 -2
- package/examples/react/useDevLogEntries.js +42 -11
- package/package.json +1 -1
- package/preview/App.jsx +12 -1
- package/preview/main.jsx +4 -1
- package/preview/vite.config.js +8 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A Claude Code skill that turns your daily git commits into a published dev log
|
|
|
9
9
|
|
|
10
10
|
## Live example
|
|
11
11
|
|
|
12
|
-
The skill is in production at [natejswenson.
|
|
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.
|
|
13
13
|
|
|
14
14
|
## How it works
|
|
15
15
|
|
package/SKILL.md
CHANGED
|
@@ -47,6 +47,25 @@ If the file does not exist or cannot be parsed, stop and tell the user:
|
|
|
47
47
|
|
|
48
48
|
Validate that `targetRepo`, `gitAuthor`, `githubUser`, and `projects` (non-empty array) are all present. Each project must have `key`, `path`, and `remote`.
|
|
49
49
|
|
|
50
|
+
### Step 0.5: SECURITY — validate config values before using them in shell commands
|
|
51
|
+
|
|
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
|
+
|
|
54
|
+
| Field | Required pattern |
|
|
55
|
+
|---|---|
|
|
56
|
+
| `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
|
+
| `githubUser` | Matches `^[a-zA-Z0-9][a-zA-Z0-9-]*$` |
|
|
60
|
+
| `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 |
|
|
62
|
+
| `projects[].remote` | Same pattern as `targetRepo` |
|
|
63
|
+
|
|
64
|
+
If any field fails validation, stop with:
|
|
65
|
+
> Config field `<field>` failed security validation: `<value>`. Edit `~/.claude/skills/devlog/config.json` and retry.
|
|
66
|
+
|
|
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).
|
|
68
|
+
|
|
50
69
|
## Step 1: Determine scope
|
|
51
70
|
|
|
52
71
|
- If the user passed a project argument (e.g. `/devlog myproject`), filter `projects` to that one. If the key is not in the registry, list available keys and stop.
|
|
@@ -54,10 +73,10 @@ Validate that `targetRepo`, `gitAuthor`, `githubUser`, and `projects` (non-empty
|
|
|
54
73
|
|
|
55
74
|
## Step 2: Gather today's commits
|
|
56
75
|
|
|
57
|
-
For each project in scope, run:
|
|
76
|
+
For each project in scope, run (use `git -C` to avoid `cd` shell-composition):
|
|
58
77
|
|
|
59
78
|
```bash
|
|
60
|
-
|
|
79
|
+
git -C <project.path> log --author=<config.gitAuthor> --since=midnight --format=%H|%s|%D --all
|
|
61
80
|
```
|
|
62
81
|
|
|
63
82
|
If no commits are found for a project, skip it. If no commits are found across all projects, inform the user and stop.
|
|
@@ -67,8 +86,8 @@ If no commits are found for a project, skip it. If no commits are found across a
|
|
|
67
86
|
For each commit, check if it's on the `main` branch and if the remote is public:
|
|
68
87
|
|
|
69
88
|
```bash
|
|
70
|
-
|
|
71
|
-
git branch --contains <hash> -r 2>/dev/null | grep -q 'origin/main'
|
|
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'
|
|
72
91
|
```
|
|
73
92
|
|
|
74
93
|
- 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>`.
|
|
@@ -125,33 +144,38 @@ gh api repos/<config.targetRepo>/contents/<project.key>/YYYY-MM-DD.md --jq '.con
|
|
|
125
144
|
|
|
126
145
|
## Step 6: Push to GitHub
|
|
127
146
|
|
|
128
|
-
Clone the repo once, write all project entries, then push
|
|
147
|
+
Clone the repo once, write all project entries, then push.
|
|
148
|
+
|
|
149
|
+
**Important:** Claude Code's bash tool runs each invocation in a fresh shell — variables don't persist across calls. Use a single temp path you compute once and pass as an absolute path to every subsequent command. Do NOT rely on `$TMPDIR` or any other shell variable surviving between bash calls.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
# Step 6.1: create temp dir, capture absolute path (use this exact path
|
|
153
|
+
# in every subsequent command — do not reference $TMPDIR after this call)
|
|
154
|
+
mktemp -d
|
|
155
|
+
# → record the printed path, e.g. /var/folders/.../tmp.abc123
|
|
156
|
+
|
|
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
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Write entries and manifest using the **Write tool** (not bash heredocs — avoids re-injecting content into shell):
|
|
162
|
+
|
|
163
|
+
- For each project with commits:
|
|
164
|
+
- Path: `<abs-tmp-path>/<repo-name>/<project.key>/YYYY-MM-DD.md`
|
|
165
|
+
- Path: `<abs-tmp-path>/<repo-name>/<project.key>/manifest.json` — read with the Read tool, mutate the entries array (newest first), write back
|
|
166
|
+
- Entry object: `{ "date": "YYYY-MM-DD", "file": "YYYY-MM-DD.md", "title": "...", "summary": "..." }`
|
|
167
|
+
- If appending to existing entry, update title/summary only if changed
|
|
168
|
+
- If manifest doesn't exist, create it as `{ "entries": [...] }`
|
|
169
|
+
|
|
170
|
+
Then commit and push:
|
|
129
171
|
|
|
130
172
|
```bash
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
# For each project with commits:
|
|
138
|
-
# - Create directory if needed: mkdir -p <project.key>/
|
|
139
|
-
# - Write the entry file to <project.key>/YYYY-MM-DD.md
|
|
140
|
-
# - Update <project.key>/manifest.json
|
|
141
|
-
# - Read manifest, add/update entry in entries array (newest first)
|
|
142
|
-
# - Entry object: { "date": "YYYY-MM-DD", "file": "YYYY-MM-DD.md", "title": "...", "summary": "..." }
|
|
143
|
-
# - If appending to existing entry, update title/summary only if changed
|
|
144
|
-
# - If manifest doesn't exist, create it as { "entries": [...] }
|
|
145
|
-
|
|
146
|
-
# Stage all changed project directories
|
|
147
|
-
git add .
|
|
148
|
-
|
|
149
|
-
# Single commit covering all projects
|
|
150
|
-
git commit -m "devlog: add entries for YYYY-MM-DD"
|
|
151
|
-
git push origin <config.branch || 'main'>
|
|
152
|
-
|
|
153
|
-
# Cleanup
|
|
154
|
-
rm -rf "$TMPDIR"
|
|
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'>
|
|
176
|
+
|
|
177
|
+
# Cleanup — pass the absolute path explicitly
|
|
178
|
+
rm -rf <abs-tmp-path>
|
|
155
179
|
```
|
|
156
180
|
|
|
157
181
|
## Step 7: Confirm
|
package/bin/devlog.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawn, execSync } from 'node:child_process';
|
|
2
|
+
import { spawn, spawnSync, execSync } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { dirname, join, resolve, basename } from 'node:path';
|
|
@@ -8,6 +8,8 @@ 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]/;
|
|
12
|
+
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
12
14
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
13
15
|
const SKILL_SRC = join(PACKAGE_ROOT, 'SKILL.md');
|
|
@@ -29,6 +31,8 @@ function readPackageVersion() {
|
|
|
29
31
|
return pkg.version;
|
|
30
32
|
}
|
|
31
33
|
|
|
34
|
+
// Runs a hardcoded shell command (no user input). Use tryExecArgs for any
|
|
35
|
+
// command whose arguments include user-supplied values.
|
|
32
36
|
function tryExec(cmd) {
|
|
33
37
|
try {
|
|
34
38
|
return execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' }).trim();
|
|
@@ -37,6 +41,17 @@ function tryExec(cmd) {
|
|
|
37
41
|
}
|
|
38
42
|
}
|
|
39
43
|
|
|
44
|
+
// argv-style invocation; no shell, so user-supplied args cannot inject.
|
|
45
|
+
function tryExecArgs(cmd, args) {
|
|
46
|
+
try {
|
|
47
|
+
const r = spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
|
|
48
|
+
if (r.status !== 0) return null;
|
|
49
|
+
return (r.stdout || '').trim();
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
function expandHome(p) {
|
|
41
56
|
if (!p) return p;
|
|
42
57
|
if (p === '~') return homedir();
|
|
@@ -76,7 +91,7 @@ function detectGitName() {
|
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
function detectProjectRemote(path) {
|
|
79
|
-
const url =
|
|
94
|
+
const url = tryExecArgs('git', ['-C', path, 'remote', 'get-url', 'origin']);
|
|
80
95
|
if (!url) return null;
|
|
81
96
|
const m = url.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
|
|
82
97
|
return m ? m[1] : null;
|
|
@@ -109,21 +124,25 @@ async function cmdInit() {
|
|
|
109
124
|
name: 'gitAuthor',
|
|
110
125
|
message: 'Your name (used to filter `git log --author`):',
|
|
111
126
|
initial: defaults.gitAuthor,
|
|
112
|
-
validate: (v) =>
|
|
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
|
+
},
|
|
113
132
|
},
|
|
114
133
|
{
|
|
115
134
|
type: 'text',
|
|
116
135
|
name: 'githubUser',
|
|
117
136
|
message: 'Your GitHub username:',
|
|
118
137
|
initial: defaults.githubUser,
|
|
119
|
-
validate: (v) => /^[a-z0-9-]
|
|
138
|
+
validate: (v) => /^[a-z0-9][a-z0-9-]*$/i.test(v.trim()) || 'Invalid username (must start with letter/digit, alphanumeric + hyphens only)',
|
|
120
139
|
},
|
|
121
140
|
{
|
|
122
141
|
type: 'text',
|
|
123
142
|
name: 'targetRepoName',
|
|
124
143
|
message: 'Name of the repo where dev logs will be published:',
|
|
125
144
|
initial: defaults.targetRepoName,
|
|
126
|
-
validate: (v) => /^[a-z0-9._-]
|
|
145
|
+
validate: (v) => /^[a-z0-9][a-z0-9._-]*$/i.test(v.trim()) || 'Invalid repo name (must start with letter/digit, no leading dash)',
|
|
127
146
|
},
|
|
128
147
|
{
|
|
129
148
|
type: 'confirm',
|
|
@@ -143,21 +162,29 @@ async function cmdInit() {
|
|
|
143
162
|
name: 'path',
|
|
144
163
|
message: 'Project absolute path:',
|
|
145
164
|
initial: cwd,
|
|
146
|
-
validate: (v) =>
|
|
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
|
+
},
|
|
147
169
|
},
|
|
148
170
|
{
|
|
149
171
|
type: 'text',
|
|
150
172
|
name: 'key',
|
|
151
173
|
message: 'Project key (used as dev-log subdir name):',
|
|
152
174
|
initial: (prev) => basename(expandHome(prev || cwd)),
|
|
153
|
-
validate: (v) =>
|
|
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
|
+
},
|
|
154
181
|
},
|
|
155
182
|
{
|
|
156
183
|
type: 'text',
|
|
157
184
|
name: 'remote',
|
|
158
185
|
message: 'Project GitHub remote (<owner>/<repo>):',
|
|
159
186
|
initial: (_prev, values) => detectProjectRemote(expandHome(values.path)) || cwdRemote || `${answers.githubUser}/${basename(expandHome(values.path))}`,
|
|
160
|
-
validate: (v) => /^[
|
|
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',
|
|
161
188
|
},
|
|
162
189
|
], { onCancel: () => process.exit(1) });
|
|
163
190
|
}
|
|
@@ -191,20 +218,19 @@ async function cmdInit() {
|
|
|
191
218
|
|
|
192
219
|
log.info('');
|
|
193
220
|
|
|
194
|
-
const repoExists =
|
|
221
|
+
const repoExists = tryExecArgs('gh', ['repo', 'view', targetRepo, '--json', 'name']) !== null;
|
|
195
222
|
if (repoExists) {
|
|
196
223
|
log.warn(`Repo github.com/${targetRepo} already exists. Will use it as-is.`);
|
|
197
224
|
} else {
|
|
198
225
|
log.step(`Creating github.com/${targetRepo}...`);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
log.ok('Repo created');
|
|
204
|
-
} catch {
|
|
226
|
+
const r = spawnSync('gh', ['repo', 'create', targetRepo, '--public', '--description', 'Daily dev log', '--add-readme'], {
|
|
227
|
+
stdio: 'inherit',
|
|
228
|
+
});
|
|
229
|
+
if (r.status !== 0) {
|
|
205
230
|
log.err('Failed to create repo. Check `gh` permissions.');
|
|
206
231
|
process.exit(1);
|
|
207
232
|
}
|
|
233
|
+
log.ok('Repo created');
|
|
208
234
|
}
|
|
209
235
|
|
|
210
236
|
if (!existsSync(CONFIG_DIR)) {
|
|
@@ -8,8 +8,12 @@ import './DevLogPage.css';
|
|
|
8
8
|
const ENTRIES_PER_PAGE = 10;
|
|
9
9
|
|
|
10
10
|
function formatDate(dateStr) {
|
|
11
|
-
|
|
12
|
-
const
|
|
11
|
+
if (typeof dateStr !== 'string') return '';
|
|
12
|
+
const parts = dateStr.split('-');
|
|
13
|
+
if (parts.length !== 3) return '';
|
|
14
|
+
const [year, month, day] = parts;
|
|
15
|
+
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
16
|
+
if (isNaN(date.getTime())) return '';
|
|
13
17
|
return date.toLocaleDateString('en-US', {
|
|
14
18
|
month: 'short',
|
|
15
19
|
day: 'numeric',
|
|
@@ -1,24 +1,44 @@
|
|
|
1
1
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
2
2
|
import { DEVLOG_CONFIG as DEFAULT_CONFIG } from './devlog-config.js';
|
|
3
3
|
|
|
4
|
+
// Allowlist of frontmatter keys we recognize. Anything else is ignored —
|
|
5
|
+
// prevents prototype-pollution via crafted keys like `__proto__`.
|
|
6
|
+
const FRONTMATTER_KEYS = new Set(['title', 'date', 'project', 'summary']);
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Parse YAML-ish frontmatter from a markdown string.
|
|
6
10
|
* Returns { metadata: { title, date, project, summary }, body: string }
|
|
7
11
|
*/
|
|
8
12
|
function parseFrontmatter(raw) {
|
|
9
13
|
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const body = match[2];
|
|
14
|
-
const metadata = {};
|
|
14
|
+
// Use Object.create(null) so the returned object has no prototype chain.
|
|
15
|
+
const metadata = Object.create(null);
|
|
16
|
+
if (!match) return { metadata, body: raw };
|
|
15
17
|
|
|
16
|
-
for (const line of
|
|
18
|
+
for (const line of match[1].split('\n')) {
|
|
17
19
|
const m = line.match(/^(\w+)\s*:\s*"?([^"]*)"?\s*$/);
|
|
18
|
-
if (m) metadata[m[1]] = m[2].trim();
|
|
20
|
+
if (m && FRONTMATTER_KEYS.has(m[1])) metadata[m[1]] = m[2].trim();
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
return { metadata, body };
|
|
23
|
+
return { metadata, body: match[2] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Schema validation for fetched manifest. Reject anything that isn't shaped
|
|
27
|
+
// like { entries: [{ date, file, title, summary }, ...] } so a hostile commit
|
|
28
|
+
// to the dev-log repo can't crash the page.
|
|
29
|
+
function validateManifest(data) {
|
|
30
|
+
if (!data || typeof data !== 'object') return null;
|
|
31
|
+
if (!Array.isArray(data.entries)) return null;
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const e of data.entries) {
|
|
34
|
+
if (!e || typeof e !== 'object') continue;
|
|
35
|
+
const { date, file, title, summary } = e;
|
|
36
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
|
|
37
|
+
if (typeof file !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(file)) continue;
|
|
38
|
+
if (typeof title !== 'string' || typeof summary !== 'string') continue;
|
|
39
|
+
entries.push({ date, file, title, summary });
|
|
40
|
+
}
|
|
41
|
+
return { entries };
|
|
22
42
|
}
|
|
23
43
|
|
|
24
44
|
/**
|
|
@@ -51,7 +71,9 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
51
71
|
setError(null);
|
|
52
72
|
|
|
53
73
|
try {
|
|
54
|
-
|
|
74
|
+
// Encode project key to neutralize any path-traversal characters
|
|
75
|
+
// (the project key is allowlisted upstream, but defense-in-depth).
|
|
76
|
+
const url = `${config.baseUrl}/${encodeURIComponent(project)}/manifest.json`;
|
|
55
77
|
const res = await fetch(url);
|
|
56
78
|
if (!res.ok) {
|
|
57
79
|
if (res.status === 404) {
|
|
@@ -61,7 +83,9 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
61
83
|
throw new Error(`Failed to fetch manifest (${res.status})`);
|
|
62
84
|
}
|
|
63
85
|
const data = await res.json();
|
|
64
|
-
|
|
86
|
+
const validated = validateManifest(data);
|
|
87
|
+
if (!validated) throw new Error('Manifest failed schema validation');
|
|
88
|
+
setEntries(validated.entries);
|
|
65
89
|
} catch (err) {
|
|
66
90
|
setError(err.message);
|
|
67
91
|
} finally {
|
|
@@ -76,8 +100,15 @@ export function useDevLogEntries(project, configOverride) {
|
|
|
76
100
|
const fetchEntryContent = useCallback(async (filename) => {
|
|
77
101
|
if (contentCache.current.has(filename)) return;
|
|
78
102
|
|
|
103
|
+
// Final filename gate (manifest validation already enforces the same
|
|
104
|
+
// pattern — keep this here so any consumer calling fetchEntryContent
|
|
105
|
+
// directly is also protected).
|
|
106
|
+
if (typeof filename !== 'string' || !/^[a-zA-Z0-9._-]+\.md$/.test(filename)) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
79
110
|
try {
|
|
80
|
-
const url = `${config.baseUrl}/${project}/${filename}`;
|
|
111
|
+
const url = `${config.baseUrl}/${encodeURIComponent(project)}/${filename}`;
|
|
81
112
|
const res = await fetch(url);
|
|
82
113
|
if (!res.ok) throw new Error(`Failed to fetch entry (${res.status})`);
|
|
83
114
|
const raw = await res.text();
|
package/package.json
CHANGED
package/preview/App.jsx
CHANGED
|
@@ -11,7 +11,18 @@ const isDemo = !owner || !repo;
|
|
|
11
11
|
|
|
12
12
|
let realProjects = [];
|
|
13
13
|
try {
|
|
14
|
-
|
|
14
|
+
const parsed = projectsRaw ? JSON.parse(projectsRaw) : [];
|
|
15
|
+
// Schema-validate: must be array of {key, label?} where key matches a safe
|
|
16
|
+
// allowlist. Anything else gets dropped silently.
|
|
17
|
+
if (Array.isArray(parsed)) {
|
|
18
|
+
for (const p of parsed) {
|
|
19
|
+
if (!p || typeof p !== 'object') continue;
|
|
20
|
+
if (typeof p.key !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(p.key)) continue;
|
|
21
|
+
if (p.key.includes('..')) continue;
|
|
22
|
+
const label = typeof p.label === 'string' ? p.label : p.key;
|
|
23
|
+
realProjects.push({ key: p.key, label });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
15
26
|
} catch {
|
|
16
27
|
realProjects = [];
|
|
17
28
|
}
|
package/preview/main.jsx
CHANGED
|
@@ -4,7 +4,10 @@ import App from './App.jsx';
|
|
|
4
4
|
import { installDemoFetch } from './demo.js';
|
|
5
5
|
import './preview.css';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Gate the global window.fetch override behind DEV. In production builds
|
|
8
|
+
// (e.g. when adopters deploy the preview directory standalone), demo content
|
|
9
|
+
// must NOT silently intercept fetches — show the empty-state UX instead.
|
|
10
|
+
if (import.meta.env.DEV && (!import.meta.env.VITE_DEVLOG_OWNER || !import.meta.env.VITE_DEVLOG_REPO)) {
|
|
8
11
|
installDemoFetch();
|
|
9
12
|
}
|
|
10
13
|
|
package/preview/vite.config.js
CHANGED
|
@@ -19,8 +19,16 @@ export default defineConfig({
|
|
|
19
19
|
'style-to-js',
|
|
20
20
|
],
|
|
21
21
|
},
|
|
22
|
+
// Bind preview server to localhost only — the dev server has no auth and
|
|
23
|
+
// serves transformed source modules. Don't expose it to LAN by default.
|
|
24
|
+
// (npm audit flags moderate CVEs in dev-server CORS handling for any
|
|
25
|
+
// deployment that allows cross-origin reads; localhost-binding is
|
|
26
|
+
// defense-in-depth on top of vite's own patches.)
|
|
22
27
|
server: {
|
|
28
|
+
host: 'localhost',
|
|
23
29
|
port: 5173,
|
|
24
30
|
open: true,
|
|
31
|
+
strictPort: false,
|
|
32
|
+
cors: false,
|
|
25
33
|
},
|
|
26
34
|
});
|