pantheon-opencode 1.3.2 → 1.3.4
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 +154 -1
- package/bin/pantheon-init.mjs +160 -120
- package/opencode.json +0 -1
- package/package.json +10 -5
- package/scripts/cost.mjs +69 -0
- package/scripts/doctor.mjs +56 -22
- package/scripts/hooks/on-subagent-delegation-stop.sh +22 -5
- package/scripts/install/opencode.mjs +132 -32
- package/scripts/install/plugin.mjs +112 -28
- package/scripts/install-mcp.mjs +1 -1
- package/scripts/redaction-gate.mjs +69 -0
- package/scripts/release-notes.mjs +317 -0
- package/scripts/test-context-compression.sh +5 -2
- package/scripts/validate-package.mjs +56 -0
- package/scripts/versioning.mjs +117 -18
- package/src/agents/zeus.md +80 -0
- package/src/pantheon/background-job-board.ts +115 -40
- package/src/pantheon/chat-reminders.ts +79 -0
- package/src/pantheon/compaction-assert.ts +160 -0
- package/src/pantheon/cost-command.ts +208 -0
- package/src/pantheon/cost-tracker.ts +108 -0
- package/src/pantheon/delegation-compaction.ts +237 -0
- package/src/pantheon/delegation-enforce.ts +144 -0
- package/src/pantheon/delegation-finalize.ts +277 -0
- package/src/pantheon/delegation-notify.ts +100 -0
- package/src/pantheon/delegation.ts +690 -0
- package/src/pantheon/dispatch-guard.ts +107 -0
- package/src/pantheon/file-persistence.ts +10 -6
- package/src/pantheon/goal-loop.ts +300 -0
- package/src/pantheon/goal-store.ts +97 -0
- package/src/pantheon/hashline/core.ts +91 -0
- package/src/pantheon/hashline/read-enhancer.ts +66 -0
- package/src/pantheon/hashline/tool.ts +260 -0
- package/src/pantheon/hashline/xxhash.ts +53 -0
- package/src/pantheon/idle-continuation.ts +54 -0
- package/src/pantheon/logger.ts +117 -0
- package/src/pantheon/plugin-once.ts +76 -0
- package/src/pantheon/preemptive-compact.ts +76 -0
- package/src/pantheon/presets.d.mts +31 -0
- package/src/pantheon/presets.mjs +105 -3
- package/src/pantheon/todo-enforcer.ts +421 -0
- package/src/pantheon/todo-preserve.ts +295 -0
- package/src/plugin.ts +406 -13
- package/src/plugins/pantheon-hooks.ts +52 -30
- package/src/plugins/tui/dist/server.d.ts +17 -0
- package/src/plugins/tui/dist/server.d.ts.map +1 -0
- package/src/plugins/tui/dist/server.js +20 -0
- package/src/plugins/tui/dist/server.js.map +1 -0
- package/src/plugins/tui/dist/tui.d.ts +303 -1
- package/src/plugins/tui/dist/tui.d.ts.map +1 -1
- package/src/plugins/tui/dist/tui.js +977 -350
- package/src/plugins/tui/dist/tui.tsx +1427 -150
- package/src/plugins/tui/package.json +4 -3
- package/src/plugins/tui/src/index.tsx +1427 -150
- package/src/plugins/tui/src/server.ts +15 -0
- package/src/plugins/tui/tsdown.config.ts +1 -1
- package/src/routing.yml +35 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/** Fail closed when validation artifacts contain callback URLs or credentials. */
|
|
4
|
+
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { basename, join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const callbackUrl = /https?:\/\/[^\s"'<>]+(?:callback|redirect|return|webhook)[^\s"'<>]*/iu
|
|
10
|
+
const bearer = /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}\b/u
|
|
11
|
+
const jwt = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/u
|
|
12
|
+
const apiSecret =
|
|
13
|
+
/\b(?:api[_-]?key|token|secret|password)\s*[=:]\s*["'](?!\$\{|process\.env|os\.getenv|env:|<redacted>|\[redacted\]|fixture)[^"']{12,}["']/iu
|
|
14
|
+
|
|
15
|
+
function isText(buffer) {
|
|
16
|
+
return !buffer.subarray(0, Math.min(buffer.length, 4096)).includes(0)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findings(text) {
|
|
20
|
+
const result = []
|
|
21
|
+
if (callbackUrl.test(text)) result.push('callback URL')
|
|
22
|
+
if (bearer.test(text) || jwt.test(text) || apiSecret.test(text)) result.push('credential/token')
|
|
23
|
+
return result
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scanFile(path) {
|
|
27
|
+
if (!existsSync(path) || !lstatSync(path).isFile()) return []
|
|
28
|
+
const content = readFileSync(path)
|
|
29
|
+
return isText(content) ? findings(content.toString('utf8')) : []
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function scanTarball(path) {
|
|
33
|
+
const entries = execFileSync('tar', ['-tzf', path], { encoding: 'utf8' }).split('\n').filter(Boolean)
|
|
34
|
+
const result = []
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const content = execFileSync('tar', ['-xOzf', path, entry])
|
|
37
|
+
if (isText(content)) result.push(...findings(content.toString('utf8')))
|
|
38
|
+
}
|
|
39
|
+
return [...new Set(result)]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function scanPath(path) {
|
|
43
|
+
if (path.endsWith('.tgz') || path.endsWith('.tar.gz')) return scanTarball(path)
|
|
44
|
+
if (!existsSync(path)) throw new Error(`path does not exist: ${path}`)
|
|
45
|
+
if (lstatSync(path).isDirectory()) {
|
|
46
|
+
return readdirSync(path, { withFileTypes: true }).flatMap((entry) =>
|
|
47
|
+
scanPath(join(path, entry.name)),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
return scanFile(path)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const paths = process.argv.slice(2)
|
|
54
|
+
if (paths.length === 0) {
|
|
55
|
+
console.error(`Usage: ${basename(process.argv[1])} <file|directory|tarball> [...]`)
|
|
56
|
+
process.exit(2)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const hits = [...new Set(paths.flatMap(scanPath))]
|
|
61
|
+
if (hits.length > 0) {
|
|
62
|
+
for (const hit of hits) console.error(`[REDACTION GATE] ${hit} detected`)
|
|
63
|
+
process.exit(1)
|
|
64
|
+
}
|
|
65
|
+
console.log('Redaction gate passed: no callback URLs or credentials detected.')
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`[REDACTION GATE] failed to inspect input: ${error.message}`)
|
|
68
|
+
process.exit(2)
|
|
69
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* release-notes.mjs — generate release notes from conventional commits
|
|
4
|
+
*
|
|
5
|
+
* Bridges the conventional-commit pipeline (commitlint: 12 types / 40 scopes)
|
|
6
|
+
* to the CHANGELOG / release-body content. Reads `git log <lastTag>..HEAD`
|
|
7
|
+
* (subject + body per commit), groups commits by type, and prints markdown
|
|
8
|
+
* ready to paste into the [Unreleased] CHANGELOG section.
|
|
9
|
+
*
|
|
10
|
+
* Groups (section order — user-facing first):
|
|
11
|
+
* 🆕 What's New feat | perf | docs
|
|
12
|
+
* (docs are user-facing — no dedicated group)
|
|
13
|
+
* 🐞 Fixed fix | security
|
|
14
|
+
* ⚠️ Known Issues manual — CLI flag `--known-issues "text"` (repeatable);
|
|
15
|
+
* the group is OMITTED when the flag is absent (never
|
|
16
|
+
* emitted empty)
|
|
17
|
+
* ✅ Closed Issues issue refs parsed from commit BODIES (%b):
|
|
18
|
+
* `Closes #N` / `Fixes #N` / `Resolves #N` — one
|
|
19
|
+
* bullet `- #N - <subject>` per referenced issue
|
|
20
|
+
* (deduped); the group is OMITTED when no refs exist
|
|
21
|
+
*
|
|
22
|
+
* Omitted (internal, never user-facing): chore | refactor | test | ci |
|
|
23
|
+
* build | style | revert (unknown conventional types too).
|
|
24
|
+
*
|
|
25
|
+
* Breaking: `BREAKING CHANGE` footer or `type!:` / `type(scope)!:` — the
|
|
26
|
+
* commit keeps its type bucket and the bullet is prefixed with 💥
|
|
27
|
+
* (e.g. `- 💥 **scope** — subject`). No dedicated Breaking group.
|
|
28
|
+
*
|
|
29
|
+
* Skipped: merge commits ("Merge ..."), empty-subject commits (trivial
|
|
30
|
+
* chores), and non-conventional subjects (grandfathered history only).
|
|
31
|
+
*
|
|
32
|
+
* Bullet format:
|
|
33
|
+
* - **<scope>** — <subject without the type(scope): prefix> (scope present)
|
|
34
|
+
* - **<subject>** (no scope)
|
|
35
|
+
*
|
|
36
|
+
* Usage:
|
|
37
|
+
* node scripts/release-notes.mjs # lastTag..HEAD
|
|
38
|
+
* node scripts/release-notes.mjs --draft # last 30 commits
|
|
39
|
+
* node scripts/release-notes.mjs --known-issues "widget API is unstable"
|
|
40
|
+
* # manual ⚠️ entry
|
|
41
|
+
*
|
|
42
|
+
* Exit code is 0 whenever output can be generated (empty output means no
|
|
43
|
+
* groupable commits since the range). stdout is pure markdown — diagnostics
|
|
44
|
+
* go to stderr.
|
|
45
|
+
*
|
|
46
|
+
* getLastTag() intentionally duplicates scripts/versioning.mjs getLatestTag()
|
|
47
|
+
* (that module is a CLI, not importable). Keep both in sync when changing
|
|
48
|
+
* the stable-tag filter.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { execSync } from 'node:child_process'
|
|
52
|
+
import { dirname, join } from 'node:path'
|
|
53
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
54
|
+
|
|
55
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
56
|
+
|
|
57
|
+
/** Commit window used by --draft (and as fallback when no stable tag exists). */
|
|
58
|
+
export const DRAFT_RANGE = 30
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Git helpers
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
function run(cmd) {
|
|
65
|
+
try {
|
|
66
|
+
return execSync(cmd, { cwd: ROOT, encoding: 'utf-8' }).trim()
|
|
67
|
+
} catch {
|
|
68
|
+
return ''
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Latest stable tag: strict vX.Y.Z with NO pre-release suffix. The loose
|
|
74
|
+
* glob `v*` also matches v1.2.0-beta.9.<sha>; pre-release tags must never
|
|
75
|
+
* poison the version gate or the notes range, so filter strictly.
|
|
76
|
+
*/
|
|
77
|
+
export function getLastTag() {
|
|
78
|
+
const tag = run("git tag -l 'v*' | grep -E '^v[0-9]+\\.[0-9]+\\.[0-9]+$' | sort -V | tail -1")
|
|
79
|
+
return tag || 'v0.0.0'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function tagExists(tag) {
|
|
83
|
+
return run(`git rev-parse --verify --quiet ${tag}`) !== ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Collect `subject|body` records for a git range.
|
|
88
|
+
*
|
|
89
|
+
* Records are separated with %x1e because %b (raw body) may contain
|
|
90
|
+
* newlines; each record keeps its multi-line body intact. The `|` inside a
|
|
91
|
+
* body is preserved (parsing splits on the FIRST `|`, right after %s).
|
|
92
|
+
*
|
|
93
|
+
* @param {{since?: string|null, draft?: boolean}} [opts]
|
|
94
|
+
* since — lower-bound tag for the range (default null)
|
|
95
|
+
* draft — use the last DRAFT_RANGE commits instead of a tag range
|
|
96
|
+
* @returns {string[]} raw "subject|body" records
|
|
97
|
+
*/
|
|
98
|
+
export function collectEntries({ since = null, draft = false } = {}) {
|
|
99
|
+
// Single-quoted: execSync goes through a shell, and the `|` inside the
|
|
100
|
+
// format string is a literal separator (subject|body), not a shell pipe.
|
|
101
|
+
const fmt = "'--format=%x1e%s|%b'"
|
|
102
|
+
// Without a valid `since` we cannot build a tag range — fall back to the
|
|
103
|
+
// draft window (also covers --draft, which must not read tags at all).
|
|
104
|
+
const range = draft || !since ? `-n ${DRAFT_RANGE}` : `${since}..HEAD`
|
|
105
|
+
const out = run(`git log ${range} ${fmt}`)
|
|
106
|
+
if (!out) return []
|
|
107
|
+
return out
|
|
108
|
+
.split('\x1e')
|
|
109
|
+
.map((record) => record.trim())
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Parsing / grouping (pure functions, tested in tests/release-notes.test.mjs)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse issue references from a commit body: `Closes #N`, `Fixes #N` or
|
|
119
|
+
* `Resolves #N` (case-insensitive). Returns the numbers in order of
|
|
120
|
+
* appearance; empty when the body references nothing.
|
|
121
|
+
*
|
|
122
|
+
* @param {string} body - the raw commit body (may be '')
|
|
123
|
+
* @returns {number[]}
|
|
124
|
+
*/
|
|
125
|
+
function parseIssueRefs(body) {
|
|
126
|
+
return [...body.matchAll(/\b(?:Closes|Fixes|Resolves)\s+#(\d+)\b/gi)].map((m) => Number(m[1]))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Parse one "subject|body" record into a conventional-commit entry.
|
|
131
|
+
* Returns null for records that must be skipped: merge commits ("Merge ..."),
|
|
132
|
+
* non-conventional subjects, and empty-subject (trivial) commits.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} line
|
|
135
|
+
* @returns {{type: string, scope: string|null, subject: string,
|
|
136
|
+
* breaking: boolean, refs: number[]}|null}
|
|
137
|
+
*/
|
|
138
|
+
export function parseCommitLine(line) {
|
|
139
|
+
const [subjectPart, ...bodyParts] = line.split('|')
|
|
140
|
+
const subject = subjectPart.trim()
|
|
141
|
+
if (!subject || /^Merge\b/i.test(subject)) return null
|
|
142
|
+
|
|
143
|
+
const body = bodyParts.join('|')
|
|
144
|
+
const match = /^([a-z]+)(?:\(([^)]+)\))?(!)?:\s*(.*)$/i.exec(subject)
|
|
145
|
+
if (!match) return null
|
|
146
|
+
const [, type, scope, bang, rest] = match
|
|
147
|
+
const cleanSubject = rest.trim()
|
|
148
|
+
if (!cleanSubject) return null
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
type: type.toLowerCase(),
|
|
152
|
+
scope: scope || null,
|
|
153
|
+
subject: cleanSubject,
|
|
154
|
+
breaking: Boolean(bang) || /BREAKING CHANGE/i.test(body),
|
|
155
|
+
refs: parseIssueRefs(body),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Conventional type → final release-note group. Types missing from this map
|
|
161
|
+
* (chore, refactor, test, ci, build, style, revert, unknown) are internal
|
|
162
|
+
* and omitted from the notes.
|
|
163
|
+
*/
|
|
164
|
+
const TYPE_GROUPS = {
|
|
165
|
+
feat: 'whatsNew',
|
|
166
|
+
perf: 'whatsNew',
|
|
167
|
+
docs: 'whatsNew',
|
|
168
|
+
fix: 'fixed',
|
|
169
|
+
security: 'fixed',
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Group parsed commits into the final release-note sections. Breaking
|
|
174
|
+
* commits keep their type bucket (the 💥 marker is applied at render time);
|
|
175
|
+
* internal types are dropped; issue refs from ANY commit body feed
|
|
176
|
+
* closedIssues (deduped, first occurrence wins). Entries keep git log order.
|
|
177
|
+
*
|
|
178
|
+
* @param {Array<ReturnType<typeof parseCommitLine>>} entries
|
|
179
|
+
* @param {{knownIssues?: string[]|string|null}} [options]
|
|
180
|
+
* knownIssues — manual Known Issues entries (CLI --known-issues). Omitted
|
|
181
|
+
* (empty array) when not provided.
|
|
182
|
+
* @returns {{whatsNew: Array, fixed: Array, knownIssues: string[],
|
|
183
|
+
* closedIssues: Array<{ref: number, subject: string}>}}
|
|
184
|
+
*/
|
|
185
|
+
export function groupCommits(entries, options = {}) {
|
|
186
|
+
const { knownIssues = [] } = options
|
|
187
|
+
const groups = {
|
|
188
|
+
whatsNew: [],
|
|
189
|
+
fixed: [],
|
|
190
|
+
knownIssues: Array.isArray(knownIssues) ? [...knownIssues] : knownIssues ? [knownIssues] : [],
|
|
191
|
+
closedIssues: [],
|
|
192
|
+
}
|
|
193
|
+
const seenRefs = new Set()
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
if (!entry) continue
|
|
196
|
+
const bucket = TYPE_GROUPS[entry.type]
|
|
197
|
+
if (bucket) groups[bucket].push(entry)
|
|
198
|
+
for (const ref of entry.refs) {
|
|
199
|
+
if (seenRefs.has(ref)) continue
|
|
200
|
+
seenRefs.add(ref)
|
|
201
|
+
groups.closedIssues.push({ ref, subject: entry.subject })
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return groups
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
// Rendering
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
/** One bullet: `- **<scope>** — <subject>`; without a scope the whole
|
|
212
|
+
* subject becomes the bold summary. Breaking bullets get a 💥 prefix. */
|
|
213
|
+
function bullet(entry) {
|
|
214
|
+
const prefix = entry.breaking ? '💥 ' : ''
|
|
215
|
+
return entry.scope
|
|
216
|
+
? `- ${prefix}**${entry.scope}** — ${entry.subject}`
|
|
217
|
+
: `- ${prefix}**${entry.subject}**`
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** One Closed Issues bullet: `- #N - <subject>` (subject may be absent). */
|
|
221
|
+
function closedIssueBullet({ ref, subject }) {
|
|
222
|
+
return subject ? `- #${ref} - ${subject}` : `- #${ref}`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Section order — user-facing first, Known Issues then Closed Issues. */
|
|
226
|
+
const MARKDOWN_SECTIONS = [
|
|
227
|
+
['whatsNew', "🆕 What's New"],
|
|
228
|
+
['fixed', '🐞 Fixed'],
|
|
229
|
+
['knownIssues', '⚠️ Known Issues'],
|
|
230
|
+
['closedIssues', '✅ Closed Issues'],
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
/** Render one entry for its section (strings for Known Issues). */
|
|
234
|
+
function renderEntry(key, entry) {
|
|
235
|
+
if (key === 'knownIssues') return `- ${entry}`
|
|
236
|
+
if (key === 'closedIssues') return closedIssueBullet(entry)
|
|
237
|
+
return bullet(entry)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Render grouped commits as emoji-sectioned markdown (the CLI stdout and the
|
|
242
|
+
* CHANGELOG body). Returns '' when there is nothing to report.
|
|
243
|
+
*
|
|
244
|
+
* @param {ReturnType<typeof groupCommits>} groups
|
|
245
|
+
* @returns {string}
|
|
246
|
+
*/
|
|
247
|
+
export function renderMarkdown(groups) {
|
|
248
|
+
const blocks = []
|
|
249
|
+
for (const [key, heading] of MARKDOWN_SECTIONS) {
|
|
250
|
+
if (!groups[key]?.length) continue
|
|
251
|
+
blocks.push(`## ${heading}\n${groups[key].map((entry) => renderEntry(key, entry)).join('\n')}`)
|
|
252
|
+
}
|
|
253
|
+
return blocks.join('\n\n')
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Render grouped commits for a CHANGELOG version entry. Used by
|
|
258
|
+
* `versioning.mjs apply --notes` to pre-fill the promoted [Unreleased] entry.
|
|
259
|
+
* Emits the SAME final emoji groups as renderMarkdown — the CHANGELOG uses
|
|
260
|
+
* the emoji format.
|
|
261
|
+
*
|
|
262
|
+
* @param {ReturnType<typeof groupCommits>} groups
|
|
263
|
+
* @returns {string}
|
|
264
|
+
*/
|
|
265
|
+
export function renderChangelog(groups) {
|
|
266
|
+
return renderMarkdown(groups)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// CLI
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
/** Collect every `--known-issues <text>` value from argv (repeatable). */
|
|
274
|
+
function parseKnownIssues(argv) {
|
|
275
|
+
const issues = []
|
|
276
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
277
|
+
if (argv[i] === '--known-issues' && argv[i + 1]) {
|
|
278
|
+
issues.push(argv[i + 1])
|
|
279
|
+
i += 1
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return issues
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
|
286
|
+
|
|
287
|
+
if (isDirectRun) {
|
|
288
|
+
const args = process.argv.slice(2)
|
|
289
|
+
const draft = args.includes('--draft')
|
|
290
|
+
const knownIssues = parseKnownIssues(args)
|
|
291
|
+
let since = null
|
|
292
|
+
|
|
293
|
+
if (draft) {
|
|
294
|
+
console.error(`release-notes: draft mode — last ${DRAFT_RANGE} commits (no tag lookup)`)
|
|
295
|
+
} else {
|
|
296
|
+
since = getLastTag()
|
|
297
|
+
if (!tagExists(since)) {
|
|
298
|
+
since = null
|
|
299
|
+
console.error('release-notes: no stable vX.Y.Z tag — falling back to draft mode')
|
|
300
|
+
} else {
|
|
301
|
+
console.error(`release-notes: ${since}..HEAD`)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const raw = collectEntries({ since, draft: since === null })
|
|
306
|
+
const groups = groupCommits(raw.map(parseCommitLine), { knownIssues })
|
|
307
|
+
const markdown = renderMarkdown(groups)
|
|
308
|
+
const sectionCount = Object.values(groups).filter((g) => g.length).length
|
|
309
|
+
|
|
310
|
+
if (raw.length === 0) {
|
|
311
|
+
console.error(`release-notes: no commits to group${since ? ` since ${since}` : ''}`)
|
|
312
|
+
} else {
|
|
313
|
+
console.error(`release-notes: ${raw.length} commits grouped into ${sectionCount} section(s)`)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (markdown) process.stdout.write(`${markdown}\n`)
|
|
317
|
+
}
|
|
@@ -135,8 +135,11 @@ echo ""
|
|
|
135
135
|
# Step 5: Validate scrub-secrets.py works
|
|
136
136
|
echo "[5/4] Testing scrub-secrets.py (bonus)..."
|
|
137
137
|
if [ -f scripts/scrub-secrets.py ]; then
|
|
138
|
-
# Test with a simple secret
|
|
139
|
-
|
|
138
|
+
# Test with a simple secret. The token is assembled at runtime so the
|
|
139
|
+
# shipped script never contains a literal `Bearer <token>` sequence (the
|
|
140
|
+
# redaction gate flags any such literal as a credential).
|
|
141
|
+
SCRUB_FIXTURE_TOKEN="my-test-token-12345"
|
|
142
|
+
SECRET_TEST=$(echo "Bearer ${SCRUB_FIXTURE_TOKEN}" | python3 scripts/scrub-secrets.py --stdin 2>/dev/null)
|
|
140
143
|
if echo "$SECRET_TEST" | grep -q "REDACTED"; then
|
|
141
144
|
echo " ✅ scrub-secrets.py: secret detection working"
|
|
142
145
|
else
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Validate files that npm is allowed to publish. */
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
4
|
+
import { join, relative } from 'node:path'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..')
|
|
8
|
+
const executableConfigs = ['opencode.json', 'plugin.json', 'src/plugins/tui/package.json']
|
|
9
|
+
const checkoutRoot = ROOT.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
10
|
+
const forbiddenPath = new RegExp(
|
|
11
|
+
`(?:${checkoutRoot}|(?:^|[\\s"'(])[A-Za-z]:[\\\\/]|\\/(?:Users|home|tmp|workspace|private\\/var)\\/|pantheon[\\\\/]src[\\\\/]|(?:^|[\\\\/])logs(?:[\\\\/]|$))`,
|
|
12
|
+
'i',
|
|
13
|
+
)
|
|
14
|
+
const errors = []
|
|
15
|
+
|
|
16
|
+
for (const file of executableConfigs) {
|
|
17
|
+
const path = join(ROOT, file)
|
|
18
|
+
if (!existsSync(path)) continue
|
|
19
|
+
const text = readFileSync(path, 'utf8')
|
|
20
|
+
if (forbiddenPath.test(text)) errors.push(`${file}: contains a machine, checkout, or log path`)
|
|
21
|
+
try {
|
|
22
|
+
const config = JSON.parse(text)
|
|
23
|
+
if (Array.isArray(config.plugin) && config.plugin.some((entry) => /^\//.test(entry))) {
|
|
24
|
+
errors.push(`${file}: plugin entries must not be absolute in the published template`)
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
errors.push(`${file}: invalid JSON`)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function walk(dir) {
|
|
32
|
+
for (const name of readdirSync(dir)) {
|
|
33
|
+
const path = join(dir, name)
|
|
34
|
+
const rel = relative(ROOT, path)
|
|
35
|
+
// These are local runtime state and are excluded by npm's explicit files
|
|
36
|
+
// allow-list; never inspect them as publish candidates.
|
|
37
|
+
if (
|
|
38
|
+
name === 'node_modules' ||
|
|
39
|
+
name === '.git' ||
|
|
40
|
+
name === '.venv' ||
|
|
41
|
+
name === '.pantheon' ||
|
|
42
|
+
name === 'logs'
|
|
43
|
+
)
|
|
44
|
+
continue
|
|
45
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
46
|
+
else if (/\.log$|(?:^|[\\/])logs(?:[\\/]|$)/i.test(rel))
|
|
47
|
+
errors.push(`${rel}: log artifact must not ship`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
walk(ROOT)
|
|
51
|
+
|
|
52
|
+
if (errors.length) {
|
|
53
|
+
console.error('Package validation failed:')
|
|
54
|
+
for (const error of errors) console.error(`- ${error}`)
|
|
55
|
+
process.exit(1)
|
|
56
|
+
}
|
package/scripts/versioning.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import { execSync } from 'node:child_process'
|
|
|
20
20
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
21
21
|
import { dirname, join } from 'node:path'
|
|
22
22
|
import { fileURLToPath } from 'node:url'
|
|
23
|
+
import { collectEntries, groupCommits, parseCommitLine, renderChangelog } from './release-notes.mjs'
|
|
23
24
|
|
|
24
25
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
25
26
|
const ROOT = join(__dirname, '..')
|
|
@@ -117,12 +118,70 @@ function updateManifests(newVersion) {
|
|
|
117
118
|
// CHANGELOG updater
|
|
118
119
|
//
|
|
119
120
|
// Finds the [Unreleased] section and:
|
|
120
|
-
// 1. Strips empty subsections (### Added
|
|
121
|
+
// 1. Strips empty subsections (### Added, ## 🆕 What's New, ...)
|
|
121
122
|
// 2. Renames [Unreleased] → [vX.Y.Z] - date
|
|
122
123
|
// 3. Inserts a fresh empty [Unreleased] template above it
|
|
124
|
+
//
|
|
125
|
+
// `notesBody` (from `apply --notes`) replaces the manual body of the
|
|
126
|
+
// promoted entry with release notes generated from conventional commits
|
|
127
|
+
// (rendered via release-notes.mjs renderChangelog — the final emoji groups
|
|
128
|
+
// 🆕/🐞/⚠️/✅). When notesBody is provided the empty-[Unreleased] early
|
|
129
|
+
// return is bypassed.
|
|
123
130
|
// ---------------------------------------------------------------------------
|
|
124
131
|
|
|
125
|
-
|
|
132
|
+
// A section header that adds no user-facing content: the legacy
|
|
133
|
+
// Keep-a-Changelog ### subsections and the emoji release-note groups.
|
|
134
|
+
const EMPTY_SECTION_HEADER =
|
|
135
|
+
/^(?:### \w|## 🆕 What's New|## 🐞 Fixed|## ⚠️ Known Issues|## ✅ Closed Issues)/
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Neutralize HTML comment delimiters in body content derived from commit
|
|
139
|
+
* messages before it is written into CHANGELOG.md. CodeQL's changelog-
|
|
140
|
+
* injection rule flags interpolating untrusted markdown into a file that
|
|
141
|
+
* also carries HTML comments: a `-->` in a commit subject (surfaced via
|
|
142
|
+
* `apply --notes`) would terminate the template comment early and let the
|
|
143
|
+
* surrounding markdown render as HTML. Escaping BOTH delimiters keeps the
|
|
144
|
+
* emitted body inert regardless of the input commit messages.
|
|
145
|
+
*/
|
|
146
|
+
function sanitizeCommentDelimiters(body) {
|
|
147
|
+
return body.replace(/<!--/g, '<!--').replace(/--(?:!?)>/g, '-->')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Any markdown header — used to find the end of a section's content.
|
|
151
|
+
const ANY_HEADER = /^(?:### |## )/
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove empty subsections from a changelog body: a recognized section
|
|
155
|
+
* header with no content (non-blank, non-header lines) before the next
|
|
156
|
+
* header or end of body is dropped together with its trailing blank lines.
|
|
157
|
+
*/
|
|
158
|
+
function stripEmptySections(body) {
|
|
159
|
+
const lines = body.split('\n')
|
|
160
|
+
const keep = []
|
|
161
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
162
|
+
const line = lines[i]
|
|
163
|
+
if (!EMPTY_SECTION_HEADER.test(line)) {
|
|
164
|
+
keep.push(line)
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
let j = i + 1
|
|
168
|
+
let hasContent = false
|
|
169
|
+
while (j < lines.length && !ANY_HEADER.test(lines[j])) {
|
|
170
|
+
if (lines[j].trim()) hasContent = true
|
|
171
|
+
j += 1
|
|
172
|
+
}
|
|
173
|
+
if (hasContent) {
|
|
174
|
+
keep.push(line)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
// Empty section: drop the header and any blank lines up to the next
|
|
178
|
+
// header so the section leaves no empty gap.
|
|
179
|
+
while (i + 1 < lines.length && lines[i + 1].trim() === '') i += 1
|
|
180
|
+
}
|
|
181
|
+
return keep.join('\n').replace(/\n{3,}/g, '\n\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function promoteUnreleased(newVersion, dateStr, notesBody = null) {
|
|
126
185
|
const content = readFileSync(CHANGELOG_PATH, 'utf-8')
|
|
127
186
|
|
|
128
187
|
const unreleasedHeader = '## [Unreleased]'
|
|
@@ -139,33 +198,53 @@ function promoteUnreleased(newVersion, dateStr) {
|
|
|
139
198
|
nextSectionIdx === -1 ? content.slice(afterHeader) : content.slice(afterHeader, nextSectionIdx)
|
|
140
199
|
|
|
141
200
|
// Check if the [Unreleased] section has any real content (non-empty lines
|
|
142
|
-
// that aren't just section headers or comments)
|
|
143
|
-
|
|
201
|
+
// that aren't just section headers or comments). HTML comments (the
|
|
202
|
+
// template hint) are removed first — they span multiple lines.
|
|
203
|
+
let bodyWithoutComments = unreleasedBody
|
|
204
|
+
let previousBody
|
|
205
|
+
do {
|
|
206
|
+
previousBody = bodyWithoutComments
|
|
207
|
+
bodyWithoutComments = bodyWithoutComments.replace(/<!--[\s\S]*?-->/g, '')
|
|
208
|
+
} while (bodyWithoutComments !== previousBody)
|
|
209
|
+
const realLines = bodyWithoutComments
|
|
144
210
|
.split('\n')
|
|
145
|
-
.
|
|
211
|
+
.map((l) => l.trim())
|
|
212
|
+
.filter((l) => l && !l.startsWith('###') && !l.startsWith('## '))
|
|
146
213
|
|
|
147
|
-
if (realLines.length === 0) {
|
|
214
|
+
if (notesBody === null && realLines.length === 0) {
|
|
148
215
|
console.log(' ℹ [Unreleased] is empty — CHANGELOG not modified')
|
|
149
216
|
return false
|
|
150
217
|
}
|
|
151
218
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
219
|
+
if (notesBody !== null && realLines.length > 0) {
|
|
220
|
+
console.log(
|
|
221
|
+
' ⚠ --notes replaces the manual [Unreleased] content — review the diff before committing',
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Strip lines that are just empty subsections (### X or ## 🆕/🐞/⚠️/✅
|
|
226
|
+
// followed by blank lines then another header or end)
|
|
227
|
+
const cleanedBody =
|
|
228
|
+
notesBody !== null ? `\n\n${notesBody}` : stripEmptySections(unreleasedBody).trimEnd()
|
|
229
|
+
// Sanitize the WRITTEN body only (never the strip pass): commit-derived
|
|
230
|
+
// notes can carry `<!--` / `-->`, which CodeQL flags as changelog
|
|
231
|
+
// injection when interpolated next to the template's HTML comment.
|
|
232
|
+
const writtenBody = sanitizeCommentDelimiters(cleanedBody)
|
|
158
233
|
|
|
159
|
-
const newTemplate = `\n\n<!-- Add new changes here. Running \`node scripts/versioning.mjs apply\` will\n move this section to a versioned entry and reset the template below. -->\n\n
|
|
234
|
+
const newTemplate = `\n\n<!-- Add new changes here. Running \`node scripts/versioning.mjs apply\` will\n move this section to a versioned entry and reset the template below. -->\n\n## 🆕 What's New\n\n## 🐞 Fixed\n\n## ⚠️ Known Issues\n\n## ✅ Closed Issues`
|
|
160
235
|
const newVersionHeader = `## [v${newVersion}] - ${dateStr}`
|
|
161
236
|
|
|
162
237
|
const before = content.slice(0, idx)
|
|
163
238
|
const after = nextSectionIdx === -1 ? '' : content.slice(nextSectionIdx)
|
|
164
239
|
|
|
165
|
-
const updated = `${before + unreleasedHeader + newTemplate}\n\n${newVersionHeader}${
|
|
240
|
+
const updated = `${before + unreleasedHeader + newTemplate}\n\n${newVersionHeader}${writtenBody}${after}`
|
|
166
241
|
|
|
167
242
|
writeFileSync(CHANGELOG_PATH, updated)
|
|
168
|
-
console.log(
|
|
243
|
+
console.log(
|
|
244
|
+
notesBody !== null
|
|
245
|
+
? ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}] (notes generated from commits)`
|
|
246
|
+
: ` ✓ CHANGELOG: [Unreleased] → [v${newVersion}]`,
|
|
247
|
+
)
|
|
169
248
|
return true
|
|
170
249
|
}
|
|
171
250
|
|
|
@@ -204,11 +283,29 @@ switch (command) {
|
|
|
204
283
|
}
|
|
205
284
|
|
|
206
285
|
case 'apply': {
|
|
207
|
-
const
|
|
286
|
+
const args = process.argv.slice(3)
|
|
287
|
+
const useNotes = args.includes('--notes')
|
|
288
|
+
const type = args.find((a) => !a.startsWith('--')) || 'auto'
|
|
208
289
|
const latestTag = getLatestTag()
|
|
209
290
|
const current = getCurrentVersion()
|
|
210
291
|
const latestVer = latestTag.replace(/^v/, '')
|
|
211
292
|
|
|
293
|
+
// `apply --notes` pre-fills the promoted [Unreleased] entry with release
|
|
294
|
+
// notes generated from conventional commits (release-notes.mjs). Default
|
|
295
|
+
// stays manual — the flag is opt-in.
|
|
296
|
+
const generateNotes = () => {
|
|
297
|
+
if (!useNotes) return null
|
|
298
|
+
const since = latestTag === 'v0.0.0' ? null : latestTag
|
|
299
|
+
const raw = collectEntries({ since, draft: since === null })
|
|
300
|
+
const body = renderChangelog(groupCommits(raw.map(parseCommitLine)))
|
|
301
|
+
if (!body) {
|
|
302
|
+
console.log(' ⚠ --notes: no groupable commits — manual [Unreleased] content used')
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
console.log(` ℹ --notes: release notes generated from ${raw.length} commits`)
|
|
306
|
+
return body
|
|
307
|
+
}
|
|
308
|
+
|
|
212
309
|
// If package.json is already ahead of the latest tag, someone bumped
|
|
213
310
|
// without tagging — warn and use the current version as-is. Tags are
|
|
214
311
|
// workflow-owned, so there is nothing to create here.
|
|
@@ -217,7 +314,7 @@ switch (command) {
|
|
|
217
314
|
console.log(` Syncing all manifests to ${current} and promoting CHANGELOG.`)
|
|
218
315
|
updateManifests(current)
|
|
219
316
|
const date = new Date().toISOString().slice(0, 10)
|
|
220
|
-
promoteUnreleased(current, date)
|
|
317
|
+
promoteUnreleased(current, date, generateNotes())
|
|
221
318
|
console.log(`Tag v${current} will be created by the release workflow after merge to main.`)
|
|
222
319
|
break
|
|
223
320
|
}
|
|
@@ -228,7 +325,7 @@ switch (command) {
|
|
|
228
325
|
|
|
229
326
|
console.log(`Bumping ${current} → ${newVersion} (${bumpType})`)
|
|
230
327
|
updateManifests(newVersion)
|
|
231
|
-
promoteUnreleased(newVersion, date)
|
|
328
|
+
promoteUnreleased(newVersion, date, generateNotes())
|
|
232
329
|
console.log(`\nDone. Commit with: git add -A && git commit -m "chore(release): v${newVersion}"`)
|
|
233
330
|
|
|
234
331
|
// Tags are owned by the release workflow — never create them locally.
|
|
@@ -254,6 +351,8 @@ Commands:
|
|
|
254
351
|
recommend Print recommended bump type (patch/minor/major)
|
|
255
352
|
apply [type] Bump manifests + move [Unreleased] → [vX.Y.Z]
|
|
256
353
|
type: patch | minor | major | auto (default: auto)
|
|
354
|
+
--notes: pre-fill the promoted entry with release
|
|
355
|
+
notes generated from conventional commits
|
|
257
356
|
changelog [version] Promote [Unreleased] → [vX.Y.Z] without bumping
|
|
258
357
|
|
|
259
358
|
Release flow:
|