diff --git a/.github/actions/aggregate-report/action.yml b/.github/actions/aggregate-report/action.yml index 47e4891c..8b1cd7ed 100644 --- a/.github/actions/aggregate-report/action.yml +++ b/.github/actions/aggregate-report/action.yml @@ -7,10 +7,6 @@ inputs: working-directory: description: 'Path to the ./packages/name_of_your_package_folder' required: true -outputs: - report: - description: 'Markdown report section generated by the action' - value: ${{ steps.section-report.outputs.report }} runs: using: 'composite' steps: @@ -35,10 +31,6 @@ runs: name: mutation-report path: mutation-report - - name: Find current PR's number - uses: jwalton/gh-find-current-pr@v1 - id: find_pr - - name: Process coverage reports uses: actions/github-script@v9 id: process_test_report @@ -55,7 +47,7 @@ runs: id: process_mutation_report env: WORKING_DIR: ${{ inputs.working-directory }} - PR_NUMBER: ${{ steps.find_pr.outputs.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} PACKAGE_NAME: ${{ inputs.package-name }} with: script: | @@ -65,16 +57,16 @@ runs: - name: Acquire comment-update lock id: acquire_lock - uses: softprops/turnstyle@v3 + # Pinned to a full SHA (v3.3.2) rather than the moving v3 tag. + uses: softprops/turnstyle@afaccda0f3c0136fb7cb4a734b9b96be03599948 # v3.3.2 with: - queue-name: aggregated-comment-${{ steps.find_pr.outputs.number || github.ref_name || github.ref }}}} + queue-name: aggregated-comment-${{ github.event.pull_request.number }} - name: Create or update PR comment with report uses: actions/github-script@v9 env: - REPORT: ${{ steps.section-report.outputs.report }} PACKAGE: ${{ inputs.package-name }} - PR_NUMBER: ${{ steps.find_pr.outputs.number }} + PR_NUMBER: ${{ github.event.pull_request.number }} MUTATION_MESSAGE: ${{ steps.process_mutation_report.outputs.message }} UNIT_TEST_MESSAGE: ${{ steps.process_test_report.outputs.message }} with: diff --git a/.github/actions/mutation-tests-all-files/action.yml b/.github/actions/mutation-tests-all-files/action.yml index a0b2c52d..1aef6364 100644 --- a/.github/actions/mutation-tests-all-files/action.yml +++ b/.github/actions/mutation-tests-all-files/action.yml @@ -26,7 +26,9 @@ runs: package-name: ${{ inputs.package-name }} - name: Get changed files - uses: tj-actions/changed-files@v47 + # Pinned to a full SHA (v47.0.6) — see mutation-tests-changed-files for why. + # Only the src_any_changed boolean is consumed below, so no separator is set. + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 id: changed-files with: files_yaml: | @@ -34,13 +36,13 @@ runs: - 'src/**/*.ts' - '!src/**/*.spec.ts' - '!src/**/__mocks__/**' - separator: "','" path: ${{ inputs.working-directory }} - name: Run mutation tests if: steps.changed-files.outputs.src_any_changed == 'true' env: STRYKER_DASHBOARD_API_KEY: ${{ inputs.stryker_dashboard_api_key }} + PACKAGE_NAME: ${{ inputs.package-name }} shell: bash id: run-mutation-tests - run: yarn workspace ${{ inputs.package-name }} test:mutations --dashboard.version main --dashboard.module ${{ inputs.package-name }} + run: yarn workspace "$PACKAGE_NAME" test:mutations --dashboard.version main --dashboard.module "$PACKAGE_NAME" diff --git a/.github/actions/mutation-tests-changed-files/action.yml b/.github/actions/mutation-tests-changed-files/action.yml index fee294eb..86100ee9 100644 --- a/.github/actions/mutation-tests-changed-files/action.yml +++ b/.github/actions/mutation-tests-changed-files/action.yml @@ -27,7 +27,9 @@ runs: package-name: ${{ inputs.package-name }} - name: Get changed files - uses: tj-actions/changed-files@v47 + # Pinned to a full SHA (v47.0.6). tj-actions/changed-files was compromised + # in March 2025 via tag re-pointing; a moving tag re-exposes that risk. + uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 id: changed-files with: files_yaml: | @@ -42,16 +44,25 @@ runs: if: steps.changed-files.outputs.src_any_changed == 'true' env: STRYKER_DASHBOARD_API_KEY: ${{ inputs.stryker_dashboard_api_key }} + # Via env, never inlined: these are filenames from the PR, and `${{ }}` + # splices them into the shell — a file named `$(...)` would execute. + CHANGED_FILES: ${{ steps.changed-files.outputs.src_all_changed_files }} + PACKAGE_NAME: ${{ inputs.package-name }} shell: bash id: run-mutation-tests - run: yarn workspace ${{ inputs.package-name }} test:mutations --mutate "${{ steps.changed-files.outputs.src_all_changed_files }}" --dashboard.module ${{ inputs.package-name }} + run: yarn workspace "$PACKAGE_NAME" test:mutations --mutate "$CHANGED_FILES" --dashboard.module "$PACKAGE_NAME" continue-on-error: true - name: Save mutated files list if: steps.changed-files.outputs.src_any_changed == 'true' shell: bash + env: + CHANGED_FILES: ${{ steps.changed-files.outputs.src_all_changed_files }} run: | - echo "${{ steps.changed-files.outputs.src_all_changed_files }}" | sed 's/,/","/g' | sed 's/^/["/;s/$/"]/' > changed-files.json + # Filenames are PR-controlled; splicing them into JSON by hand (sed) + # breaks on a `"` or `\` in a path. jq splits the comma-separated list + # and serialises each element as a properly escaped JSON string. + jq -cn --arg files "$CHANGED_FILES" '$files | split(",")' > changed-files.json - name: Save empty list if no files changed if: steps.changed-files.outputs.src_any_changed == 'false' diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml index d28bc017..3baf3e67 100644 --- a/.github/actions/unit-tests/action.yml +++ b/.github/actions/unit-tests/action.yml @@ -20,7 +20,6 @@ runs: package-name: ${{ inputs.package-name }} - name: Run unit tests with coverage - if: ${{ github.ref != 'refs/heads/main' }} # Skip coverage on main branch to save time and resources shell: bash run: | yarn workspace ${{ inputs.package-name }} test:coverage diff --git a/.github/scripts/openspec-archive-gate.mjs b/.github/scripts/openspec-archive-gate.mjs new file mode 100644 index 00000000..c959255d --- /dev/null +++ b/.github/scripts/openspec-archive-gate.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// OpenSpec archive gate. +// +// Fails when an active change is fully complete (every task in its tasks.md is +// checked) but still lives in openspec/changes/ instead of being archived into +// openspec/changes/archive/. Intermediate PRs of a multi-PR change leave some +// tasks unchecked and therefore pass; only the PR that checks the last task is +// required to carry the `openspec archive` commit. +// +// Pass --defer (or OPENSPEC_DEFER_ARCHIVE=true) to downgrade the failure to a +// warning — used when a PR carries the `openspec:defer-archive` label. +// +// Pass --list-complete to instead print the names of complete-but-un-archived +// changes (one per line) and exit 0 — used by the archive workflow to resolve +// which change `/archive` should target. +// +// Read-only. Run locally with: node .github/scripts/openspec-archive-gate.mjs + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const deferred = + process.argv.includes('--defer') || + /^(1|true|yes)$/i.test(process.env.OPENSPEC_DEFER_ARCHIVE ?? ''); + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const changesDir = join(repoRoot, 'openspec', 'changes'); + +/** Directory names of active (non-archived) changes. */ +function activeChanges() { + let entries; + try { + entries = readdirSync(changesDir, { withFileTypes: true }); + } catch { + return []; // no openspec/changes directory yet + } + return entries + .filter((e) => e.isDirectory() && e.name !== 'archive') + .map((e) => e.name); +} + +/** + * Task completeness for a change, read straight from its tasks.md checkboxes. + * Returns null when there are no task lines (nothing to complete / no gate). + */ +function completeness(change) { + const tasksPath = join(changesDir, change, 'tasks.md'); + let content; + try { + if (!statSync(tasksPath).isFile()) return null; + content = readFileSync(tasksPath, 'utf8'); + } catch { + return null; + } + let total = 0; + let unchecked = 0; + for (const line of content.split('\n')) { + const m = /^\s*[-*]\s+\[( |x|X)\]/.exec(line); + if (!m) continue; + total += 1; + if (m[1] === ' ') unchecked += 1; + } + if (total === 0) return null; + return { total, unchecked, complete: unchecked === 0 }; +} + +const violations = []; +for (const change of activeChanges()) { + const status = completeness(change); + if (status?.complete) violations.push(change); +} + +if (process.argv.includes('--list-complete')) { + console.log(violations.join('\n')); + process.exit(0); +} + +if (violations.length === 0) { + console.log('OpenSpec archive gate: OK — no completed change is left un-archived.'); + process.exit(0); +} + +// GitHub workflow commands (`::error::` / `::warning::`) treat the message as a +// single line: a raw newline ends the command and the rest leaks to the log as +// plain text. Percent-encode so multi-line details land inside the annotation. +// https://docs.github.com/actions/reference/workflow-commands-for-github-actions +const encodeCmd = (s) => + s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); + +const list = violations.map((c) => ` - ${c}`).join('\n'); +const message = + `OpenSpec archive gate: the following change(s) are complete but not archived:\n${list}\n\n` + + `Run \`openspec archive -y \` (or comment \`/archive\` on the PR) so the ` + + `delta specs fold into openspec/specs/ before this merges.`; + +if (deferred) { + console.warn( + `::warning::${encodeCmd(`${message}\n(Deferred via openspec:defer-archive — not blocking.)`)}`, + ); + process.exit(0); +} + +console.error(`::error::${encodeCmd(message)}`); +process.exit(1); diff --git a/.github/scripts/should-run-ci.sh b/.github/scripts/should-run-ci.sh index de196a45..66c02268 100644 --- a/.github/scripts/should-run-ci.sh +++ b/.github/scripts/should-run-ci.sh @@ -56,6 +56,26 @@ if [ -z "$RESOLVED_BASE_REF" ]; then exit 0 fi +# Shared/root files that can affect every package's install, build, or CI. A change +# to any of these forces CI regardless of which package this is - without it, a +# lockfile bump or a workflow edit makes every package report "no CI needed" and the +# PR shows all-green having tested nothing. Checked before workspace resolution since +# it is independent of it (and `.github/` changes should run CI even if yarn can't). +ROOT_CHANGES=$(git diff --name-only "$RESOLVED_BASE_REF...HEAD" -- \ + yarn.lock \ + package.json \ + .yarnrc.yml \ + yarn.config.cjs \ + .nvmrc \ + tsconfig.json \ + .github/ 2>/dev/null) + +if [ -n "$ROOT_CHANGES" ]; then + debug "✓ Shared/root files changed, running CI:" + debug "$ROOT_CHANGES" + exit 0 +fi + # Build an index of workspace packages: one " " record per line. # Locations come from yarn itself, so any workspace layout (nested roots like packages/tools/* # and packages/plugins/*, or anything added later) is picked up without teaching this script diff --git a/.github/workflows/build-and-push-docker-image.yml b/.github/workflows/build-and-push-docker-image.yml index ecdd4c3b..a52ebbbb 100644 --- a/.github/workflows/build-and-push-docker-image.yml +++ b/.github/workflows/build-and-push-docker-image.yml @@ -9,6 +9,17 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} +# Explicit, least-privilege: pushing to ghcr.io needs packages: write, which the +# default token only carries when the repo default is read-write. +permissions: + contents: read + packages: write + +# Two quick merges to main must not race for the same tags. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 2b1d47a2..bc93232d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -6,6 +6,16 @@ on: - main workflow_dispatch: +permissions: + contents: read + +# Never publish two runs at once — concurrent `npm publish` of the same version +# races. Queue instead of cancelling: a cancelled run can leave half the +# workspaces published. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest diff --git a/.github/workflows/openspec-archive-gate.yml b/.github/workflows/openspec-archive-gate.yml new file mode 100644 index 00000000..8f51fb57 --- /dev/null +++ b/.github/workflows/openspec-archive-gate.yml @@ -0,0 +1,33 @@ +name: OpenSpec archive gate + +# Fails a PR / merge-queue build when a completed OpenSpec change is left +# un-archived. The merge_group run is the authoritative one: the merge-queue ref is +# rebuilt fresh, so it re-evaluates with any archive commit present, whereas the +# pull_request run can go stale (the /archive workflow pushes with GITHUB_TOKEN, +# which does not re-trigger PR checks — re-run it manually to clear the red X). +# Add "OpenSpec archive gate / gate" to the merge queue's required status checks. + +on: + pull_request: + merge_group: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + gate: + name: gate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Check archive readiness + env: + # Evaluates to false on merge_group (no pull_request in the payload), so + # the label softens the PR check only — the queue gate stays hard. + OPENSPEC_DEFER_ARCHIVE: ${{ contains(github.event.pull_request.labels.*.name, 'openspec:defer-archive') }} + run: node .github/scripts/openspec-archive-gate.mjs diff --git a/.github/workflows/openspec-archive.yml b/.github/workflows/openspec-archive.yml new file mode 100644 index 00000000..496a7824 --- /dev/null +++ b/.github/workflows/openspec-archive.yml @@ -0,0 +1,311 @@ +name: OpenSpec archive + +# A maintainer comments `/archive` (optionally `/archive `) on a PR. +# This runs `openspec archive`, which moves the completed change into +# openspec/changes/archive/ and folds its delta specs into openspec/specs/, then +# commits the result to the PR branch. The spec fold-in is therefore reviewable in +# the PR and lands atomically with the code on merge. +# +# Split into two jobs so the write token never sits in scope while the PR branch's +# own code runs. `archive-run` checks out the PR head and executes untrusted code +# (setup lifecycle scripts, `openspec archive`) with a read-only token, and emits +# the archive result as a git patch artifact. `archive-commit` runs NO PR code — +# it only applies that patch, commits, and pushes with the write token. This is the +# mitigation for the "untrusted checkout" class: untrusted execution and privileged +# write are never in the same job. +# +# The commit is pushed with the default GITHUB_TOKEN. That does not re-trigger the +# PR's own checks, so the authoritative gate is the merge_group run in +# openspec-archive-gate.yml, which re-evaluates fresh at queue time. + +on: + issue_comment: + types: [created] + +# One archive at a time per PR: two `/archive` comments racing would both try to +# push to the same branch. Never cancel in flight — a cancelled run can leave the +# archive committed but unpushed, or vice versa. +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: false + +jobs: + archive-run: + # Only for PR comments beginning with /archive. author_association is a cheap + # pre-filter; this job holds only a read token, so running the PR branch's code + # here is no more privileged than ordinary pull_request CI. Actual write access + # is verified in archive-commit before anything is pushed. + if: > + github.event.issue.pull_request && + startsWith(github.event.comment.body, '/archive') && + contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + ref: ${{ steps.pr.outputs.ref }} + sha: ${{ steps.pr.outputs.sha }} + cross: ${{ steps.pr.outputs.cross }} + name: ${{ steps.change.outputs.name }} + error: ${{ steps.change.outputs.error }} + changed: ${{ steps.patch.outputs.changed }} + commit: ${{ steps.patch.outputs.commit }} + steps: + - name: Resolve PR head + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + data=$(gh pr view "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --json headRefName,headRefOid,isCrossRepository) + echo "ref=$(echo "$data" | jq -r .headRefName)" >> "$GITHUB_OUTPUT" + echo "sha=$(echo "$data" | jq -r .headRefOid)" >> "$GITHUB_OUTPUT" + echo "cross=$(echo "$data" | jq -r .isCrossRepository)" >> "$GITHUB_OUTPUT" + + # Everything below runs the PR branch's own code. It is skipped for fork PRs: + # we can't push to a fork branch anyway, and archive-commit posts the reason. + - uses: actions/checkout@v6 + if: steps.pr.outputs.cross != 'true' + with: + # Pinned to the exact oid resolved above, not the branch name, so the + # tree we archive is the one archive-commit will apply the patch onto. + ref: ${{ steps.pr.outputs.sha }} + fetch-depth: 0 + + - uses: actions/setup-node@v6 + if: steps.pr.outputs.cross != 'true' + with: + node-version-file: '.nvmrc' + + - run: corepack enable + if: steps.pr.outputs.cross != 'true' + + - name: Setup environment + if: steps.pr.outputs.cross != 'true' + uses: ./.github/actions/setup + + - name: Resolve change to archive + id: change + if: steps.pr.outputs.cross != 'true' + env: + # Via env, never inlined into the script: the comment body is + # attacker-controlled text and `${{ }}` would splice it into the shell. + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + # Explicit "/archive " wins; otherwise pick the single + # complete-but-un-archived change. + named=$(printf '%s' "$COMMENT_BODY" | head -n1 | awk '{print $2}') + if [ -n "$named" ]; then + if ! printf '%s' "$named" | grep -qE '^[A-Za-z0-9._-]+$'; then + echo "error=Invalid change name. Use \`/archive \` (letters, digits, \`.\`, \`_\`, \`-\`)." >> "$GITHUB_OUTPUT" + exit 1 + fi + if [ ! -d "openspec/changes/$named" ]; then + echo "error=No active change named \`$named\` in \`openspec/changes/\`." >> "$GITHUB_OUTPUT" + exit 1 + fi + echo "name=$named" >> "$GITHUB_OUTPUT" + exit 0 + fi + mapfile -t complete < <(node .github/scripts/openspec-archive-gate.mjs --list-complete | sed '/^$/d') + if [ "${#complete[@]}" -eq 0 ]; then + echo "error=No completed change to archive. Check off all tasks first, or pass \`/archive \`." >> "$GITHUB_OUTPUT" + exit 1 + elif [ "${#complete[@]}" -gt 1 ]; then + echo "error=Multiple completed changes (${complete[*]}). Disambiguate with \`/archive \`." >> "$GITHUB_OUTPUT" + exit 1 + fi + echo "name=${complete[0]}" >> "$GITHUB_OUTPUT" + + - name: Archive + # Uses the pinned @fission-ai/openspec devDependency installed by the setup + # action above — not an unpinned `npx` fetch. This is the deterministic CLI + # (no agent); it validates the delta, then folds it into openspec/specs/. + if: steps.pr.outputs.cross != 'true' + env: + CHANGE: ${{ steps.change.outputs.name }} + run: yarn openspec archive -y "$CHANGE" + + - name: Build archive commit + # The commit is created here, in the unprivileged job, so archive-commit + # never has to check out (materialise) the PR branch's untrusted tree while + # holding the write token. The finished commit ships as a thin git bundle + # (only the new commit, with the PR head as its prerequisite); archive-commit + # unbundles the objects and pushes them without ever touching a working tree. + id: patch + if: steps.pr.outputs.cross != 'true' + env: + CHANGE: ${{ steps.change.outputs.name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A openspec + # --cached only: the archive is staged above, and porcelain would also + # report unrelated dirt from the install step. + if git diff --cached --quiet; then + echo "No changes produced by archive — nothing to commit." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + git commit -m "chore(openspec): archive $CHANGE" + echo "commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + # Range bundle: contents are the single new commit, prerequisite is the PR + # head (steps.pr.outputs.sha). archive-commit must have that head object + # present before it can unbundle. + git bundle create "${RUNNER_TEMP}/openspec-archive.bundle" \ + "${{ steps.pr.outputs.sha }}..HEAD" + echo "changed=true" >> "$GITHUB_OUTPUT" + + - name: Upload archive bundle + if: steps.patch.outputs.changed == 'true' + uses: actions/upload-artifact@v7 + with: + name: openspec-archive-bundle + path: ${{ runner.temp }}/openspec-archive.bundle + retention-days: 1 + if-no-files-found: error + + archive-commit: + # Pushes the archive commit bundle. Runs NO PR code and never checks out the PR + # tree — only git object transfer and the write token live here. Runs even when + # archive-run failed so it can report the + # reason; skipped only if archive-run never ran (not an /archive comment). + needs: archive-run + if: ${{ !cancelled() && needs.archive-run.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + # Reacting to the trigger comment hits the issue-comments reactions endpoint, + # which is gated by `issues`, not `pull-requests`. + issues: write + steps: + - name: React to the command + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X POST \ + "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" \ + -f content=eyes >/dev/null + + - name: Verify commenter has write access + # The authoritative privilege gate: author_association in archive-run is a + # pre-filter (COLLABORATOR can be read-only), and nothing is pushed until + # this passes. + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMENTER: ${{ github.event.comment.user.login }} + run: | + level=$(gh api "repos/${{ github.repository }}/collaborators/$COMMENTER/permission" --jq .permission) + case "$level" in + admin|write|maintain) echo "$COMMENTER has $level access." ;; + *) echo "::error::$COMMENTER has '$level' access; /archive requires write." ; exit 1 ;; + esac + + - name: Reject fork PRs + if: needs.archive-run.outputs.cross == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr comment "${{ github.event.issue.number }}" --repo "${{ github.repository }}" \ + --body "⚠️ \`/archive\` can't push to a fork branch. Run \`openspec archive -y\` locally and push the commit yourself." + exit 1 + + - name: Report archive failure + if: needs.archive-run.result == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ERROR: ${{ needs.archive-run.outputs.error }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + msg="$ERROR" + [ -z "$msg" ] && msg="\`openspec archive\` failed — see the [workflow run]($RUN_URL)." + gh pr comment "${{ github.event.issue.number }}" --repo "${{ github.repository }}" --body "❌ $msg" + exit 1 + + - name: Report no-op + if: needs.archive-run.outputs.changed != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHANGE: ${{ needs.archive-run.outputs.name }} + run: | + gh pr comment "${{ github.event.issue.number }}" --repo "${{ github.repository }}" \ + --body "ℹ️ \`openspec archive\` ran for \`$CHANGE\` but produced no changes under \`openspec/\` — nothing to commit. It was most likely archived already." + + # From here: archive-run succeeded and produced a commit bundle. + # Deliberately checks out the trusted default branch — never the PR head — so + # the write token never sits alongside the PR's untrusted working tree. The + # untrusted objects arrive only as git objects (fetched, never checked out or + # executed) and are pushed straight through. + - name: Checkout default branch + if: needs.archive-run.outputs.changed == 'true' + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Download archive bundle + if: needs.archive-run.outputs.changed == 'true' + uses: actions/download-artifact@v8 + with: + name: openspec-archive-bundle + path: ${{ runner.temp }} + + - name: Push archive commit to PR branch + id: commit + if: needs.archive-run.outputs.changed == 'true' + env: + HEAD_REF: ${{ needs.archive-run.outputs.ref }} + COMMIT: ${{ needs.archive-run.outputs.commit }} + EXPECTED_PARENT: ${{ needs.archive-run.outputs.sha }} + PR: ${{ github.event.issue.number }} + run: | + # The bundle's prerequisite is the PR head; fetch it (objects only, no + # working-tree checkout) so the bundle unpacks. refs/pull/N/head resolves + # to the current head, which still contains that prerequisite unless the + # branch was force-pushed onto unrelated history. + git fetch --no-tags origin "refs/pull/${PR}/head" + git fetch "${RUNNER_TEMP}/openspec-archive.bundle" HEAD + + # Defense-in-depth: archive-run executed the PR's own code to build this + # commit (with a read-only token). Don't trust the bundle blindly — verify + # it's built directly on the exact head archive-run resolved, and that it + # only touches openspec/, before pushing it with the write token. + parent=$(git rev-parse "${COMMIT}^") + if [ "$parent" != "$EXPECTED_PARENT" ]; then + echo "::error::Archive commit's parent ($parent) does not match the PR head archive-run resolved ($EXPECTED_PARENT) — refusing to push." + exit 1 + fi + outside=$(git diff --name-only "$EXPECTED_PARENT" "$COMMIT" | grep -v '^openspec/' || true) + if [ -n "$outside" ]; then + echo "::error::Archive commit touches paths outside openspec/ — refusing to push:" + echo "$outside" + exit 1 + fi + + # Fast-forward push of the new commit onto the PR branch. If the branch + # advanced past the archived head, this is a non-fast-forward and fails + # rather than clobbering. + git push origin "${COMMIT}:refs/heads/${HEAD_REF}" + + - name: Report success + if: steps.commit.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHANGE: ${{ needs.archive-run.outputs.name }} + run: | + gh pr comment "${{ github.event.issue.number }}" --repo "${{ github.repository }}" \ + --body "✅ Archived \`$CHANGE\` — delta specs folded into \`openspec/specs/\` and committed to this PR. Review the spec diff before merging." + + - name: Report push failure + # Scoped to the commit step's own outcome so an access/react failure can't + # masquerade as a push failure. + if: steps.commit.outcome == 'failure' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CHANGE: ${{ needs.archive-run.outputs.name }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + gh pr comment "${{ github.event.issue.number }}" --repo "${{ github.repository }}" \ + --body "❌ Archived \`$CHANGE\` but couldn't commit it to the branch (the branch likely moved). See the [workflow run]($RUN_URL) and re-run \`/archive\`." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 266bed9c..5b0c17c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ Typical flow for a change: ``` 3. **Apply** — implement the tasks in `tasks.md`, following TDD as required by `openspec/config.yaml`. 4. **Verify** — check the implementation matches the proposal's specs and tasks before wrapping up. -5. **Archive / Sync** — once merged, archive the change (`openspec archive `) to fold its delta specs into `openspec/specs/`, or sync specs without archiving if the change is still in flight. +5. **Archive** — when the last task is checked, the change is archived (`openspec archive `) to move it into `openspec/changes/archive/` and fold its delta specs into `openspec/specs/`. On a PR this is done for you — see [Archiving at merge time](#archiving-at-merge-time) below. Useful CLI commands while working on a change: ```bash @@ -106,6 +106,20 @@ If you're using Claude Code in this repo, the same workflow is available as slas Keep specs and code in sync: a PR that changes behavior covered by `openspec/specs/` without a corresponding proposal/archive will likely be asked to add one during review. +### Archiving at merge time + +A change is archived **in its final PR**, right before merge, so the spec fold-in is reviewable and lands atomically with the code. CI enforces this: + +- **The gate** (`OpenSpec archive gate`) fails a PR / merge-queue build when an active change has **every** task in its `tasks.md` checked but is still sitting in `openspec/changes/`. Changes with unchecked tasks pass, so a change spread across several PRs only needs to be archived in the PR that completes it. Run the same check locally with `node .github/scripts/openspec-archive-gate.mjs`. +- **The action** — a maintainer comments **`/archive`** on the PR (or `/archive ` when more than one change is complete). A workflow runs `openspec archive`, then commits the moved change + updated specs to the PR branch. Review that spec diff before merging. + +To intentionally land completed code but defer archiving to a follow-up PR, leave the change's final task unchecked until you're ready to archive (task completion *is* the "ready to archive" signal). The `openspec:defer-archive` label additionally silences the informational gate on the PR itself. + +**Authoring deltas so the fold is clean.** CI runs the `openspec archive` **CLI** — a deterministic tool, not the agent-driven `/opsx:archive` skill — so the fold is only as good as the delta. The authoring rules live in [`openspec/config.yaml`](openspec/config.yaml) (`rules.specs`) and surface to agents via `openspec instructions specs`. Two that bite most often: + +- For `MODIFIED`/`REMOVED`/`RENAMED` requirements, the `### Requirement:` header must match the current `openspec/specs//spec.md` **exactly** — a mismatch silently appends a duplicate instead of updating. +- The CLI can't author a **new** capability's `## Purpose` (it writes a `TBD` placeholder). Put the intended Purpose in the proposal and fill it into the folded spec in the same PR. + ## Testing - Tests are co-located with source as `*.spec.ts` (unit) and `*.integration.spec.ts` (integration), run with Jest. diff --git a/openspec/config.yaml b/openspec/config.yaml index 6de0e731..b3cba3b5 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -19,5 +19,10 @@ rules: tasks: - Write the failing test first, then implement, then refactor (TDD). - Name test cases in should-notation, e.g. it('should return X when Y'). + - Check off the final task only after `openspec validate --type change` passes. A fully-checked tasks.md is the signal that the change is ready to archive, and CI (the "OpenSpec archive gate") then requires the archive commit to be present in this PR before it can merge. proposal: - Cross-check against existing docs in docs/ and note if the proposal supersedes any of them. + specs: + # CI folds deltas by running the `openspec archive` CLI mechanically (no agent), so deltas must fold correctly on their own. + - "The fold matches requirements by exact `### Requirement:` header text against the CURRENT openspec/specs//spec.md. For MODIFIED/REMOVED/RENAMED, copy the existing header verbatim — a mismatched header silently APPENDS a duplicate requirement instead of updating the intended one." + - "`openspec archive` does NOT author a new capability's `## Purpose` — it writes a `TBD - created by archiving` placeholder. When a change introduces a brand-new capability, put the intended Purpose text in the proposal so a human/reviewer fills it into the folded openspec/specs//spec.md in the same PR (the mechanical fold cannot)." diff --git a/package.json b/package.json index 8f56e9f0..95a976bb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "prepare": "husky" }, "devDependencies": { + "@fission-ai/openspec": "1.6.0", "husky": "^9.1.7" } } diff --git a/yarn.lock b/yarn.lock index 127a480f..62a35a5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2387,6 +2387,7 @@ __metadata: version: 0.0.0-use.local resolution: "@editorjs/document@workspace:." dependencies: + "@fission-ai/openspec": "npm:1.6.0" husky: "npm:^9.1.7" languageName: unknown linkType: soft @@ -3101,6 +3102,26 @@ __metadata: languageName: node linkType: hard +"@fission-ai/openspec@npm:1.6.0": + version: 1.6.0 + resolution: "@fission-ai/openspec@npm:1.6.0" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/prompts": "npm:^7.10.1" + chalk: "npm:^5.5.0" + commander: "npm:^14.0.0" + cross-spawn: "npm:7.0.6" + fast-glob: "npm:^3.3.3" + ora: "npm:^8.2.0" + posthog-node: "npm:^5.20.0" + yaml: "npm:^2.8.2" + zod: "npm:^4.0.17" + bin: + openspec: bin/openspec.js + checksum: 0ba08fac346584212b417a071c8fc805fcc47f53ae47f8a855bea924f1ed326eef919e766457ceb8feddd32cb6ff50ae7363e92c092f71f51dd890701a8ba082 + languageName: node + linkType: hard + "@hawk.so/nodejs@npm:^3.1.5": version: 3.1.5 resolution: "@hawk.so/nodejs@npm:3.1.5" @@ -3177,6 +3198,121 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^1.0.2": + version: 1.0.2 + resolution: "@inquirer/ansi@npm:1.0.2" + checksum: d1496e573a63ee6752bcf3fc93375cdabc55b0d60f0588fe7902282c710b223252ad318ff600ee904e48555634663b53fda517f5b29ce9fbda90bfae18592fbc + languageName: node + linkType: hard + +"@inquirer/checkbox@npm:^4.3.2": + version: 4.3.2 + resolution: "@inquirer/checkbox@npm:4.3.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 4ac5dd2679981e23f066c51c605cb1c63ccda9ea6e1ad895e675eb26702aaf6cf961bf5ca3acd832efba5edcf9883b6742002c801673d2b35c123a7fa7db7b23 + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^5.1.21": + version: 5.1.21 + resolution: "@inquirer/confirm@npm:5.1.21" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: a107aa0073965ea510affb9e5b55baf40333503d600970c458c07770cd4e0eee01efc4caba66f0409b0fadc9550d127329622efb543cffcabff3ad0e7f865372 + languageName: node + linkType: hard + +"@inquirer/core@npm:^10.3.2": + version: 10.3.2 + resolution: "@inquirer/core@npm:10.3.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^2.0.0" + signal-exit: "npm:^4.1.0" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: eb434bdf0ae7d904367003c772bcd80cbf679f79c087c99a4949fd7288e9a2f713ec3ea63381b9a001f52389ab56a77fcd88d64d81a03b1195193410ce8971c2 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^4.2.23": + version: 4.2.23 + resolution: "@inquirer/editor@npm:4.2.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/external-editor": "npm:^1.0.3" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: f91b9aadba6ea28a0f4ea5f075af421e076262aebbd737e1b9779f086fa9d559d064e9942a581544645d1dcf56d6b685e8063fe46677880fbca73f6de4e4e7c5 + languageName: node + linkType: hard + +"@inquirer/expand@npm:^4.0.23": + version: 4.0.23 + resolution: "@inquirer/expand@npm:4.0.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 73ad1d6376e5efe2a452c33494d6d16ee2670c638ae470a795fdff4acb59a8e032e38e141f87b603b6e96320977519b375dac6471d86d5e3087a9c1db40e3111 + languageName: node + linkType: hard + +"@inquirer/external-editor@npm:^1.0.3": + version: 1.0.3 + resolution: "@inquirer/external-editor@npm:1.0.3" + dependencies: + chardet: "npm:^2.1.1" + iconv-lite: "npm:^0.7.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: c95d7237a885b32031715089f92820525731d4d3c2bd7afdb826307dc296cc2b39e7a644b0bb265441963348cca42e7785feb29c3aaf18fd2b63131769bf6587 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.15": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 3f858807f361ca29f41ec1076bbece4098cc140d86a06159d42c6e3f6e4d9bec9e10871ccfcbbaa367d6a8462b01dff89f2b1b157d9de6e8726bec85533f525c + languageName: node + linkType: hard + "@inquirer/figures@npm:^1.0.3": version: 1.0.11 resolution: "@inquirer/figures@npm:1.0.11" @@ -3184,6 +3320,138 @@ __metadata: languageName: node linkType: hard +"@inquirer/input@npm:^4.3.1": + version: 4.3.1 + resolution: "@inquirer/input@npm:4.3.1" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 713aaa4c94263299fbd7adfd65378f788cac1b5047f2b7e1ea349ca669db6c7c91b69ab6e2f6660cdbc28c7f7888c5c77ab4433bd149931597e43976d1ba5f34 + languageName: node + linkType: hard + +"@inquirer/number@npm:^3.0.23": + version: 3.0.23 + resolution: "@inquirer/number@npm:3.0.23" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 50694807b71746e15ed69d100aae3c8014d83c90aa660e8a179fe0db1046f26d727947542f64e24cc8b969a61659cb89fe36208cc2b59c1816382b598e686dd2 + languageName: node + linkType: hard + +"@inquirer/password@npm:^4.0.23": + version: 4.0.23 + resolution: "@inquirer/password@npm:4.0.23" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 97364970b01c85946a4a50ad876c53ef0c1857a9144e24fad65e5dfa4b4e5dd42564fbcdfa2b49bb049a25d127efbe0882cb18afcdd47b166ebd01c6c4b5e825 + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^7.10.1": + version: 7.10.1 + resolution: "@inquirer/prompts@npm:7.10.1" + dependencies: + "@inquirer/checkbox": "npm:^4.3.2" + "@inquirer/confirm": "npm:^5.1.21" + "@inquirer/editor": "npm:^4.2.23" + "@inquirer/expand": "npm:^4.0.23" + "@inquirer/input": "npm:^4.3.1" + "@inquirer/number": "npm:^3.0.23" + "@inquirer/password": "npm:^4.0.23" + "@inquirer/rawlist": "npm:^4.1.11" + "@inquirer/search": "npm:^3.2.2" + "@inquirer/select": "npm:^4.4.2" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: b3e3386edd255e4e91c7908050674f8a2e69b043883c00feec2f87d697be37bc6e8cd4a360e7e3233a9825ae7ea044a2ac63d5700926d27f9959013d8566f890 + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^4.1.11": + version: 4.1.11 + resolution: "@inquirer/rawlist@npm:4.1.11" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 0d8f6484cfc20749190e95eecfb2d034bafb3644ec4907b84b1673646f5dd71730e38e35565ea98dfd240d8851e3cff653edafcc4e0af617054b127b407e3229 + languageName: node + linkType: hard + +"@inquirer/search@npm:^3.2.2": + version: 3.2.2 + resolution: "@inquirer/search@npm:3.2.2" + dependencies: + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: abaed2df7763633ff4414b58d1c87233b69ed3cd2ac77629f0d54b72b8b585dc4806c7a2a8261daba58af5b0a2147e586d079fdc82060b6bcf56b75d3d03f3a7 + languageName: node + linkType: hard + +"@inquirer/select@npm:^4.4.2": + version: 4.4.2 + resolution: "@inquirer/select@npm:4.4.2" + dependencies: + "@inquirer/ansi": "npm:^1.0.2" + "@inquirer/core": "npm:^10.3.2" + "@inquirer/figures": "npm:^1.0.15" + "@inquirer/type": "npm:^3.0.10" + yoctocolors-cjs: "npm:^2.1.3" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 795ec0ac77d575f20bd6a12fb1c040093e62217ac0c80194829a8d3c3d1e09f70ad738e9a9dd6095cc8358fff4e13882209c09bdf8eb0864a86dcabef5b0a6a6 + languageName: node + linkType: hard + +"@inquirer/type@npm:^3.0.10": + version: 3.0.10 + resolution: "@inquirer/type@npm:3.0.10" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 57d113a9db7abc73326491e29bedc88ef362e53779f9f58a1b61225e0be068ce0c54e33cd65f4a13ca46131676fb72c3ef488463c4c9af0aa89680684c55d74c + languageName: node + linkType: hard + "@inversifyjs/common@npm:2.0.1": version: 2.0.1 resolution: "@inversifyjs/common@npm:2.0.1" @@ -3721,6 +3989,22 @@ __metadata: languageName: node linkType: hard +"@posthog/core@npm:^1.44.0": + version: 1.45.0 + resolution: "@posthog/core@npm:1.45.0" + dependencies: + "@posthog/types": "npm:^1.398.0" + checksum: ac935a89b30940d267f44948a7b888d0d61f5a562c63ec596aac811a7ec0515136e3dde5a0411ee5f2f60a81c1b778f668fefa47b11389453ae7fed806687eeb + languageName: node + linkType: hard + +"@posthog/types@npm:^1.398.0": + version: 1.398.0 + resolution: "@posthog/types@npm:1.398.0" + checksum: 21640eca787a8dc930062af9db194e95f538a31eb7ae90e8471ddd071cc86e6d38fc088e7a9544ea185336418788e84dbf08f3f604e2e68853472149c0bb22b3 + languageName: node + linkType: hard + "@rollup/pluginutils@npm:^5.1.0": version: 5.1.4 resolution: "@rollup/pluginutils@npm:5.1.4" @@ -4818,6 +5102,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^6.2.2": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 9b17ce2c6daecc75bcd5966b9ad672c23b184dc3ed9bf3c98a0702f0d2f736c15c10d461913568f2cf527a5e64291c7473358885dd493305c84a1cfed66ba94f + languageName: node + linkType: hard + "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -5393,6 +5684,13 @@ __metadata: languageName: node linkType: hard +"chalk@npm:^5.5.0": + version: 5.6.2 + resolution: "chalk@npm:5.6.2" + checksum: 1b2f48f6fba1370670d5610f9cd54c391d6ede28f4b7062dd38244ea5768777af72e5be6b74fb6c6d54cb84c4a2dff3f3afa9b7cb5948f7f022cfd3d087989e0 + languageName: node + linkType: hard + "chalk@npm:~5.3.0": version: 5.3.0 resolution: "chalk@npm:5.3.0" @@ -5414,6 +5712,13 @@ __metadata: languageName: node linkType: hard +"chardet@npm:^2.1.1": + version: 2.2.0 + resolution: "chardet@npm:2.2.0" + checksum: 78d1e5ba7bdc7db23511a194eedca805918df90a511f9c57f9cc7c1fc03702ed4ad053e485a766ace930362869269c4a731feaf460ae24bd3ed7d190cde8087d + languageName: node + linkType: hard + "chownr@npm:^3.0.0": version: 3.0.0 resolution: "chownr@npm:3.0.0" @@ -5453,7 +5758,16 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.5.0": +"cli-cursor@npm:^5.0.0": + version: 5.0.0 + resolution: "cli-cursor@npm:5.0.0" + dependencies: + restore-cursor: "npm:^5.0.0" + checksum: 1eb9a3f878b31addfe8d82c6d915ec2330cec8447ab1f117f4aa34f0137fbb3137ec3466e1c9a65bcb7557f6e486d343f2da57f253a2f668d691372dfa15c090 + languageName: node + linkType: hard + +"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 @@ -5559,6 +5873,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^14.0.0": + version: 14.0.3 + resolution: "commander@npm:14.0.3" + checksum: dfa9ebe2a433d277de5cb0252d23b10a543d245d892db858d23b516336a835c50fd4f52bee4cd13c705cc8acb6f03dc632c73dd806f7d06d3353eb09953dd17a + languageName: node + linkType: hard + "commander@npm:^7.2.0": version: 7.2.0 resolution: "commander@npm:7.2.0" @@ -5670,7 +5991,7 @@ __metadata: languageName: node linkType: hard -"cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": +"cross-spawn@npm:7.0.6, cross-spawn@npm:^7.0.2, cross-spawn@npm:^7.0.3, cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" dependencies: @@ -6133,6 +6454,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^10.3.0": + version: 10.6.0 + resolution: "emoji-regex@npm:10.6.0" + checksum: 98cc0b0e1daed1ed25afbf69dcb921fee00f712f51aab93aa1547e4e4e8171725cc4f0098aaa645b4f611a19da11ec9f4623eb6ff2b72314b39a8f2ae7c12bf2 + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -7031,7 +7359,7 @@ __metadata: languageName: node linkType: hard -"fast-glob@npm:^3.3.2": +"fast-glob@npm:^3.3.2, fast-glob@npm:^3.3.3": version: 3.3.3 resolution: "fast-glob@npm:3.3.3" dependencies: @@ -7339,6 +7667,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.0.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 3e5370b5df1f0020db711d8a3f9ee2cbfc9c7542daa99a699e9d7b9acf66e7868b89084741565a45d30d80afedf6e1218e0fb8bef7a583924a449c2816777380 + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" @@ -7687,6 +8022,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.0": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: "npm:>= 2.1.2 < 3.0.0" + checksum: b88a3f4527795c96854743bcdcdd03e0083ff5f1c072cb13d3e27c67cd276efd9a2a364922cb836538e976456f82323d645f7ea91d5bc8da1f000dc16742a0aa + languageName: node + linkType: hard + "icss-replace-symbols@npm:^1.1.0": version: 1.1.0 resolution: "icss-replace-symbols@npm:1.1.0" @@ -8016,6 +8360,13 @@ __metadata: languageName: node linkType: hard +"is-interactive@npm:^2.0.0": + version: 2.0.0 + resolution: "is-interactive@npm:2.0.0" + checksum: e8d52ad490bed7ae665032c7675ec07732bbfe25808b0efbc4d5a76b1a1f01c165f332775c63e25e9a03d319ebb6b24f571a9e902669fc1e40b0a60b5be6e26c + languageName: node + linkType: hard + "is-map@npm:^2.0.3": version: 2.0.3 resolution: "is-map@npm:2.0.3" @@ -8126,6 +8477,20 @@ __metadata: languageName: node linkType: hard +"is-unicode-supported@npm:^1.3.0": + version: 1.3.0 + resolution: "is-unicode-supported@npm:1.3.0" + checksum: 20a1fc161afafaf49243551a5ac33b6c4cf0bbcce369fcd8f2951fbdd000c30698ce320de3ee6830497310a8f41880f8066d440aa3eb0a853e2aa4836dd89abc + languageName: node + linkType: hard + +"is-unicode-supported@npm:^2.0.0": + version: 2.1.0 + resolution: "is-unicode-supported@npm:2.1.0" + checksum: f254e3da6b0ab1a57a94f7273a7798dd35d1d45b227759f600d0fa9d5649f9c07fa8d3c8a6360b0e376adf916d151ec24fc9a50c5295c58bae7ca54a76a063f9 + languageName: node + linkType: hard + "is-weakmap@npm:^2.0.2": version: 2.0.2 resolution: "is-weakmap@npm:2.0.2" @@ -8992,6 +9357,16 @@ __metadata: languageName: node linkType: hard +"log-symbols@npm:^6.0.0": + version: 6.0.0 + resolution: "log-symbols@npm:6.0.0" + dependencies: + chalk: "npm:^5.3.0" + is-unicode-supported: "npm:^1.3.0" + checksum: 510cdda36700cbcd87a2a691ea08d310a6c6b449084018f7f2ec4f732ca5e51b301ff1327aadd96f53c08318e616276c65f7fe22f2a16704fb0715d788bc3c33 + languageName: node + linkType: hard + "log4js@npm:~6.9.0": version: 6.9.1 resolution: "log4js@npm:6.9.1" @@ -9142,6 +9517,13 @@ __metadata: languageName: node linkType: hard +"mimic-function@npm:^5.0.0": + version: 5.0.1 + resolution: "mimic-function@npm:5.0.1" + checksum: eb5893c99e902ccebbc267c6c6b83092966af84682957f79313311edb95e8bb5f39fb048d77132b700474d1c86d90ccc211e99bae0935447a4834eb4c882982c + languageName: node + linkType: hard + "minimatch@npm:^3.0.4, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -9337,6 +9719,13 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^2.0.0": + version: 2.0.0 + resolution: "mute-stream@npm:2.0.0" + checksum: d2e4fd2f5aa342b89b98134a8d899d8ef9b0a6d69274c4af9df46faa2d97aeb1f2ce83d867880d6de63643c52386579b99139801e24e7526c3b9b0a6d1e18d6c + languageName: node + linkType: hard + "nanoid@npm:^3.3.11, nanoid@npm:^3.3.8": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -9568,6 +9957,15 @@ __metadata: languageName: node linkType: hard +"onetime@npm:^7.0.0": + version: 7.0.0 + resolution: "onetime@npm:7.0.0" + dependencies: + mimic-function: "npm:^5.0.0" + checksum: eb08d2da9339819e2f9d52cab9caf2557d80e9af8c7d1ae86e1a0fef027d00a88e9f5bd67494d350df360f7c559fbb44e800b32f310fb989c860214eacbb561c + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.4 resolution: "optionator@npm:0.9.4" @@ -9599,6 +9997,23 @@ __metadata: languageName: node linkType: hard +"ora@npm:^8.2.0": + version: 8.2.0 + resolution: "ora@npm:8.2.0" + dependencies: + chalk: "npm:^5.3.0" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^2.9.2" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.0.0" + log-symbols: "npm:^6.0.0" + stdin-discarder: "npm:^0.2.2" + string-width: "npm:^7.2.0" + strip-ansi: "npm:^7.1.0" + checksum: cea932fdcb29549cd7b5af81f427760986429cadc752b1dd4bf31bc6821f5ba137e1ef9a18cde7bdfbe5b4e3d3201e76b048765c51a27b15d18c57ac0e0a909a + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -10676,6 +11091,20 @@ __metadata: languageName: node linkType: hard +"posthog-node@npm:^5.20.0": + version: 5.46.0 + resolution: "posthog-node@npm:5.46.0" + dependencies: + "@posthog/core": "npm:^1.44.0" + peerDependencies: + rxjs: ^7.0.0 + peerDependenciesMeta: + rxjs: + optional: true + checksum: 7d0a0b563b9ddeb456754fb2bfb85493424a5d0d3c287e977c6e34e2eb93cab9a4d1874668ab4102e0f03cbe1e12785e9690344fba820ae5086e95d2c4ed7629 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -11020,6 +11449,16 @@ __metadata: languageName: node linkType: hard +"restore-cursor@npm:^5.0.0": + version: 5.1.0 + resolution: "restore-cursor@npm:5.1.0" + dependencies: + onetime: "npm:^7.0.0" + signal-exit: "npm:^4.1.0" + checksum: 838dd54e458d89cfbc1a923b343c1b0f170a04100b4ce1733e97531842d7b440463967e521216e8ab6c6f8e89df877acc7b7f4c18ec76e99fb9bf5a60d358d2c + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -11584,6 +12023,13 @@ __metadata: languageName: node linkType: hard +"stdin-discarder@npm:^0.2.2": + version: 0.2.2 + resolution: "stdin-discarder@npm:0.2.2" + checksum: 642ffd05bd5b100819d6b24a613d83c6e3857c6de74eb02fc51506fa61dc1b0034665163831873868157c4538d71e31762bcf319be86cea04c3aba5336470478 + languageName: node + linkType: hard + "streamroller@npm:^3.1.5": version: 3.1.5 resolution: "streamroller@npm:3.1.5" @@ -11651,6 +12097,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^7.2.0": + version: 7.2.0 + resolution: "string-width@npm:7.2.0" + dependencies: + emoji-regex: "npm:^10.3.0" + get-east-asian-width: "npm:^1.0.0" + strip-ansi: "npm:^7.1.0" + checksum: 42f9e82f61314904a81393f6ef75b832c39f39761797250de68c041d8ba4df2ef80db49ab6cd3a292923a6f0f409b8c9980d120f7d32c820b4a8a84a2598a295 + languageName: node + linkType: hard + "string.prototype.trim@npm:^1.2.10": version: 1.2.10 resolution: "string.prototype.trim@npm:1.2.10" @@ -11734,6 +12191,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^7.1.0": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 96da3bc6d73cfba1218625a3d66cf7d37a69bf0920d8735b28f9eeaafcdb6c1fe8440e1ae9eb1ba0ca355dbe8702da872e105e2e939fa93e7851b3cb5dd7d316 + languageName: node + linkType: hard + "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -12829,6 +13295,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.8.2": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -12865,6 +13340,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.3": + version: 2.1.3 + resolution: "yoctocolors-cjs@npm:2.1.3" + checksum: b2144b38807673a4254dae06fe1a212729550609e606289c305e45c585b36fab1dbba44fe6cde90db9b28be465ec63f4c2a50867aeec6672f6bc36b6c9a361a0 + languageName: node + linkType: hard + "z-schema@npm:~5.0.2": version: 5.0.5 resolution: "z-schema@npm:5.0.5" @@ -12881,3 +13363,10 @@ __metadata: checksum: 8ac2fa445f5a00e790d1f91a48aeff0ccfc340f84626771853e03f4d97cdc2f5f798cdb2e38418f7815ffc3aac3952c45caabcf077bf4f83fedf0cdef43b885b languageName: node linkType: hard + +"zod@npm:^4.0.17": + version: 4.4.3 + resolution: "zod@npm:4.4.3" + checksum: 804b9a42aa8f35f2b3c5a8dff906291cb749115f83ee2afe3576d70b5b5c53c965365c7f4967690647a9c54af9838ff232a85ff9577a0a36c44b68bc6cdefe36 + languageName: node + linkType: hard