shout it out

This commit is contained in:
Chris Wanstrath 2026-03-09 21:13:39 -07:00
commit c4cd5353bf
8 changed files with 375 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
.sandlot/
.dev/
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

1
.npmrc Normal file
View File

@ -0,0 +1 @@
registry=https://npm.nose.space

15
README.md Normal file
View File

@ -0,0 +1,15 @@
# shout
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

201
SPEC.md Normal file
View File

@ -0,0 +1,201 @@
# shout
A transcript-based shell integration test runner.
## Format
A `.shout` file is a plain text transcript of a shell session. Lines starting
with `$ ` are commands. Everything after — until the next `$` or end of file
— is the expected output (stdout and stderr combined).
```
$ dev new "add auth"
created draft 1 "add auth"
$ dev save
saved draft 1 (v1)
```
Blank lines within expected output are significant. Trailing newline on the
file is ignored.
### Comments
`#` after a command is a comment and is stripped before execution. Comments
in expected output are matched literally.
```
$ dev new "add auth" # create a draft from timeline HEAD
created draft 1 "add auth"
```
### Wildcards
A `...` on its own line in expected output matches any number of lines
(including zero).
```
$ dev log
...
draft 1 "add auth"
```
A `...` inline matches any sequence of characters on that line.
```
$ dev status
draft 1 "add auth" (v...)
```
### Environment
Each `.shout` file runs in a fresh temporary directory. The directory is
created before the first command and removed after the last (unless
`--keep` is passed).
The following environment variables are set for every command:
| Variable | Value |
|---|---|
| `HOME` | the temp directory |
| `PATH` | prepended with the directory containing the binary under test |
| `CUE_DIR` | the temp directory |
All other environment variables are inherited from the host unless explicitly
cleared with `--clean-env`.
### Setup blocks
Commands before the first blank line + command sequence are run as setup and
their output is not asserted.
Alternatively, a `# ---` line separates setup from the test body explicitly:
```
$ export TOKEN=abc
$ cd myproject
# ---
$ dev status
on timeline @ change 0
```
### Exit codes
By default, a non-zero exit code fails the test regardless of output. To
assert a specific exit code, append `[N]` on the last line of expected output:
```
$ dev rm
error: draft 1 has children. use dev rm -f to cascade.
[1]
```
`[*]` accepts any non-zero exit code without asserting the value.
---
## CLI
```
shout [options] [files|dirs...]
```
If no files are given, shout runs all `*.shout` files in the current directory
and subdirectories. Each command in each shout file is run sequentially
(unless `--parallel` is passed).
### Options
| Flag | Description |
|---|---|
| `--update` / `-u` | Rewrite expected output in-place with actual output |
| `--keep` / `-k` | Keep temp directories after run (printed to stderr) |
| `--clean-env` | Start with empty environment (only `PATH` and `CUE_DIR` set) |
| `--bin <path>` | Prepend `<path>` to `PATH` instead of auto-detecting |
| `--timeout <dur>` | Per-command timeout (default: `10s`) |
| `--verbose` / `-v` | Print each command as it runs |
| `--parallel` | Run files in parallel (implies all files run regardless of failures) |
### Output
Passing files print a single `.` per file. Failing files print a unified diff:
```
FAIL tests/auth.shout
$ dev rm
- error: draft 1 has children. use dev rm -f to cascade.
+ error: draft 1 has dependents. use dev rm -f to cascade.
[1]
```
Summary line at the end:
```
12 passed, 1 failed in 340ms
```
---
## Update mode
`--update` rewrites the expected output sections of each `.shout` file with the
actual output from the run. Commands, comments, and whitespace are preserved.
Wildcard lines are left in place if the actual output matches them; they are
only replaced if the match fails.
This makes it safe to run `shout --update` routinely after intentional output
changes — review the diff, commit if correct.
---
## File layout
```
tests/
auth.shout
drafts.shout
stack.shout
```
No special directory structure is required. `.shout` files can live anywhere.
---
## Implementation notes
- Bun + TypeScript
- Commands run via `Bun.spawn` with a shell (`/bin/sh -c`)
- Stdout and stderr merged (same as a terminal)
- Each command in a file shares a working directory but runs in a fresh
process — no persistent shell state between commands
- For persistent state (e.g. `cd`, `export`), users wrap in a shell block or
use a setup script
---
## Example
```
$ dev new "add auth"
created draft 1 "add auth"
$ echo 'export function auth() {}' > auth.ts
$ dev save
saved draft 1 (v1)
$ echo 'export function auth(token: string) {}' > auth.ts
$ dev save
saved draft 1 (v2)
$ dev status
draft 1 "add auth" (v2)
modified: (none)
$ dev new "add db"
created draft 2 "add db"
$ dev rm 1
error: draft 1 has children. use dev rm -f to cascade.
[1]
```

40
bun.lock Normal file
View File

@ -0,0 +1,40 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "shout",
"dependencies": {
"ansis": "*",
"commander": "14.0.3",
"diff": "^8.0.3",
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0",
},
"peerDependencies": {
"typescript": "^5.9.3",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.10", "https://npm.nose.space/@types/bun/-/bun-1.3.10.tgz", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/diff": ["@types/diff@8.0.0", "https://npm.nose.space/@types/diff/-/diff-8.0.0.tgz", { "dependencies": { "diff": "*" } }, "sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw=="],
"@types/node": ["@types/node@25.4.0", "https://npm.nose.space/@types/node/-/node-25.4.0.tgz", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
"ansis": ["ansis@4.2.0", "https://npm.nose.space/ansis/-/ansis-4.2.0.tgz", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"bun-types": ["bun-types@1.3.10", "https://npm.nose.space/bun-types/-/bun-types-1.3.10.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"commander": ["commander@14.0.3", "https://npm.nose.space/commander/-/commander-14.0.3.tgz", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"diff": ["diff@8.0.3", "https://npm.nose.space/diff/-/diff-8.0.3.tgz", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"typescript": ["typescript@5.9.3", "https://npm.nose.space/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.18.2", "https://npm.nose.space/undici-types/-/undici-types-7.18.2.tgz", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}

1
index.ts Normal file
View File

@ -0,0 +1 @@
console.log("Hello via Bun!");

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "@because/shout",
"version": "0.0.0",
"description": "test shell output",
"module": "src/index.ts",
"type": "module",
"files": [
"src"
],
"exports": {
".": "./src/index.ts"
},
"bin": {
"shout": "src/cli/index.ts"
},
"scripts": {
"check": "bunx tsc --noEmit",
"build": "./scripts/build.sh",
"cli:build": "bun run scripts/build.ts",
"cli:build:all": "bun run scripts/build.ts --all",
"cli:install": "bun cli:build && sudo cp dist/shout /usr/local/bin",
"cli:link": "ln -sf $(pwd)/src/cli/index.ts ~/.bun/bin/shout",
"cli:uninstall": "sudo rm /usr/local/bin",
"test": "bun test"
},
"devDependencies": {
"@types/bun": "latest",
"@types/diff": "^8.0.0"
},
"peerDependencies": {
"typescript": "^5.9.3"
},
"dependencies": {
"commander": "14.0.3",
"diff": "^8.0.3",
"ansis": "*"
}
}

43
tsconfig.json Normal file
View File

@ -0,0 +1,43 @@
{
"exclude": ["apps", "templates"],
"compilerOptions": {
// Environment setup & latest features
"lib": [
"ESNext",
"DOM"
],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false,
"baseUrl": ".",
"paths": {
"$*": [
"./src/server/*"
],
"@*": [
"./src/shared/*"
],
"%*": [
"./src/lib/*"
]
}
}
}