diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..696811f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Check out LF everywhere. Windows runners default to autocrlf=true, which +# rewrites every file to CRLF and makes `prettier --check` fail on all of them. +* text=auto eol=lf + +# Sprites are width-sensitive art; never let a filter touch them. +src/ui/sprites/*.ts -text +*.gif binary diff --git a/.github/workflows/action-smoke.yml b/.github/workflows/action-smoke.yml new file mode 100644 index 0000000..e2db697 --- /dev/null +++ b/.github/workflows/action-smoke.yml @@ -0,0 +1,61 @@ +name: Action smoke + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + smoke: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + permissions: + contents: read + actions: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # the pet reads commit history + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + + - id: pet + uses: ./ + with: + version: local + card: gitgotchi-card.svg + cache: 'false' # a fresh egg every run keeps the assertions stable + + - name: the action produced a live pet + shell: bash + env: + STAGE: ${{ steps.pet.outputs.stage }} + MOOD: ${{ steps.pet.outputs.mood }} + HEALTH: ${{ steps.pet.outputs.health }} + CARD: ${{ steps.pet.outputs.card-path }} + run: | + set -euo pipefail + [ -n "$STAGE" ] || { echo "no stage output"; exit 1; } + [ -n "$MOOD" ] || { echo "no mood output"; exit 1; } + case "$HEALTH" in ''|*[!0-9]*) echo "health not a number: '$HEALTH'"; exit 1 ;; esac + [ "$CARD" = "gitgotchi-card.svg" ] || { echo "card-path was '$CARD'"; exit 1; } + grep -q '/dev/null && npm install "$tarball" >/dev/null) + + cd "$scratch/repo" + bin="$scratch/node_modules/.bin/gitgotchi" + "$bin" --version + "$bin" --report "$scratch/pet.json" | tee "$scratch/out.txt" + grep -q health "$scratch/out.txt" + node -e 'const s=require(process.argv[1]); if (!s.vitals) { throw new Error("no vitals") }' \ + "$scratch/pet.json" + test -z "$(git status --porcelain)" || { echo "the CLI dirtied the repo"; exit 1; } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7fb212 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,38 @@ +name: Release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm provenance + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + registry-url: https://registry.npmjs.org + + - run: npm ci + - run: npm run typecheck + - run: npm run lint + - run: npm test + + - name: the tag matches package.json + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME#v}" + pkg="$(node -p 'require("./package.json").version')" + [ "$tag" = "$pkg" ] || { echo "tag $tag != package.json $pkg"; exit 1; } + + - run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index c417070..4a45126 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ node_modules/ dist/ coverage/ .gitgotchi/ +gitgotchi-card.svg +*.tgz *.log diff --git a/CLAUDE.md b/CLAUDE.md index 1d8705a..428062d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,8 @@ Full product/architecture spec: `PLAN.md`. Milestones: `docs/milestones/`. Work 1. **Zero-config first run.** `npx gitgotchi` in any git repo must produce a living creature in under 5 seconds using only local signals (git + grep). CI and coverage are progressive enhancements. 2. **Ambient, not nagging.** Never block, never exit non-zero because of repo state, never lecture. The pet's face is the only feedback mechanism. 3. **The creature is the UI.** Sprite + one flavor line first; numbers second. -4. **Local-first.** No accounts, no server, no telemetry. State lives in `.gitgotchi/state.json` (gitignored automatically). +4. **Local-first and read-only.** No accounts, no server, no telemetry. Gitgotchi never writes into the repo it observes — state lives outside the working tree (`GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else `~/.local/state/gitgotchi/`, `%LOCALAPPDATA%` on Windows), keyed by a hash of the repo path. Pre-0.2 pets at `.gitgotchi/state.json` are read once for migration and never written. +5. **One run, one check-in.** A single invocation ticks the pet exactly once; `--report` and `--card` are extra renderings of that tick, never extra ticks. ## Development process — TDD is mandatory diff --git a/PLAN.md b/PLAN.md index 2809237..118f083 100644 --- a/PLAN.md +++ b/PLAN.md @@ -24,7 +24,7 @@ This document is a Claude Code-ready build plan: architecture, data model, and s | Git access | `simple-git` | Battle-tested, promise-based | | CI/GitHub data | `@octokit/rest` (optional, token via `gh auth token` fallback) | Progressive enhancement | | Coverage parsing | Own parsers for `lcov.info` + `coverage-summary.json` | Two formats cover ~90% of JS/TS repos | -| Config/state | `zod`-validated JSON at `.gitgotchi/state.json` | Schema-first, safe migrations | +| Config/state | `zod`-validated JSON outside the repo (`GITGOTCHI_STATE_DIR` / `$XDG_STATE_HOME/gitgotchi/`) | Schema-first, safe migrations, never writes to the observed repo | | Share card | `satori` (JSX → SVG) + optional `sharp` (SVG → PNG) | No headless browser | | Tests | **Vitest** + ink-testing-library | Fast, TS-native | | Lint/format | ESLint + Prettier | Standard | @@ -50,7 +50,7 @@ src/ evolution.ts # lifetime stats -> Species/Stage (pure) decay.ts # time-since-last-check decay (pure) state/ - store.ts # load/save/migrate .gitgotchi/state.json (zod) + store.ts # load/save/migrate state.json outside the repo (zod) history.ts # ring buffer of last N snapshots ui/ App.tsx # Ink root: layout, refresh loop @@ -154,7 +154,7 @@ Pure functions only. Table-driven tests. - **DoD:** 100% line coverage on `engine/` (it's pure — no excuse); running the engine twice on identical input is byte-identical. ### M3 — State store (½ day) -- `store.ts`: load → zod-validate → migrate → save to `.gitgotchi/state.json`; auto-append to `.gitignore` on first run (ask nothing). +- `store.ts`: load → zod-validate → migrate → save to a per-repo slot outside the working tree (`GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else `~/.local/state/gitgotchi/`). A pre-0.2 `.gitgotchi/state.json` is read once for migration and never written back. - `history.ts`: ring buffer of 50 snapshots. - Tests: missing file → newborn state; corrupt JSON → backup to `.bak` + newborn (never crash, never silently overwrite); schemaVersion bump path. - **DoD:** kill -9 during save can't corrupt state (write-to-temp + atomic rename, tested). diff --git a/README.md b/README.md index c6eb8e4..fbac2f1 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ npx gitgotchi - [Life stages](#life-stages) - [How it works](#how-it-works) - [The share card](#the-share-card) +- [In GitHub Actions](#in-github-actions) - [FAQ](#faq) - [Development](#development) @@ -43,6 +44,15 @@ npx gitgotchi rename Pixel npx gitgotchi --json # machine-readable state, for status bars ``` +One run is one check-in. `--report pet.json` and `--card pet.svg` are extra +renderings of that same check-in, so scripts can have the numbers, the picture, +and the human block without the pet ageing three times: + +```bash +npx gitgotchi --report pet.json --card pet.svg +npx gitgotchi card -o - # SVG straight to stdout +``` + Zero config. Runs in any git repo using only local signals (git + grep) in a few seconds. CI and coverage are automatic bonuses when they're available. @@ -117,14 +127,95 @@ mood and stage. ## The share card `gitgotchi card` renders a 1200×630 SVG of your pet + stats — the thing you post. -It's deterministic (same state → identical bytes) and needs no browser. Add -`--png` to rasterize via `sharp` if you have it installed; otherwise you get the -SVG and a friendly note. +It needs no browser, and it's deterministic: the flavor line is seeded by the +pet's condition rather than the clock, so re-rendering an unchanged pet gives +byte-identical output. Add `--png` to rasterize via `sharp` if you have it +installed; otherwise you get the SVG and a friendly note. + +## In GitHub Actions + +The action checks in on your pet and puts it in the **job summary** — nothing is +committed, nothing is pushed, and the working tree is never written to. + +```yaml +- uses: actions/checkout@v4 + with: + fetch-depth: 0 # the pet reads commit history +- uses: dvd90/gitgotchi@v1 +``` + +That's the whole thing. `actions/cache` keeps the pet alive between runs, so it +actually grows up instead of hatching every time. + +**Keep the card as a downloadable artifact:** + +```yaml +- uses: dvd90/gitgotchi@v1 + id: pet + with: + card: gitgotchi-card.svg +- uses: actions/upload-artifact@v4 + with: + name: gitgotchi-card + path: ${{ steps.pet.outputs.card-path }} +``` + +**Commit the card to a branch** — opt in explicitly, since this is the one +workflow that writes: + +```yaml +permissions: + contents: write +steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: dvd90/gitgotchi@v1 + with: + card: .github/gitgotchi-card.svg + - run: | + git config user.name "gitgotchi" + git config user.email "gitgotchi@users.noreply.github.com" + git add .github/gitgotchi-card.svg + git diff --staged --quiet || git commit -m "chore: update gitgotchi card" + git push +``` + +The card is seeded by the pet's condition, not by when you looked, so an +unchanged pet renders identical bytes and `git diff --staged --quiet` skips the +commit. The day counter still ticks, so expect roughly one commit a day rather +than one per push. + +### Inputs + +| Input | Default | What it does | +| -------------- | --------------------- | ------------------------------------------------------------- | +| `path` | `.` | Repository directory to check in on | +| `version` | `latest` | npm version to run; `local` builds the checked-out copy | +| `cache` | `true` | Persist the pet between runs via `actions/cache` | +| `summary` | `true` | Write the pet to the job summary | +| `card` | _(none)_ | Workspace-relative path for the card SVG | +| `github-token` | `${{ github.token }}` | Read Actions status; needs `actions: read`, pass `''` to skip | + +### Outputs + +`name`, `species`, `stage`, `mood`, `health`, `hunger`, `hygiene`, `social`, +`report` (path to the full state JSON), `card-path`. + +```yaml +- if: steps.pet.outputs.mood == 'critical' + run: echo "the repo is not okay" +``` ## FAQ **Does it send my data anywhere?** No. No account, no server, no telemetry. -State lives in `.gitgotchi/state.json` (added to your `.gitignore` automatically). + +**Does it touch my repo?** No. Gitgotchi only reads. Your pet lives outside the +working tree, under `$XDG_STATE_HOME/gitgotchi/` (`~/.local/state/gitgotchi/` by +default, `%LOCALAPPDATA%\gitgotchi\` on Windows), keyed by the repo's path. Set +`GITGOTCHI_STATE_DIR` to put it somewhere else. Pets from before 0.2 that live +in `.gitgotchi/state.json` are picked up automatically and move on the next +check-in; the old file is left alone and can be deleted. **Can it block or fail my build?** Never, by design. It always exits 0 and never lectures. Guilt is delivered exclusively through sad eyes. diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..8f3dcbd --- /dev/null +++ b/action.yml @@ -0,0 +1,183 @@ +name: Gitgotchi +description: Check in on your repo's pet and show it in the job summary. Read-only. +author: dvd90 + +branding: + icon: heart + color: green + +inputs: + path: + description: Repository directory to check in on. + required: false + default: . + version: + description: > + npm version spec to run (e.g. latest, 0.1.1). Use `local` to build and run + the copy of gitgotchi checked out with this action. + required: false + default: latest + cache: + description: > + Persist the pet between runs with actions/cache. Without it the pet is a + newborn egg on every run. + required: false + default: 'true' + summary: + description: Write the pet to the job summary. + required: false + default: 'true' + card: + description: > + Path to write the share-card SVG to. Empty means no card. The path is + resolved inside the workspace; nothing is committed. + required: false + default: '' + github-token: + description: > + Token for the CI collector. Needs `actions: read` only. Pass an empty + string to skip the CI signal entirely. + required: false + default: ${{ github.token }} + +outputs: + name: + description: The pet's name. + value: ${{ steps.checkin.outputs.name }} + species: + description: The pet's species. + value: ${{ steps.checkin.outputs.species }} + stage: + description: Life stage — egg, baby, kid, adult or elder. + value: ${{ steps.checkin.outputs.stage }} + mood: + description: Mood — thriving, content, hungry, grubby, lonely, sick or critical. + value: ${{ steps.checkin.outputs.mood }} + health: + description: Health, 0-100. + value: ${{ steps.checkin.outputs.health }} + hunger: + description: Hunger, 0-100. + value: ${{ steps.checkin.outputs.hunger }} + hygiene: + description: Hygiene, 0-100. + value: ${{ steps.checkin.outputs.hygiene }} + social: + description: Social, 0-100. + value: ${{ steps.checkin.outputs.social }} + report: + description: Path to the full state JSON for this run. + value: ${{ steps.checkin.outputs.report }} + card-path: + description: Workspace-relative path to the card SVG, or empty when no card was asked for. + value: ${{ steps.checkin.outputs.card-path }} + +runs: + using: composite + steps: + # State lives outside the repo, so a check-in never dirties the working tree. + - id: setup + shell: bash + run: | + set -euo pipefail + echo "state-dir=${RUNNER_TEMP}/gitgotchi-state" >> "$GITHUB_OUTPUT" + echo "report=${RUNNER_TEMP}/gitgotchi-report.json" >> "$GITHUB_OUTPUT" + echo "out=${RUNNER_TEMP}/gitgotchi-out.txt" >> "$GITHUB_OUTPUT" + + - if: inputs.cache == 'true' + uses: actions/cache@v4 + with: + path: ${{ steps.setup.outputs.state-dir }} + key: gitgotchi-state-${{ github.repository }}-${{ github.run_id }} + restore-keys: | + gitgotchi-state-${{ github.repository }}- + + - id: build-local + if: inputs.version == 'local' + shell: bash + env: + ACTION_PATH: ${{ github.action_path }} + run: | + set -euo pipefail + npm ci --prefix "$ACTION_PATH" + npm run build --prefix "$ACTION_PATH" + + # Inputs reach the shell through env, never through ${{ }} interpolation + # inside the script — an input is untrusted text, not code. + - id: checkin + shell: bash + env: + GITGOTCHI_STATE_DIR: ${{ steps.setup.outputs.state-dir }} + GITGOTCHI_GITHUB_TOKEN: ${{ inputs.github-token }} + REPO_PATH: ${{ inputs.path }} + VERSION: ${{ inputs.version }} + ACTION_PATH: ${{ github.action_path }} + REPORT: ${{ steps.setup.outputs.report }} + OUT: ${{ steps.setup.outputs.out }} + CARD: ${{ inputs.card }} + run: | + set -euo pipefail + mkdir -p "$GITGOTCHI_STATE_DIR" + + # Keep the card inside the workspace: an input must not be able to + # write anywhere on the runner. + card_args=() + card_rel="" + if [ -n "$CARD" ]; then + case "$CARD" in + /* | *..*) + echo "::warning::card path must be relative and free of '..' — skipping the card" ;; + *) + card_rel="$CARD" + mkdir -p "$(dirname "$GITHUB_WORKSPACE/$CARD")" + card_args=(--card "$GITHUB_WORKSPACE/$CARD") ;; + esac + fi + + if [ "$VERSION" = "local" ]; then + run_gitgotchi() { node "$ACTION_PATH/dist/cli.js" "$@"; } + else + run_gitgotchi() { npx -y "gitgotchi@$VERSION" "$@"; } + fi + + # gitgotchi never exits non-zero, but never let it fail the job either. + # One invocation: block, JSON and card all come from the same check-in. + cd "$REPO_PATH" + run_gitgotchi --report "$REPORT" "${card_args[@]+"${card_args[@]}"}" > "$OUT" || true + echo "report=$REPORT" >> "$GITHUB_OUTPUT" + echo "out=$OUT" >> "$GITHUB_OUTPUT" + if [ -n "$card_rel" ] && [ -f "$GITHUB_WORKSPACE/$card_rel" ]; then + echo "card-path=$card_rel" >> "$GITHUB_OUTPUT" + fi + + node -e ' + const fs = require("fs"); + const out = process.env.GITHUB_OUTPUT; + let s; + try { s = JSON.parse(fs.readFileSync(process.env.REPORT, "utf8")); } + catch { process.exit(0); } + const put = (k, v) => fs.appendFileSync(out, `${k}=${String(v)}\n`); + put("name", s.name); + put("species", s.species); + put("stage", s.stage); + put("mood", s.mood); + for (const k of ["health", "hunger", "hygiene", "social"]) put(k, s.vitals[k]); + ' + + - id: summary + if: inputs.summary == 'true' + shell: bash + env: + OUT: ${{ steps.checkin.outputs.out }} + run: | + set -euo pipefail + [ -s "$OUT" ] || exit 0 + { + echo '### 🥚 Gitgotchi' + echo + echo '```' + cat "$OUT" + echo '```' + echo + echo '_Meet yours: `npx gitgotchi`_' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f983de3..a56c1f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -62,7 +62,17 @@ threshold. - `store.ts` — `load` validates and migrates; on corrupt/invalid input it copies the broken file to `state.json.bak` and returns a newborn. `save` writes `state.json.tmp` then `rename`s over the real file (atomic — a crash can never - leave partial JSON) and appends `.gitgotchi/` to `.gitignore` once. + leave partial JSON). + State lives **outside** the repo, so observing a repo never writes to it: + `GITGOTCHI_STATE_DIR`, else `$XDG_STATE_HOME/gitgotchi/`, else + `~/.local/state/gitgotchi/` (`%LOCALAPPDATA%\gitgotchi\` on Windows). Each repo + gets a `-` slot, so two checkouts named + `api` never collide. A pre-0.2 `.gitgotchi/state.json` is read once for + migration and never written back. +- `name.ts` — `safeName`/`stripControl` scrub control bytes out of anything a + user typed, at the persistence boundary and again at the SVG boundary. A pet + name is rendered into a terminal and into a shared card; neither should ever + see an escape sequence. - `history.ts` — a 50-entry snapshot ring buffer. ### `src/ui/` — the creature is the UI diff --git a/docs/gitgotchi-card.svg b/docs/gitgotchi-card.svg index 0f0eac8..5cde52e 100644 --- a/docs/gitgotchi-card.svg +++ b/docs/gitgotchi-card.svg @@ -22,5 +22,6 @@ 100 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi \ No newline at end of file diff --git a/docs/manual-tests.md b/docs/manual-tests.md index a07584a..8a42326 100644 --- a/docs/manual-tests.md +++ b/docs/manual-tests.md @@ -79,7 +79,24 @@ node "$GG" | cat # non-TTY: plain block, no color/animation ```bash node "$GG" totally-bogus; echo "exit=$?" # friendly line, exit=0 (never non-zero) -echo '{bad json' > "$d/.gitgotchi/state.json" -node "$GG"; ls "$d/.gitgotchi/" # recovers to egg, keeps state.json.bak -cat "$d/.gitignore" # contains .gitgotchi/ (added automatically) + +# State lives outside the repo. Point it somewhere disposable to poke at it: +export GITGOTCHI_STATE_DIR="$(mktemp -d)" +node "$GG" >/dev/null +slot="$(ls "$GITGOTCHI_STATE_DIR")" +echo '{bad json' > "$GITGOTCHI_STATE_DIR/$slot/state.json" +node "$GG"; ls "$GITGOTCHI_STATE_DIR/$slot" # recovers to egg, keeps state.json.bak + +git -C "$d" status --porcelain # empty: the repo is never written to +ls -a "$d" | grep -c .gitgotchi || true # 0: no state dir inside the repo +``` + +## One run, one check-in + +```bash +node "$GG" --report pet.json --card pet.svg # block on stdout, JSON + SVG on disk +jq .lifetime.checkIns pet.json # goes up by exactly 1 per invocation +node "$GG" card -o - | head -1 # SVG straight to stdout +node "$GG" card -o; echo "exit=$?" # friendly line about -o, exit=0 +node "$GG" watch -i nope; echo "exit=$?" # friendly line about -i, exit=0 ``` diff --git a/package.json b/package.json index 2d7ce3d..a7c132d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gitgotchi", - "version": "0.1.1", + "version": "0.2.0", "description": "A codebase Tamagotchi that lives in your terminal.", "keywords": [ "git", @@ -24,6 +24,10 @@ "url": "git+https://github.com/dvd90/gitgotchi.git" }, "homepage": "https://github.com/dvd90/gitgotchi#readme", + "publishConfig": { + "access": "public", + "provenance": true + }, "engines": { "node": ">=20" }, @@ -34,7 +38,7 @@ "test:watch": "vitest", "lint": "eslint . && prettier --check .", "format": "prettier --write .", - "prepublishOnly": "npm run build" + "prepack": "npm run build" }, "dependencies": { "@octokit/rest": "^22.0.1", @@ -64,5 +68,8 @@ "typescript-eslint": "^8.0.0", "vitest": "^2.0.5" }, - "license": "MIT" + "license": "MIT", + "bugs": { + "url": "https://github.com/dvd90/gitgotchi/issues" + } } diff --git a/src/cli.ts b/src/cli.ts index 14fdedf..0c01542 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ Usage: gitgotchi init name your pet gitgotchi rename rename your pet gitgotchi --json machine-readable state (for status bars) + gitgotchi --report p check in once; block on stdout, JSON to p gitgotchi --version print the version gitgotchi --help show this help @@ -27,7 +28,11 @@ export interface Io { } /** Parse args and dispatch. Never exits non-zero (ambient principle). */ -export async function main(argv: string[], io: Io = { out: console.log }): Promise { +export async function main( + argv: string[], + io: Io = { out: console.log }, + repoPath: string = process.cwd(), +): Promise { const [cmd, ...rest] = argv; if (cmd === '--version' || cmd === '-v') { @@ -39,7 +44,6 @@ export async function main(argv: string[], io: Io = { out: console.log }): Promi return 0; } - const repoPath = process.cwd(); if (cmd === '--json') { const { jsonDump } = await import('./commands.js'); io.out(JSON.stringify(await jsonDump(repoPath))); @@ -48,28 +52,53 @@ export async function main(argv: string[], io: Io = { out: console.log }): Promi switch (cmd) { case undefined: + case '--report': + case '--card': case 'status': { + const report = flagValue(argv, '--report'); + const card = flagValue(argv, '--card'); + if (report === MISSING || card === MISSING) { + const flag = report === MISSING ? '--report' : '--card'; + io.out(`\`${flag}\` needs a file path, e.g. \`gitgotchi ${flag} out\`.`); + return 0; + } const { oneShot } = await import('./ui/launch.js'); - await oneShot(repoPath); + await oneShot(repoPath, io, { report, card }); return 0; } case 'watch': { + const interval = intervalOf(rest); + if ('error' in interval) { + io.out(interval.error); + return 0; + } const { watch } = await import('./ui/launch.js'); - await watch(repoPath, intervalOf(rest)); + await watch(repoPath, interval.seconds, io); return 0; } case 'card': { + const out = flagValue(rest, '--out', '-o'); + if (out === MISSING) { + io.out('`--out`/`-o` needs a path, or `-` for stdout. Try: gitgotchi card -o card.svg'); + return 0; + } const { basename } = await import('node:path'); - const { load } = await import('./state/store.js'); - const { exportCard } = await import('./share/export.js'); + const { collectAndAdvance } = await import('./core/session.js'); + const { buildCardSvg, cardSeed } = await import('./share/card.js'); const clock = Date.now; - const state = await load(repoPath, clock); - const res = await exportCard( - state, - { repoName: basename(repoPath), clock, seed: state.history.at(-1)?.timestamp ?? 0 }, - { out: outOf(rest), png: rest.includes('--png') }, - ); - res.messages.forEach(io.out); + // A card is a check-in: collect fresh signals so it never shows stale vitals. + const state = await collectAndAdvance(repoPath, { clock }); + const meta = { repoName: basename(repoPath), clock, seed: cardSeed(state) }; + + if (out === '-') { + io.out(buildCardSvg(state, meta)); + return 0; + } + const { exportCard } = await import('./share/export.js'); + const res = await exportCard(state, meta, { out, png: rest.includes('--png') }); + // Not `forEach(io.out)`: forEach also passes index and array, and the + // default io is console.log, which happily prints all three. + res.messages.forEach((message) => io.out(message)); return 0; } case 'rename': { @@ -98,15 +127,30 @@ export async function main(argv: string[], io: Io = { out: console.log }): Promi } } -function intervalOf(args: string[]): number { - const i = args.findIndex((a) => a === '--interval' || a === '-i'); - const raw = i >= 0 ? Number(args[i + 1]) : 60; - return Number.isFinite(raw) && raw > 0 ? raw : 60; +/** A flag was given but its value was another flag or the end of argv. */ +const MISSING = Symbol('missing-flag-value'); + +function flagValue(args: string[], ...names: string[]): string | undefined | typeof MISSING { + const i = args.findIndex((a) => names.includes(a)); + if (i < 0) return undefined; + const value = args[i + 1]; + // `-` is a real value (stdout); any other leading dash is the next flag. + return value === undefined || (value.startsWith('-') && value !== '-') ? MISSING : value; } -function outOf(args: string[]): string | undefined { - const i = args.findIndex((a) => a === '--out' || a === '-o'); - return i >= 0 ? args[i + 1] : undefined; +const MIN_INTERVAL = 10; + +function intervalOf(args: string[]): { seconds: number } | { error: string } { + const raw = flagValue(args, '--interval', '-i'); + if (raw === MISSING) + return { error: '`--interval`/`-i` needs a number of seconds, e.g. `-i 30`.' }; + if (raw === undefined) return { seconds: 60 }; + + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) { + return { error: `"${raw}" isn't a number of seconds. Try: gitgotchi watch -i 30` }; + } + return { seconds: Math.max(MIN_INTERVAL, seconds) }; } /** diff --git a/src/commands.ts b/src/commands.ts index 3ff2bf1..05ef0ab 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,16 +1,17 @@ import { basename } from 'node:path'; import { collectAndAdvance, type SessionOpts } from './core/session.js'; +import { safeName } from './state/name.js'; import { load, save } from './state/store.js'; import type { PetState } from './state/types.js'; -/** Set the pet's name and persist. */ +/** Set the pet's name and persist. Unusable names keep the current one. */ export async function renamePet( repoPath: string, name: string, clock: () => number = Date.now, ): Promise { const state = await load(repoPath, clock); - const next: PetState = { ...state, name: name.trim() || state.name }; + const next: PetState = { ...state, name: safeName(name) || state.name }; await save(repoPath, next); return next; } diff --git a/src/share/card.ts b/src/share/card.ts index 68bb92c..8b22278 100644 --- a/src/share/card.ts +++ b/src/share/card.ts @@ -3,6 +3,7 @@ import { VITAL_ROWS } from '../ui/bars.js'; import { computeMood } from '../engine/mood.js'; import { ageDays } from '../ui/age.js'; import { getSprite } from '../ui/sprites/index.js'; +import { stripControl } from '../state/name.js'; import type { PetState } from '../state/types.js'; // Social-card ratio. Deterministic: no timestamps, age from injected clock. @@ -12,6 +13,8 @@ const BG = '#0d1117'; const FG = '#c9d1d9'; const MUTED = '#8b949e'; const TRACK = '#21262d'; +// A card gets shared away from its repo, so it carries its own attribution. +const HOME_URL = 'github.com/dvd90/gitgotchi'; const SVG_COLOR: Record<'green' | 'yellow' | 'red', string> = { green: '#3fb950', yellow: '#d29922', @@ -24,8 +27,26 @@ export interface CardMeta { seed: number; } +/** + * Seed the card's flavor line from the pet's condition rather than from when + * you looked at it. A card is a file people commit and re-share, so an + * unchanged pet has to render identical bytes — seeding off the last check-in + * timestamp rewrote the flavor line on every run. + */ +export function cardSeed(state: PetState): number { + const { health, hunger, hygiene, social } = state.vitals; + const key = `${state.stage}|${state.mood}|${health}|${hunger}|${hygiene}|${social}`; + let hash = 2166136261; // FNV-1a, same as species picking + for (let i = 0; i < key.length; i++) { + hash ^= key.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +/** XML-escape and drop control bytes — the card's only text boundary. */ function esc(s: string): string { - return s.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); + return stripControl(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); } function truncate(s: string, max: number): string { @@ -66,6 +87,7 @@ export function buildCardSvg(state: PetState, meta: CardMeta): string { ${spriteText} ${esc(truncate(flavor, 60))} ${bars} - gitgotchi · ${esc(repo)} + npx gitgotchi + ${esc(repo)} · ${esc(HOME_URL)} `; } diff --git a/src/state/name.ts b/src/state/name.ts new file mode 100644 index 0000000..32a4955 --- /dev/null +++ b/src/state/name.ts @@ -0,0 +1,32 @@ +/** Longest pet name we keep. The card truncates further; this bounds storage. */ +export const MAX_NAME = 32; + +const SPACE_LIKE = new Set([9, 10, 11, 12, 13]); // tab, newline, vtab, formfeed, CR + +/** + * Make a user-supplied name safe to print in a terminal and embed in an SVG. + * A name is typed by the user, read back from disk, and rendered in both + * places, so a stray escape byte could retitle a terminal or hide text. + * + * ponytail: drops control bytes rather than parsing escape sequences — no + * escape byte means no sequence, and the leftover `[31m` is merely ugly. Parse + * properly only if someone complains about the residue. + * + * Returns '' when nothing survives, so callers can keep the previous name. + */ +export function safeName(raw: string): string { + return stripControl(raw).replace(/\s+/g, ' ').trim().slice(0, MAX_NAME); +} + +/** Control bytes out; tab/newline/CR become spaces. Length and spacing preserved. */ +export function stripControl(raw: string): string { + let out = ''; + for (const ch of raw) { + const code = ch.codePointAt(0) ?? 0; + if (SPACE_LIKE.has(code)) out += ' '; + else if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) + continue; // C0 / DEL / C1 + else out += ch; + } + return out; +} diff --git a/src/state/schemas.ts b/src/state/schemas.ts index f2073c3..a014779 100644 --- a/src/state/schemas.ts +++ b/src/state/schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { safeName } from './name.js'; import type { PetState, Snapshot, VitalStats } from './types.js'; const vital = z.number().min(0).max(100); @@ -32,7 +33,12 @@ export const SnapshotSchema = z export const PetStateSchema = z.object({ schemaVersion: z.literal(1), - name: z.string(), + // Sanitize at the persistence boundary: covers `rename` input and a + // hand-edited state.json alike, so nothing downstream has to re-check. + name: z + .string() + .transform(safeName) + .transform((n) => n || 'Byte'), species: z.string(), stage: StageSchema, born: z.number(), diff --git a/src/state/store.ts b/src/state/store.ts index 1b76a3e..26b3c5b 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -1,22 +1,68 @@ +import { createHash } from 'node:crypto'; +import { existsSync } from 'node:fs'; import { copyFile, mkdir, readFile, rename as fsRename, writeFile } from 'node:fs/promises'; -import { join, resolve } from 'node:path'; +import { homedir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; import { pickSpecies } from '../engine/evolution.js'; import { PetStateSchema } from './schemas.js'; import type { PetState } from './types.js'; -const GITIGNORE_ENTRY = '.gitgotchi/'; +/** Only the env vars the state location depends on. */ +export type StateEnv = Partial< + Record<'GITGOTCHI_STATE_DIR' | 'XDG_STATE_HOME' | 'LOCALAPPDATA', string> +>; export interface StatePaths { dir: string; file: string; tmp: string; bak: string; + /** Pre-0.2 location, inside the repo. Read for migration, never written. */ + legacyFile: string; } -export function statePaths(repoPath: string): StatePaths { - const dir = join(repoPath, '.gitgotchi'); +/** + * Root directory for every pet's state. State lives outside the repo so + * gitgotchi never writes into a working tree it is only meant to observe — + * which is also what makes it safe to run in CI. + */ +export function stateHome(env: StateEnv = process.env): string { + if (env.GITGOTCHI_STATE_DIR) return env.GITGOTCHI_STATE_DIR; + if (env.XDG_STATE_HOME) return join(env.XDG_STATE_HOME, 'gitgotchi'); + if (process.platform === 'win32' && env.LOCALAPPDATA) return join(env.LOCALAPPDATA, 'gitgotchi'); + return join(homedir(), '.local', 'state', 'gitgotchi'); +} + +/** + * Directory name for one repo: a readable basename plus a hash of the absolute + * path, so two checkouts named `api` never collide. + */ +export function repoSlot(repoPath: string): string { + const key = resolve(repoPath); + const hash = createHash('sha256').update(key).digest('hex').slice(0, 12); + const label = + basename(key) + .replace(/[^a-zA-Z0-9._-]/g, '-') + .slice(0, 32) || 'repo'; + return `${label}-${hash}`; +} + +export function statePaths(repoPath: string, env: StateEnv = process.env): StatePaths { + const dir = join(stateHome(env), repoSlot(repoPath)); const file = join(dir, 'state.json'); - return { dir, file, tmp: `${file}.tmp`, bak: `${file}.bak` }; + return { + dir, + file, + tmp: `${file}.tmp`, + bak: `${file}.bak`, + legacyFile: join(repoPath, '.gitgotchi', 'state.json'), + }; +} + +/** True when this repo already has a pet, in either location. */ +export function hasState(repoPath: string, env: StateEnv = process.env): boolean { + const { file, legacyFile } = statePaths(repoPath, env); + return existsSync(file) || existsSync(legacyFile); } /** A fresh egg. Species is deterministic per repo. */ @@ -43,13 +89,19 @@ export function newbornState(repoKey: string, clock: () => number): PetState { */ export async function load(repoPath: string, clock: () => number): Promise { const key = resolve(repoPath); - const { file, bak } = statePaths(repoPath); + const { file, bak, legacyFile } = statePaths(repoPath); let raw: string; try { raw = await readFile(file, 'utf8'); } catch { - return newbornState(key, clock); // missing file/dir + // Pre-0.2 pets lived in the repo. Read one through once; the next save + // lands in the new home and the old file is left alone. + try { + raw = await readFile(legacyFile, 'utf8'); + } catch { + return newbornState(key, clock); // missing in both locations + } } let parsed: unknown; @@ -79,7 +131,6 @@ export async function save( await mkdir(dir, { recursive: true }); await writeFile(tmp, JSON.stringify(state, null, 2)); await rename(tmp, file); - await ensureGitignore(repoPath); } function migrate(raw: unknown): unknown { @@ -104,21 +155,3 @@ async function backup(file: string, bak: string): Promise { // best effort; degrade silently } } - -/** Append `.gitgotchi/` to the repo's .gitignore exactly once. Idempotent. */ -async function ensureGitignore(repoPath: string): Promise { - const path = join(repoPath, '.gitignore'); - let content = ''; - try { - content = await readFile(path, 'utf8'); - } catch { - // no .gitignore yet - } - const present = content.split('\n').some((l) => { - const t = l.trim(); - return t === GITIGNORE_ENTRY || t === '.gitgotchi'; - }); - if (present) return; - const prefix = content === '' || content.endsWith('\n') ? content : `${content}\n`; - await writeFile(path, `${prefix}${GITIGNORE_ENTRY}\n`); -} diff --git a/src/ui/launch.tsx b/src/ui/launch.tsx index 294ac4f..4dd0dd2 100644 --- a/src/ui/launch.tsx +++ b/src/ui/launch.tsx @@ -1,9 +1,9 @@ -import { existsSync } from 'node:fs'; import { basename } from 'node:path'; import { render, useApp, useInput, type Key } from 'ink'; import { useEffect, useState } from 'react'; +import type { Io } from '../cli.js'; import { collectAndAdvance, startLoop } from '../core/session.js'; -import { statePaths } from '../state/store.js'; +import { hasState } from '../state/store.js'; import type { PetState } from '../state/types.js'; import { App } from './App.js'; import { renderPlain } from './plain.js'; @@ -16,19 +16,50 @@ export function shouldQuit(input: string, key: Pick): boolean { return input === 'q' || (key.ctrl && input === 'c'); } -/** `gitgotchi` — collect, advance, render once, exit. Non-TTY → plain block. */ -export async function oneShot(repoPath: string): Promise { +/** Extra artifacts to emit from the same check-in, so nothing ticks twice. */ +export interface OneShotArtifacts { + /** Write the new state as JSON here. */ + report?: string; + /** Write the share card SVG here. */ + card?: string; +} + +/** + * `gitgotchi` — collect, advance, render once, exit. Non-TTY → plain block. + * One invocation is one check-in; the artifacts are extra renderings of it, + * which is what lets the GitHub Action produce a summary, outputs and a card + * without advancing the pet three times. + */ +export async function oneShot( + repoPath: string, + io: Io = { out: console.log }, + artifacts: OneShotArtifacts = {}, +): Promise { const clock = Date.now; - const fresh = !existsSync(statePaths(repoPath).file); + const fresh = !hasState(repoPath); const state = await collectAndAdvance(repoPath, { clock }); const seed = seedFor(state); - if (!process.stdout.isTTY) { - console.log(renderPlain(state, clock, seed)); - return; + if (artifacts.report || artifacts.card) { + const { writeFile } = await import('node:fs/promises'); + if (artifacts.report) await writeFile(artifacts.report, JSON.stringify(state, null, 2)); + if (artifacts.card) { + const { cardSeed } = await import('../share/card.js'); + const { exportCard } = await import('../share/export.js'); + await exportCard( + state, + { repoName: basename(repoPath), clock, seed: cardSeed(state) }, + { out: artifacts.card }, + ); + } } + if (fresh) { - console.log(`An egg appeared in ${basename(repoPath)}… it's watching your commits.\n`); + io.out(`An egg appeared in ${basename(repoPath)}… it's watching your commits.\n`); + } + if (!process.stdout.isTTY) { + io.out(renderPlain(state, clock, seed)); + return; } const { unmount } = render( , @@ -37,12 +68,16 @@ export async function oneShot(repoPath: string): Promise { } /** `gitgotchi watch` — refresh on an interval with an idle blink between refreshes. */ -export async function watch(repoPath: string, intervalSec: number): Promise { +export async function watch( + repoPath: string, + intervalSec: number, + io: Io = { out: console.log }, +): Promise { const clock = Date.now; - const intervalMs = Math.max(10, intervalSec) * 1000; + const intervalMs = intervalSec * 1000; if (!process.stdout.isTTY) { - await oneShot(repoPath); // no animation in a pipe + await oneShot(repoPath, io); // no animation in a pipe return; } const initial = await collectAndAdvance(repoPath, { clock }); diff --git a/test/cli.integration.test.ts b/test/cli.integration.test.ts new file mode 100644 index 0000000..6dd0821 --- /dev/null +++ b/test/cli.integration.test.ts @@ -0,0 +1,194 @@ +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { main } from '../src/cli.js'; +import type { PetState } from '../src/state/types.js'; +import { makeRepo, type Fixture } from './helpers/repo.js'; + +const capture = () => { + const lines: string[] = []; + return { io: { out: (l: string) => lines.push(l) }, lines }; +}; + +/** Every assertion here runs main() end to end against a real git repo. */ +describe('cli end to end', () => { + let repo: Fixture; + + // `main()` imports its command modules lazily, so the first command in this + // file would otherwise pay for transforming the whole Ink/React tree — + // seconds on a cold Windows runner, inside a timed test. Pay it in a hook. + beforeAll(async () => { + await Promise.all([ + import('../src/ui/launch.js'), + import('../src/commands.js'), + import('../src/core/session.js'), + import('../src/share/card.js'), + import('../src/share/export.js'), + ]); + }); + + afterEach(async () => { + await repo?.cleanup(); + }); + + const setup = async () => { + repo = await makeRepo(); + await repo.write('index.ts', 'export const hi = 1;\n'); + await repo.commit('feat: first'); + return repo.path; + }; + + const json = async (path: string): Promise => { + const { io, lines } = capture(); + expect(await main(['--json'], io, path)).toBe(0); + return JSON.parse(lines[0]!) as PetState; + }; + + it('status renders a plain block through the injected io, not console', async () => { + const path = await setup(); + const { io, lines } = capture(); + + expect(await main([], io, path)).toBe(0); + const text = lines.join('\n'); + expect(text).toContain('Byte'); + expect(text).toContain('health'); + expect(text).toContain('hunger'); + }); + + it('greets a brand new pet once, then stops', async () => { + const path = await setup(); + const first = capture(); + await main([], first.io, path); + expect(first.lines.join('\n')).toContain('An egg appeared'); + + const second = capture(); + await main([], second.io, path); + expect(second.lines.join('\n')).not.toContain('An egg appeared'); + }); + + it('--json emits one parseable line and counts a check-in each run', async () => { + const path = await setup(); + expect((await json(path)).lifetime.checkIns).toBe(1); + expect((await json(path)).lifetime.checkIns).toBe(2); + }); + + it('card performs a real check-in instead of reporting stale state', async () => { + const path = await setup(); + const out = join(path, 'card.svg'); + const { io, lines } = capture(); + + expect(await main(['card', '-o', out], io, path)).toBe(0); + expect(lines).toEqual([`Saved ${out}`]); // one message, one argument + expect(existsSync(out)).toBe(true); + + // The card advanced the pet: --json now sees the second check-in, not the first. + expect((await json(path)).lifetime.checkIns).toBe(2); + }); + + it('card writes the SVG to stdout when out is "-"', async () => { + const path = await setup(); + const { io, lines } = capture(); + + expect(await main(['card', '-o', '-'], io, path)).toBe(0); + expect(lines[0]).toContain(' { + const path = await setup(); + const a = join(path, 'a.svg'); + const b = join(path, 'b.svg'); + + await main(['card', '-o', a], capture().io, path); + await main(['card', '-o', b], capture().io, path); + + // What makes the "commit the card" workflow quiet instead of churning. + expect(await readFile(a, 'utf8')).toBe(await readFile(b, 'utf8')); + }); + + it('card renders the renamed pet', async () => { + const path = await setup(); + await main(['rename', 'Pixel'], capture().io, path); + const out = join(path, 'card.svg'); + await main(['card', '-o', out], capture().io, path); + + expect(await readFile(out, 'utf8')).toContain('Pixel'); + }); + + it('a flag missing its value says so instead of silently defaulting', async () => { + const path = await setup(); + + const card = capture(); + expect(await main(['card', '-o'], card.io, path)).toBe(0); + expect(card.lines.join('\n')).toContain('-o'); + expect(existsSync(join(path, 'gitgotchi-card.svg'))).toBe(false); + + const watch = capture(); + expect(await main(['watch', '-i'], watch.io, path)).toBe(0); + expect(watch.lines.join('\n')).toContain('-i'); + + const bad = capture(); + expect(await main(['watch', '-i', 'soon'], bad.io, path)).toBe(0); + expect(bad.lines.join('\n')).toContain('soon'); + }); + + it('--report writes the JSON to a file while stdout keeps the human block', async () => { + const path = await setup(); + const report = join(path, 'pet.json'); + const { io, lines } = capture(); + + expect(await main(['--report', report], io, path)).toBe(0); + expect(lines.join('\n')).toContain('Byte'); + + const state = JSON.parse(await readFile(report, 'utf8')) as PetState; + expect(state.lifetime.checkIns).toBe(1); + // One invocation, one check-in — the report must not cost a second tick. + expect((await json(path)).lifetime.checkIns).toBe(2); + }); + + it('--report and --card share a single check-in', async () => { + const path = await setup(); + const report = join(path, 'pet.json'); + const card = join(path, 'pet.svg'); + + expect(await main(['--report', report, '--card', card], capture().io, path)).toBe(0); + + const state = JSON.parse(await readFile(report, 'utf8')) as PetState; + expect(state.lifetime.checkIns).toBe(1); + expect(await readFile(card, 'utf8')).toContain(' { + const path = await setup(); + const { io, lines } = capture(); + expect(await main(['--report'], io, path)).toBe(0); + expect(lines.join('\n')).toContain('--report'); + }); + + it('sanitizes a hostile pet name before it reaches the terminal or a card', async () => { + const path = await setup(); + const esc = String.fromCharCode(27); + const { io, lines } = capture(); + + await main(['rename', `${esc}]0;pwned${String.fromCharCode(7)}Evil`], io, path); + expect(lines.join('\n')).not.toContain(esc); + + const out = join(path, 'card.svg'); + await main(['card', '-o', out], capture().io, path); + const svg = await readFile(out, 'utf8'); + expect(svg).not.toContain(esc); + expect((await json(path)).name).not.toContain(esc); + }); + + it('never writes into the repo working tree', async () => { + const path = await setup(); + await main([], capture().io, path); + await main(['--json'], capture().io, path); + await main(['rename', 'Pixel'], capture().io, path); + + expect(await repo.git.status().then((s) => s.isClean())).toBe(true); + expect(existsSync(join(path, '.gitgotchi'))).toBe(false); + }); +}); diff --git a/test/commands.test.ts b/test/commands.test.ts index bbcf499..2d0d080 100644 --- a/test/commands.test.ts +++ b/test/commands.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { initPet, jsonDump, renamePet } from '../src/commands.js'; import { load } from '../src/state/store.js'; @@ -50,11 +50,11 @@ describe('management commands', () => { it('init falls back to the suggestion when the answer is blank', async () => { const repo = await tmp(); const ask = vi.fn(async (_q: string, suggested: string) => { - expect(suggested).toBe(join(repo).split('/').pop()); // repo folder name + expect(suggested).toBe(basename(repo)); // repo folder name return ' '; }); const pet = await initPet(repo, { ask, clock }); - expect(pet.name).toBe(join(repo).split('/').pop()); + expect(pet.name).toBe(basename(repo)); }); it('--json dump is valid against the PetState schema', async () => { diff --git a/test/helpers/repo.ts b/test/helpers/repo.ts index cd9cf37..df6596d 100644 --- a/test/helpers/repo.ts +++ b/test/helpers/repo.ts @@ -31,6 +31,13 @@ export async function makeRepo(): Promise { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '', }; + // …but git on Windows cannot start without these. + if (process.platform === 'win32') { + for (const key of ['SystemRoot', 'TEMP', 'TMP', 'USERPROFILE', 'PATHEXT', 'ComSpec']) { + const value = process.env[key]; + if (value) env[key] = value; + } + } if (date) { const iso = date.toISOString(); env.GIT_AUTHOR_DATE = iso; diff --git a/test/setup.ts b/test/setup.ts new file mode 100644 index 0000000..91eea86 --- /dev/null +++ b/test/setup.ts @@ -0,0 +1,13 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll } from 'vitest'; + +// Pet state lives outside the repo now, so an unguarded test would write into +// the developer's real state directory. Give every test file its own sandbox. +const dir = mkdtempSync(join(tmpdir(), 'gitgotchi-state-')); +process.env.GITGOTCHI_STATE_DIR = dir; + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); diff --git a/test/share/__snapshots__/card.test.ts.snap b/test/share/__snapshots__/card.test.ts.snap index 09811fa..a9dd0d1 100644 --- a/test/share/__snapshots__/card.test.ts.snap +++ b/test/share/__snapshots__/card.test.ts.snap @@ -26,7 +26,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "critical": " @@ -52,7 +53,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "grubby": " @@ -78,7 +80,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "hungry": " @@ -104,7 +107,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "lonely": " @@ -130,7 +134,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "sick": " @@ -156,7 +161,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", "thriving": " @@ -182,7 +188,8 @@ exports[`buildCardSvg > snapshot per mood 1`] = ` 95 - gitgotchi · gitgotchi + npx gitgotchi + gitgotchi · github.com/dvd90/gitgotchi ", } `; diff --git a/test/share/card.test.ts b/test/share/card.test.ts index 0384ad2..2f6fd45 100644 --- a/test/share/card.test.ts +++ b/test/share/card.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { buildCardSvg } from '../../src/share/card.js'; +import { buildCardSvg, cardSeed } from '../../src/share/card.js'; import { exportCard, type SharpLike } from '../../src/share/export.js'; import type { Mood, PetState } from '../../src/state/types.js'; @@ -35,6 +35,28 @@ describe('buildCardSvg', () => { expect(buildCardSvg(stateWith('content'), meta)).toBe(buildCardSvg(stateWith('content'), meta)); }); + describe('cardSeed', () => { + it('ignores when you looked, so an unchanged pet renders identical bytes', () => { + const pet = stateWith('content'); + const monday = { ...pet, history: [{ timestamp: 1, vitals: pet.vitals, mood: pet.mood }] }; + const tuesday = { ...pet, history: [{ timestamp: 2, vitals: pet.vitals, mood: pet.mood }] }; + + expect(cardSeed(monday)).toBe(cardSeed(tuesday)); + expect(buildCardSvg(monday, { ...meta, seed: cardSeed(monday) })).toBe( + buildCardSvg(tuesday, { ...meta, seed: cardSeed(tuesday) }), + ); + }); + + it('changes when the pet changes', () => { + const pet = stateWith('content'); + expect(cardSeed(pet)).not.toBe(cardSeed({ ...pet, mood: 'sick' })); + expect(cardSeed(pet)).not.toBe( + cardSeed({ ...pet, vitals: { ...pet.vitals, health: pet.vitals.health - 1 } }), + ); + expect(cardSeed(pet)).not.toBe(cardSeed({ ...pet, stage: 'adult' })); + }); + }); + it('truncates long repo and pet names with an ellipsis', () => { const long = { ...meta, repoName: 'a'.repeat(60) }; const state = { ...stateWith('content'), name: 'z'.repeat(60) }; @@ -49,6 +71,19 @@ describe('buildCardSvg', () => { expect(svg).not.toContain(''); expect(svg).toContain('<'); // < }); + + it('drops control bytes so a shared card carries no escape sequences', () => { + const esc = String.fromCharCode(27); + const svg = buildCardSvg({ ...stateWith('content'), name: `a${esc}[31mb` }, meta); + expect(svg).not.toContain(esc); + }); + + it('carries its own attribution and install line, since cards get shared', () => { + const svg = buildCardSvg(stateWith('content'), meta); + expect(svg).toContain('npx gitgotchi'); + expect(svg).toContain('github.com/dvd90/gitgotchi'); + expect(svg).toContain('gitgotchi'); // the repo it came from + }); }); describe('exportCard', () => { diff --git a/test/state/name.test.ts b/test/state/name.test.ts new file mode 100644 index 0000000..de2e18b --- /dev/null +++ b/test/state/name.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_NAME, safeName } from '../../src/state/name.js'; + +// Built by code point so the control bytes survive editors, linters and diffs. +const ESC = String.fromCharCode(27); +const BEL = String.fromCharCode(7); +const NUL = String.fromCharCode(0); + +describe('safeName', () => { + it('keeps an ordinary name untouched', () => { + expect(safeName('Pixel')).toBe('Pixel'); + expect(safeName('byte-2 の犬')).toBe('byte-2 の犬'); + }); + + it('trims and collapses whitespace', () => { + expect(safeName(' Pixel the Pet ')).toBe('Pixel the Pet'); + }); + + it('leaves no escape byte behind, so no sequence can execute', () => { + for (const hostile of [ + `${ESC}[31mRed${ESC}[0m`, + `${ESC}]0;pwned${BEL}Pixel`, + `${ESC}]0;pwned${ESC}\\Pixel`, + ]) { + const cleaned = safeName(hostile); + expect(cleaned).not.toContain(ESC); + expect(cleaned).not.toContain(BEL); + } + }); + + it('turns control whitespace into a space and drops other control bytes', () => { + expect(safeName('Pi\nxel')).toBe('Pi xel'); + expect(safeName('Pi\txel')).toBe('Pi xel'); + expect(safeName(`Pi${NUL}xel`)).toBe('Pixel'); + }); + + it('caps the length', () => { + expect(safeName('x'.repeat(200))).toHaveLength(MAX_NAME); + }); + + it('returns empty when nothing survives, so callers can fall back', () => { + expect(safeName('')).toBe(''); + expect(safeName(' ')).toBe(''); + expect(safeName(NUL + NUL)).toBe(''); + }); +}); diff --git a/test/state/store.test.ts b/test/state/store.test.ts index 55d17f8..7d081fd 100644 --- a/test/state/store.test.ts +++ b/test/state/store.test.ts @@ -1,9 +1,9 @@ -import { mkdtemp, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; +import { mkdtemp, readdir, readFile, rm, writeFile, mkdir } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; -import { load, save, statePaths } from '../../src/state/store.js'; +import { hasState, load, save, statePaths } from '../../src/state/store.js'; import { pickSpecies } from '../../src/engine/evolution.js'; import type { PetState } from '../../src/state/types.js'; @@ -106,30 +106,96 @@ describe('store', () => { expect(await readFile(file, 'utf8')).toBe(before); // unchanged }); - describe('.gitignore handling', () => { - const entry = '.gitgotchi/'; - it('absent → created with the entry', async () => { + describe('state location', () => { + it('save writes outside the repo and leaves the working tree untouched', async () => { const repo = await makeRepo(); + const before = await readdir(repo); await save(repo, await load(repo, clock)); - expect(await readFile(join(repo, '.gitignore'), 'utf8')).toContain(entry); + + expect(await readdir(repo)).toEqual(before); + expect(statePaths(repo).file.startsWith(resolve(repo))).toBe(false); + expect(existsSync(statePaths(repo).file)).toBe(true); }); - it('present without entry → appended once; called twice → still one', async () => { + it('never creates or edits the repo .gitignore', async () => { const repo = await makeRepo(); - await writeFile(join(repo, '.gitignore'), 'node_modules/\n'); + const gi = 'node_modules/\n'; + await writeFile(join(repo, '.gitignore'), gi); await save(repo, await load(repo, clock)); + + expect(await readFile(join(repo, '.gitignore'), 'utf8')).toBe(gi); + }); + + it('is stable per repo path and distinct between repos', async () => { + const a = await makeRepo(); + const b = await makeRepo(); + expect(statePaths(a).file).toBe(statePaths(a).file); + expect(statePaths(a).file).not.toBe(statePaths(b).file); + }); + + it('two repos with the same basename get different slots', async () => { + const parentA = await makeRepo(); + const parentB = await makeRepo(); + const a = join(parentA, 'api'); + const b = join(parentB, 'api'); + await mkdir(a, { recursive: true }); + await mkdir(b, { recursive: true }); + expect(statePaths(a).dir).not.toBe(statePaths(b).dir); + }); + + it('GITGOTCHI_STATE_DIR is used verbatim as the state root', async () => { + const repo = await makeRepo(); + const root = await makeRepo(); + expect(statePaths(repo, { GITGOTCHI_STATE_DIR: root }).dir.startsWith(root)).toBe(true); + }); + + it('XDG_STATE_HOME gets a gitgotchi segment', async () => { + const repo = await makeRepo(); + const dir = statePaths(repo, { XDG_STATE_HOME: '/xdg' }).dir; + expect(dir.startsWith(join('/xdg', 'gitgotchi'))).toBe(true); + }); + + it('reads a legacy in-repo state file, then saves to the new location', async () => { + const repo = await makeRepo(); + const legacy = join(repo, '.gitgotchi', 'state.json'); + await mkdir(join(repo, '.gitgotchi'), { recursive: true }); + const original = { ...(await load(repo, clock)), name: 'Legacy' }; + await writeFile(legacy, JSON.stringify(original)); + + expect((await load(repo, clock)).name).toBe('Legacy'); + await save(repo, await load(repo, clock)); - const gi = await readFile(join(repo, '.gitignore'), 'utf8'); - expect(gi.match(/\.gitgotchi\//g)).toHaveLength(1); - expect(gi).toContain('node_modules/'); + expect(existsSync(statePaths(repo).file)).toBe(true); + expect((await load(repo, clock)).name).toBe('Legacy'); }); - it('present with entry → byte-for-byte unchanged', async () => { + it('external state wins over a stale legacy file', async () => { const repo = await makeRepo(); - const gi = 'dist/\n.gitgotchi/\n'; - await writeFile(join(repo, '.gitignore'), gi); + await mkdir(join(repo, '.gitgotchi'), { recursive: true }); + const base = await load(repo, clock); + await writeFile( + join(repo, '.gitgotchi', 'state.json'), + JSON.stringify({ ...base, name: 'Old' }), + ); + await save(repo, { ...base, name: 'New' }); + + expect((await load(repo, clock)).name).toBe('New'); + }); + }); + + describe('hasState', () => { + it('false for an untouched repo, true after a save', async () => { + const repo = await makeRepo(); + expect(hasState(repo)).toBe(false); await save(repo, await load(repo, clock)); - expect(await readFile(join(repo, '.gitignore'), 'utf8')).toBe(gi); + expect(hasState(repo)).toBe(true); + }); + + it('true when only a legacy in-repo file exists', async () => { + const repo = await makeRepo(); + await mkdir(join(repo, '.gitgotchi'), { recursive: true }); + await writeFile(join(repo, '.gitgotchi', 'state.json'), '{}'); + expect(hasState(repo)).toBe(true); }); }); }); diff --git a/vitest.config.ts b/vitest.config.ts index 32fee38..d80c0d3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ esbuild: { jsx: 'automatic', jsxImportSource: 'react' }, test: { + setupFiles: ['./test/setup.ts'], + // Collector and CLI tests drive real git in real fixture repos. That is + // seconds of work on a Windows runner, well past the 5s default. + testTimeout: 20_000, + hookTimeout: 30_000, // module warm-up in beforeAll, cold on Windows coverage: { provider: 'v8', include: ['src/**'],