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 '
\ 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('