afterimage-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.1.1
4
+
5
+ - **`init` keeps Next.js's long cache on the live app.** For a project that depends on `next`, `vercel.json` gets a header rule and a new `netlify.toml` a `[[headers]]` block that serve `/_next/static/` with `public, max-age=31536000, immutable`. The host settings Afterimage needs for Next.js turn off each host's own Next.js handling, which set that header, and real deploys of 0.1.0 showed those files served with `max-age=0`. Run `npx afterimage init` again to add it to a project set up with 0.1.0; for an existing `netlify.toml` it prints the lines to add.
6
+ - **`afterimage dev` passes over a port Windows refuses.** Windows reserves port ranges for Hyper-V, WSL and Docker and refuses them with a permission error, which stopped `dev` instead of moving it to the next port, as a taken port does.
7
+
8
+ ## 0.1.0
9
+
10
+ The first release.
11
+
12
+ - **`afterimage init`** detects a plain HTML folder, Vite, Next.js with `output: 'export'`, Astro, and SvelteKit with `@sveltejs/adapter-static`. It writes `afterimage.config.json`, the `afterimage:save`, `afterimage:dev` and `afterimage:build` scripts, the `.gitattributes` and `.gitignore` lines, and host config for Vercel and Netlify. It stops and says what to change for a Next.js, Astro or SvelteKit project that has no static build, asks for the prototype's folder when it can't tell, takes `--source` and `--build`, copies the agent instructions with `--agent claude|cursor`, and never asks with `--no-input`.
13
+ - **`afterimage save "Name" [--note "..."]`** builds the prototype and saves it as the next version, fingerprinted and byte for byte as built, plus the one script tag a save adds to each HTML page. An unchanged prototype saves nothing. One save runs at a time, and a failed save leaves the history as it was. It prints what each version adds and the history's total, and says so once the history passes 100 MB.
14
+ - **`afterimage dev [--port N]`** serves the viewer over the saved history on localhost and picks up each new save.
15
+ - **`afterimage build`** writes one static site with the app at the root and the history at `/afterimage/`, after checking every saved version against its fingerprint.
16
+ - **The viewer** steps through versions, highlights what changed, and compares any two in Wipe or Side by side, on desktop and phone. Every view has its own link.
17
+ - **A frame runtime** that lets built apps load inside a version, with their own files and routes, while the live app at the root loads as its own build makes it.
18
+ - **Generated names** from Svelte, Astro, Vite CSS modules, Next.js CSS modules built with Turbopack and React 19.3 `useId` are ignored when matching, so two builds of an unchanged page show no changes. Names from Emotion, Vue, the Svelte 4 hydration marker and React 18 and 19.1 `useId` are recognised by their shape, not yet checked against a real build. `data-afterimage-id` pins a match by hand.
19
+ - **Agent support:** a setup prompt and example prompts in `docs/agents.md`, a skill for Claude Code, and the same instructions as a Cursor rule.
20
+ - **Examples:** Northwind (plain HTML), Vite with React, Next.js static export, Astro and SvelteKit, each with a saved history.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Lima
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,80 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # Afterimage
2
+
3
+ A version history for prototypes, deployed with the prototype itself.
4
+
5
+ Afterimage keeps every version of your prototype you save and serves them all from one link, at `/afterimage/` on the site you already deploy. Step through the versions, see what changed between them, and compare any two. Share the link, and anyone can see how the prototype got here without a walkthrough.
6
+
7
+ [Getting started](docs/getting-started.md) · [Commands](docs/commands.md) · [Deploying](docs/deploying.md) · [Agents](docs/agents.md) · [Matching](docs/matching.md) · [Limits](docs/limits.md)
8
+
9
+ ## Install
10
+
11
+ Afterimage needs Node 22 or later. From your prototype's project root:
12
+
13
+ ```bash
14
+ npm install -D afterimage-cli
15
+ npx afterimage init
16
+ ```
17
+
18
+ A plain HTML folder with no `package.json` needs `npm init -y` first.
19
+
20
+ `init` works out what your prototype is: a plain HTML folder, a Vite app, a Next.js static export, an Astro site, or SvelteKit with `@sveltejs/adapter-static`. It writes `afterimage.config.json`, three scripts in `package.json`, lines in `.gitattributes` and `.gitignore`, and host config for Vercel and Netlify, then lists every file it changed. When it can't tell which folder holds the prototype, it asks. You can also tell it:
21
+
22
+ ```bash
23
+ npx afterimage init --source dist --build "npm run build"
24
+ ```
25
+
26
+ ## Setup prompt
27
+
28
+ Working with Claude Code, Cursor or another coding agent? Paste the setup prompt from [docs/agents.md](docs/agents.md) instead. It installs Afterimage and runs `init` with `--agent claude` or `--agent cursor`, which copies Afterimage's instructions into the project, where your agent reads them. It also passes `--no-input`, so init never waits on a question. Then it saves a first version and gives you the viewer's link. After that, "save a version called Remember me" works in plain words.
29
+
30
+ ## Usage
31
+
32
+ Save a version whenever the prototype is worth keeping:
33
+
34
+ ```bash
35
+ npx afterimage save "First version"
36
+ npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."
37
+ ```
38
+
39
+ A save runs your build first when the prototype has one, then stores the result as the next version: v1, v2, v3. If nothing changed since the last version, nothing is saved.
40
+
41
+ Open the history while you work:
42
+
43
+ ```bash
44
+ npx afterimage dev
45
+ ```
46
+
47
+ It prints the viewer's address, `http://localhost:4321/afterimage/` unless that port is busy. Your app keeps running on its own dev server. In the viewer, `[` and `]` step through versions, `H` highlights what changed, `C` compares two versions, and `?` lists every shortcut.
48
+
49
+ Commit `afterimage/` with the rest of the project, along with `afterimage.config.json`, `package.json` and your lockfile, `.gitattributes`, and `vercel.json` or `netlify.toml`. `afterimage/` holds every saved version, and a fresh checkout can't get back a version that isn't in git. `.gitattributes` stops git from changing a saved version's line endings, which would break its fingerprint on another machine.
50
+
51
+ ## Deploying
52
+
53
+ ```bash
54
+ npx afterimage build
55
+ ```
56
+
57
+ This writes `.afterimage/site/`: your app at the root, as its own build makes it, and the history at `/afterimage/`. Serve it from the root of a domain, on any static host that meets [the requirements](docs/deploying.md#other-static-hosts).
58
+
59
+ On Vercel and Netlify you don't run it yourself. The `vercel.json` and `netlify.toml` that `init` wrote tell the host to run the same build, so a push deploys both. Every preview deployment carries the live prototype and the versions saved on its branch. [docs/deploying.md](docs/deploying.md) covers each host, other static hosts and what init changes for them.
60
+
61
+ ## Limits
62
+
63
+ - Static output only. Apps that render pages on a server aren't supported: Next.js without `output: 'export'`, Astro with an adapter but without `output: 'static'`, SvelteKit without `@sveltejs/adapter-static`.
64
+ - The site has to be served from the root of its domain, since the history is always at `/afterimage/`. A site under a sub-path, such as a GitHub Pages project site, doesn't work.
65
+ - Versions load through a service worker, so the history needs https, or localhost while you work. Without one, plain HTML with relative paths still shows, but a built app inside the viewer can show up unstyled or on its own not-found page.
66
+ - Every saved version is public. Each one deploys with your site, and there is no command to take one back, so save only what you'd show.
67
+ - A version keeps every file of the prototype except source maps and hidden files (names starting with `.`). The live app at the root keeps every file its build wrote.
68
+ - An old version's code runs on your live site's address, so it can read and change the cookies and storage your live app uses.
69
+ - A few ways of leaving a page inside a version open the live app instead, such as reloading the frame from the browser's menu or a link that opens a new tab.
70
+ - The viewer has been tested in Chrome only so far. Firefox and Safari haven't been tested yet.
71
+
72
+ [docs/limits.md](docs/limits.md) has the full list.
73
+
74
+ ## Contributing
75
+
76
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, tests and how the examples are made.
77
+
78
+ ## License
79
+
80
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,85 @@
1
+ ---
2
+ description: Saves, names, compares and deploys versions of this prototype in its Afterimage history (afterimage-cli). Use when the user asks to save or name a version of the prototype, add a note to one, see what changed between versions, open the version history, or get the history ready to deploy. Not for git commits, branches or package versions.
3
+ alwaysApply: false
4
+ ---
5
+
6
+ # Afterimage
7
+
8
+ Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. The viewer shows any saved version, and what changed between two of them. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
9
+
10
+ ## Commands
11
+
12
+ ### Save a version
13
+
14
+ ```bash
15
+ npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."
16
+ ```
17
+
18
+ - It runs the prototype's build first when `afterimage.config.json` has one, then saves the result.
19
+ - The name goes in quotes, up to 120 characters. `--note` is optional and can be repeated, one note each, up to 500 characters.
20
+ - Keep names and notes to plain text. A `"`, `$` or backtick inside them breaks the shell's quoting, so leave those out or escape them for the shell you are in.
21
+ - On success it prints `Saved v4 "Remember me".` and the version's size. Tell the user the id it printed. If it says the history is over 100 MB, pass that line on too.
22
+ - `Nothing changed since v3 "Passkeys". Nothing was saved.` means the prototype is the same as the last saved version. Tell the user and stop there.
23
+
24
+ ### Open the history
25
+
26
+ ```bash
27
+ npx afterimage dev
28
+ ```
29
+
30
+ - It keeps running until it is stopped. Start it as a background process, not a command you wait for.
31
+ - It prints `Afterimage is running at http://localhost:4321/afterimage/`. The port starts at 4321 and moves to the next free one when 4321 is busy, so give the user the URL it printed.
32
+ - If dev is already running from earlier, use the URL it printed then. When a save finishes, it rebuilds the history it serves and prints `Updated: N versions.` If that rebuild fails, it prints `Could not update the history` and keeps serving the last good copy.
33
+ - It serves the saved versions only. The app keeps running on its own dev server.
34
+ - It checks the history when it starts. With no saved versions it stops with `Save a first version with: afterimage save "Name"`.
35
+
36
+ ### Build the deployable site
37
+
38
+ ```bash
39
+ npx afterimage build
40
+ ```
41
+
42
+ - It runs the prototype's build, then writes `.afterimage/site/`: the app at the root and the history at `/afterimage/`. That folder is git-ignored.
43
+ - With no saved versions it stops with `Save a first version with: afterimage save "Name"`.
44
+ - The host runs the same build when it deploys: `vercel.json` and `netlify.toml` run `npm run afterimage:build` and serve `.afterimage/site`.
45
+
46
+ ### Set up
47
+
48
+ ```bash
49
+ npx afterimage init --agent claude --no-input
50
+ ```
51
+
52
+ - It writes `afterimage.config.json`, the `afterimage:save`, `afterimage:dev` and `afterimage:build` scripts in `package.json`, lines in `.gitattributes` and `.gitignore`, `vercel.json` and `netlify.toml`. Running it again is safe.
53
+ - `--agent claude` copies these instructions as a Claude Code skill to `.claude/skills/afterimage/`. `--agent cursor` copies them as a Cursor rule to `.cursor/rules/afterimage.mdc`. Pass the one for the agent you are.
54
+ - `--no-input` makes sure it never waits on a question. Where it would have asked, it stops with a message or prints a note instead.
55
+ - If its message contains `no static build`, the project has no static build to save: Next.js without `output: 'export'`, Astro with an adapter but without `output: 'static'`, or SvelteKit without `@sveltejs/adapter-static`. Tell the user what has to change, and stop. Don't pass `--source` to get around it. Once the user has made the change, run init again.
56
+ - Otherwise, if it stops with `Say where the prototype is`, it can't tell which folder holds the prototype. Ask the user which folder it is. Never guess. Then run init again with the same flags plus `--source <folder>`, and add `--build "<command>"` when a build command writes that folder.
57
+ - Anything it prints after the list of files, such as a note about an existing `vercel.json`, is for the user. Pass it on.
58
+
59
+ ## Links into the viewer
60
+
61
+ Add these to the URL dev printed:
62
+
63
+ - `?version=v3` opens one version.
64
+ - `?version=v4&mode=compare&base=v2` compares v4 with the older v2.
65
+ - `&hl=1` highlights what changed.
66
+
67
+ With dev on its default port, a comparison is `http://localhost:4321/afterimage/?version=v4&mode=compare&base=v2`.
68
+
69
+ `afterimage/versions.json` lists the versions newest first, so the first `id` in it is the latest. An id that isn't in the history opens the latest version instead, so check an id is listed before you link to it.
70
+
71
+ ## What people ask, and what to run
72
+
73
+ - **"Save a version called Remember me with a note that it keeps people signed in for 30 days."** Run `npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."` and tell the user the id it printed.
74
+ - **"What changed since v2?"** Read `afterimage/versions.json`. If v2 is the latest version, tell the user nothing has changed since v2, because it is the newest version, and don't open a comparison. If v2 isn't listed, say so. Otherwise make sure dev is running and give the user the URL dev printed with `?version=<latest>&mode=compare&base=v2` added. The viewer shows the changes, so don't write a text diff. It compares saved versions: if the prototype has changed since the last save, say that those changes aren't in the comparison.
75
+ - **"Get the version history ready to deploy."** Run `npx afterimage build`. If the project isn't a git repository, tell the user the history has to be committed to the repository the site deploys from, and stop. Otherwise run `git status --short -- afterimage afterimage.config.json package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb .gitattributes vercel.json netlify.toml` and tell the user which of those aren't committed yet, since the host only sees what is in git. The rest of the project is the user's business. Tell them the site is in `.afterimage/site/` and the history will be at `/afterimage/` on the deployed site. Don't commit, push or deploy unless the user asks.
76
+ - **"Show me the version history."** Start dev and give the user the URL it printed.
77
+
78
+ ## Rules
79
+
80
+ - **Never edit, move or delete anything in `afterimage/versions/`.** Every version is fingerprinted when it is saved. Once a saved version's files change, save and build stop and dev won't start. If one of them says a version has changed since it was saved, tell the user. Restoring it from git is their call.
81
+ - **Commit `afterimage/` with the project, and never add it to `.gitignore`.** The saved versions live only there, and a fresh checkout can't recover a version that isn't in git.
82
+ - **Save before risky changes.** Before a large or hard-to-undo change to the prototype, offer to save the current state first, so there is a version to compare the change against.
83
+ - **Only save what you'd publish.** Every saved version is deployed with the history, so never save secrets, private data or a broken state. A save keeps every file in the prototype folder, whatever its type, and leaves out only source maps, hidden files (names starting with `.`) and the paths in the config's `exclude`.
84
+ - **An unchanged prototype saves nothing.** `Nothing was saved` is the answer, not an error. Don't retry it.
85
+ - **One save at a time.** Don't start a save while another is running, and don't edit the prototype during one. A save that finds another one running stops with `Another save is active`. Wait for the first to finish. A save that was killed is cleared by the next save once its process has ended, so if the message stays while no save is running, tell the user, who can delete `afterimage/.afterimage-save.lock`.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: afterimage
3
+ description: Saves, names, compares and deploys versions of this prototype in its Afterimage history (afterimage-cli). Use when the user asks to save or name a version of the prototype, add a note to one, see what changed between versions, open the version history, or get the history ready to deploy. Not for git commits, branches or package versions.
4
+ ---
5
+
6
+ # Afterimage
7
+
8
+ Afterimage keeps a version history of this prototype. Each save builds the prototype and stores the built page as the next version (v1, v2, v3 and so on) in `afterimage/`. The viewer shows any saved version, and what changed between two of them. A version is the built page, kept to compare against and to share. It is not a backup of the source: the source belongs in git. Run every command from the project root, where `afterimage.config.json` is.
9
+
10
+ ## Commands
11
+
12
+ ### Save a version
13
+
14
+ ```bash
15
+ npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."
16
+ ```
17
+
18
+ - It runs the prototype's build first when `afterimage.config.json` has one, then saves the result.
19
+ - The name goes in quotes, up to 120 characters. `--note` is optional and can be repeated, one note each, up to 500 characters.
20
+ - Keep names and notes to plain text. A `"`, `$` or backtick inside them breaks the shell's quoting, so leave those out or escape them for the shell you are in.
21
+ - On success it prints `Saved v4 "Remember me".` and the version's size. Tell the user the id it printed. If it says the history is over 100 MB, pass that line on too.
22
+ - `Nothing changed since v3 "Passkeys". Nothing was saved.` means the prototype is the same as the last saved version. Tell the user and stop there.
23
+
24
+ ### Open the history
25
+
26
+ ```bash
27
+ npx afterimage dev
28
+ ```
29
+
30
+ - It keeps running until it is stopped. Start it as a background process, not a command you wait for.
31
+ - It prints `Afterimage is running at http://localhost:4321/afterimage/`. The port starts at 4321 and moves to the next free one when 4321 is busy, so give the user the URL it printed.
32
+ - If dev is already running from earlier, use the URL it printed then. When a save finishes, it rebuilds the history it serves and prints `Updated: N versions.` If that rebuild fails, it prints `Could not update the history` and keeps serving the last good copy.
33
+ - It serves the saved versions only. The app keeps running on its own dev server.
34
+ - It checks the history when it starts. With no saved versions it stops with `Save a first version with: afterimage save "Name"`.
35
+
36
+ ### Build the deployable site
37
+
38
+ ```bash
39
+ npx afterimage build
40
+ ```
41
+
42
+ - It runs the prototype's build, then writes `.afterimage/site/`: the app at the root and the history at `/afterimage/`. That folder is git-ignored.
43
+ - With no saved versions it stops with `Save a first version with: afterimage save "Name"`.
44
+ - The host runs the same build when it deploys: `vercel.json` and `netlify.toml` run `npm run afterimage:build` and serve `.afterimage/site`.
45
+
46
+ ### Set up
47
+
48
+ ```bash
49
+ npx afterimage init --agent claude --no-input
50
+ ```
51
+
52
+ - It writes `afterimage.config.json`, the `afterimage:save`, `afterimage:dev` and `afterimage:build` scripts in `package.json`, lines in `.gitattributes` and `.gitignore`, `vercel.json` and `netlify.toml`. Running it again is safe.
53
+ - `--agent claude` copies these instructions as a Claude Code skill to `.claude/skills/afterimage/`. `--agent cursor` copies them as a Cursor rule to `.cursor/rules/afterimage.mdc`. Pass the one for the agent you are.
54
+ - `--no-input` makes sure it never waits on a question. Where it would have asked, it stops with a message or prints a note instead.
55
+ - If its message contains `no static build`, the project has no static build to save: Next.js without `output: 'export'`, Astro with an adapter but without `output: 'static'`, or SvelteKit without `@sveltejs/adapter-static`. Tell the user what has to change, and stop. Don't pass `--source` to get around it. Once the user has made the change, run init again.
56
+ - Otherwise, if it stops with `Say where the prototype is`, it can't tell which folder holds the prototype. Ask the user which folder it is. Never guess. Then run init again with the same flags plus `--source <folder>`, and add `--build "<command>"` when a build command writes that folder.
57
+ - Anything it prints after the list of files, such as a note about an existing `vercel.json`, is for the user. Pass it on.
58
+
59
+ ## Links into the viewer
60
+
61
+ Add these to the URL dev printed:
62
+
63
+ - `?version=v3` opens one version.
64
+ - `?version=v4&mode=compare&base=v2` compares v4 with the older v2.
65
+ - `&hl=1` highlights what changed.
66
+
67
+ With dev on its default port, a comparison is `http://localhost:4321/afterimage/?version=v4&mode=compare&base=v2`.
68
+
69
+ `afterimage/versions.json` lists the versions newest first, so the first `id` in it is the latest. An id that isn't in the history opens the latest version instead, so check an id is listed before you link to it.
70
+
71
+ ## What people ask, and what to run
72
+
73
+ - **"Save a version called Remember me with a note that it keeps people signed in for 30 days."** Run `npx afterimage save "Remember me" --note "Keeps people signed in for 30 days."` and tell the user the id it printed.
74
+ - **"What changed since v2?"** Read `afterimage/versions.json`. If v2 is the latest version, tell the user nothing has changed since v2, because it is the newest version, and don't open a comparison. If v2 isn't listed, say so. Otherwise make sure dev is running and give the user the URL dev printed with `?version=<latest>&mode=compare&base=v2` added. The viewer shows the changes, so don't write a text diff. It compares saved versions: if the prototype has changed since the last save, say that those changes aren't in the comparison.
75
+ - **"Get the version history ready to deploy."** Run `npx afterimage build`. If the project isn't a git repository, tell the user the history has to be committed to the repository the site deploys from, and stop. Otherwise run `git status --short -- afterimage afterimage.config.json package.json package-lock.json pnpm-lock.yaml yarn.lock bun.lock bun.lockb .gitattributes vercel.json netlify.toml` and tell the user which of those aren't committed yet, since the host only sees what is in git. The rest of the project is the user's business. Tell them the site is in `.afterimage/site/` and the history will be at `/afterimage/` on the deployed site. Don't commit, push or deploy unless the user asks.
76
+ - **"Show me the version history."** Start dev and give the user the URL it printed.
77
+
78
+ ## Rules
79
+
80
+ - **Never edit, move or delete anything in `afterimage/versions/`.** Every version is fingerprinted when it is saved. Once a saved version's files change, save and build stop and dev won't start. If one of them says a version has changed since it was saved, tell the user. Restoring it from git is their call.
81
+ - **Commit `afterimage/` with the project, and never add it to `.gitignore`.** The saved versions live only there, and a fresh checkout can't recover a version that isn't in git.
82
+ - **Save before risky changes.** Before a large or hard-to-undo change to the prototype, offer to save the current state first, so there is a version to compare the change against.
83
+ - **Only save what you'd publish.** Every saved version is deployed with the history, so never save secrets, private data or a broken state. A save keeps every file in the prototype folder, whatever its type, and leaves out only source maps, hidden files (names starting with `.`) and the paths in the config's `exclude`.
84
+ - **An unchanged prototype saves nothing.** `Nothing was saved` is the answer, not an error. Don't retry it.
85
+ - **One save at a time.** Don't start a save while another is running, and don't edit the prototype during one. A save that finds another one running stops with `Another save is active`. Wait for the first to finish. A save that was killed is cleared by the next save once its process has ended, so if the message stays while no save is running, tell the user, who can delete `afterimage/.afterimage-save.lock`.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli/main.js';
3
+
4
+ process.exitCode = await main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout, stderr: process.stderr });
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
- {
2
- "name": "afterimage-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "afterimage-cli",
3
+ "version": "0.1.1",
4
+ "description": "A version history for prototypes, deployed with the prototype itself.",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "version-history",
8
+ "prototype",
9
+ "prototyping",
10
+ "design",
11
+ "viewer",
12
+ "compare",
13
+ "diff",
14
+ "static-site",
15
+ "vercel",
16
+ "netlify"
17
+ ],
18
+ "homepage": "https://github.com/uxlucas/Afterimage#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/uxlucas/Afterimage.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/uxlucas/Afterimage/issues"
25
+ },
26
+ "type": "module",
27
+ "bin": {
28
+ "afterimage": "bin/afterimage.mjs"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "files": [
34
+ "bin",
35
+ "src/cli",
36
+ "src/engine/manifest.js",
37
+ "src/runtime",
38
+ "viewer-dist",
39
+ "agents",
40
+ "README.md",
41
+ "CHANGELOG.md",
42
+ "LICENSE"
43
+ ],
44
+ "scripts": {
45
+ "build:viewer": "node scripts/build-viewer.mjs",
46
+ "install:examples": "node scripts/install-examples.mjs",
47
+ "pretest:e2e": "npm run build:viewer && npm run install:examples",
48
+ "prepack": "npm run build:viewer",
49
+ "test": "node --test \"tests/*.test.mjs\"",
50
+ "test:e2e": "node --test --test-concurrency=1 \"tests/e2e/*.test.mjs\"",
51
+ "try:package": "node scripts/try-package.mjs"
52
+ },
53
+ "devDependencies": {
54
+ "@vitejs/plugin-react": "6.1.2",
55
+ "motion": "14.9.0",
56
+ "playwright-core": "1.64.1",
57
+ "react": "19.3.1",
58
+ "react-dom": "19.3.1",
59
+ "vite": "8.3.5"
60
+ }
61
+ }
@@ -0,0 +1,36 @@
1
+ import path from 'node:path';
2
+ import { parseArgs } from 'node:util';
3
+ import { readConfig } from '../config.js';
4
+ import { assembleSite } from '../site.js';
5
+
6
+ export const usage = 'afterimage build';
7
+ export const summary = 'Build the prototype and write the deployable site with the history at /afterimage/.';
8
+ // What both "afterimage build --help" and "afterimage help build" print.
9
+ export const help = `Usage: ${usage}\n\n${summary}\n`;
10
+ // Read by the docs tests, which check every flag the agent files name.
11
+ export const options = { help: { type: 'boolean', short: 'h' } };
12
+
13
+ // Not every static host can serve the history (a sub-path, no https, 403 for a missing file), so the message says where
14
+ // the requirements are. tests/docs.test.mjs checks the heading exists.
15
+ export const hostDocs = 'https://github.com/uxlucas/Afterimage/blob/main/docs/deploying.md#other-static-hosts';
16
+
17
+ export async function run(args, { cwd, stdout, stderr, buildStdio, viewerDir, runtimeDir }) {
18
+ let parsed;
19
+ try {
20
+ parsed = parseArgs({ args, options });
21
+ } catch (error) {
22
+ if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
23
+ stderr.write(`${error.message}\nUsage: ${usage}\n`);
24
+ return 2;
25
+ }
26
+ if (parsed.values.help) {
27
+ stdout.write(help);
28
+ return 0;
29
+ }
30
+
31
+ const config = await readConfig(cwd);
32
+ const { versions, warnings } = await assembleSite({ projectDir: path.resolve(cwd), config, viewerDir, runtimeDir, buildStdio });
33
+ for (const warning of warnings) stderr.write(`${warning}\n`);
34
+ stdout.write(`Built ${config.out} with ${versions} ${versions === 1 ? 'version' : 'versions'}. The history is at /afterimage/.\nServe it from the root of a domain, on a static host that does what ${hostDocs} lists.\n`);
35
+ return 0;
36
+ }
@@ -0,0 +1,195 @@
1
+ import fs from 'node:fs';
2
+ import http from 'node:http';
3
+ import net from 'node:net';
4
+ import path from 'node:path';
5
+ import { parseArgs } from 'node:util';
6
+ import { readConfig } from '../config.js';
7
+ import { assembleSite } from '../site.js';
8
+ import { fileFor, requestPath, sendFile } from '../static-files.js';
9
+
10
+ export const usage = 'afterimage dev [--port N]';
11
+ export const summary = 'Serve the saved history at /afterimage/ while your app runs on its own dev server.';
12
+ // What both "afterimage dev --help" and "afterimage help dev" print.
13
+ export const help = `Usage: ${usage}\n\n${summary}\n\n --port N the port to start from, 4321 by default; a busy one moves to the next free port\n`;
14
+ // Read by the docs tests, which check every flag the agent files name.
15
+ export const options = { port: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
16
+
17
+ const defaultPort = 4321;
18
+ const portAttempts = 20;
19
+ // Its own folder, never config.out, so dev and a running build can never share a staging folder.
20
+ const devOut = '.afterimage/dev';
21
+ const servedPath = '/afterimage/';
22
+
23
+ const count = versions => `${versions} ${versions === 1 ? 'version' : 'versions'}`;
24
+
25
+ // Windows lets a bind on 127.0.0.1 succeed even while another server holds 0.0.0.0, :: or ::1 on the same port (Astro's
26
+ // default is 4321 too), so ask whether anything answers on either loopback address before binding. A refused connect or
27
+ // a missing address (no IPv6) means nothing is there; a connect that hangs is treated as taken.
28
+ const answers = (port, host) => new Promise(resolve => {
29
+ const socket = net.connect({ port, host });
30
+ const done = taken => { socket.destroy(); resolve(taken); };
31
+ socket.setTimeout(300, () => done(true));
32
+ socket.once('connect', () => done(true));
33
+ socket.once('error', () => done(false));
34
+ });
35
+ const isTaken = async port => (await Promise.all(['127.0.0.1', '::1'].map(host => answers(port, host)))).some(Boolean);
36
+
37
+ // Listens on the first free port from `start`. Port 0 asks the system for any free one.
38
+ async function listen(server, start) {
39
+ for (let port = start; port < start + (start === 0 ? 1 : portAttempts) && port <= 65535; port++) {
40
+ if (port !== 0 && await isTaken(port)) continue;
41
+ try {
42
+ await new Promise((resolve, reject) => {
43
+ server.once('error', reject);
44
+ // Loopback only: the history is for this machine.
45
+ server.listen(port, '127.0.0.1', () => {
46
+ server.off('error', reject);
47
+ resolve();
48
+ });
49
+ });
50
+ return server.address().port;
51
+ } catch (error) {
52
+ // EACCES: Windows refuses ports in the ranges Hyper-V, WSL or Docker reserve, which nothing answers on beforehand.
53
+ if (error.code !== 'EADDRINUSE' && error.code !== 'EACCES') throw error;
54
+ }
55
+ }
56
+ throw new Error(`No free port from ${start} to ${start + portAttempts - 1}. Pass one with --port.`);
57
+ }
58
+
59
+ // Assembles the viewer and the saved history (not the app) into .afterimage/dev, serves it, and re-assembles when
60
+ // versions.json changes. A failed refresh goes to onError and the last good site keeps serving.
61
+ // Returns { url, versions, warnings, close }: warnings are from the first assemble, and close stops the server and
62
+ // the watcher and waits for a refresh in flight.
63
+ export async function startDev({ projectDir, config, port = defaultPort, viewerDir, runtimeDir, debounceMs = 150, onUpdate = () => {}, onError = () => {} }) {
64
+ const devConfig = { ...config, out: devOut };
65
+ const assemble = () => assembleSite({ projectDir, config: devConfig, viewerDir, runtimeDir, includeApp: false });
66
+ const first = await assemble();
67
+ const root = first.outDir;
68
+
69
+ const server = http.createServer(async (req, res) => {
70
+ const pathname = requestPath(req.url);
71
+ if (pathname === null) { res.writeHead(400).end(); return; }
72
+ if (pathname === '/' || pathname === servedPath.slice(0, -1)) { res.writeHead(302, { Location: servedPath }).end(); return; }
73
+ const file = fileFor(root, pathname);
74
+ if (!file) { res.writeHead(403).end(); return; }
75
+ await sendFile(res, file);
76
+ });
77
+ const boundPort = await listen(server, port);
78
+
79
+ let closed = false;
80
+ const stopServer = () => new Promise(resolve => {
81
+ server.close(resolve);
82
+ // A browser's keep-alive connection would otherwise hold the server open.
83
+ server.closeAllConnections();
84
+ });
85
+ let timer = null;
86
+ let refreshing = null;
87
+ let again = false;
88
+ // One refresh at a time. A change that lands during one asks for another straight after it.
89
+ const refresh = () => {
90
+ if (refreshing) { again = true; return refreshing; }
91
+ refreshing = (async () => {
92
+ try {
93
+ do {
94
+ again = false;
95
+ try {
96
+ const result = await assemble();
97
+ if (!closed) onUpdate(result);
98
+ } catch (error) {
99
+ if (!closed) onError(error);
100
+ }
101
+ } while (again && !closed);
102
+ } finally {
103
+ refreshing = null;
104
+ }
105
+ })();
106
+ return refreshing;
107
+ };
108
+
109
+ // A save publishes versions.json last, so it is the one file whose change means "the history is complete".
110
+ let watcher;
111
+ try {
112
+ watcher = fs.watch(path.resolve(projectDir, config.history), (event, name) => {
113
+ if (closed || (name && String(name) !== 'versions.json')) return;
114
+ clearTimeout(timer);
115
+ timer = setTimeout(refresh, debounceMs);
116
+ });
117
+ } catch (error) {
118
+ // The server is already listening; do not leave its port held.
119
+ await stopServer();
120
+ throw error;
121
+ }
122
+ watcher.on('error', error => { if (!closed) onError(error); });
123
+
124
+ const close = async () => {
125
+ if (closed) return;
126
+ closed = true;
127
+ clearTimeout(timer);
128
+ watcher.close();
129
+ await stopServer();
130
+ await refreshing;
131
+ };
132
+
133
+ return { url: `http://localhost:${boundPort}${servedPath}`, versions: first.versions, warnings: first.warnings, close };
134
+ }
135
+
136
+ // Resolves on Ctrl-C (or a terminate), or when a caller's signal aborts, which is how tests stop it.
137
+ function untilStopped(signal) {
138
+ return new Promise(resolve => {
139
+ if (signal) {
140
+ if (signal.aborted) resolve();
141
+ else signal.addEventListener('abort', resolve, { once: true });
142
+ return;
143
+ }
144
+ const stop = () => {
145
+ process.off('SIGINT', stop);
146
+ process.off('SIGTERM', stop);
147
+ resolve();
148
+ };
149
+ process.once('SIGINT', stop);
150
+ process.once('SIGTERM', stop);
151
+ });
152
+ }
153
+
154
+ export async function run(args, { cwd, stdout, stderr, viewerDir, runtimeDir, signal }) {
155
+ let parsed;
156
+ try {
157
+ parsed = parseArgs({ args, options });
158
+ } catch (error) {
159
+ if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
160
+ stderr.write(`${error.message}\nUsage: ${usage}\n`);
161
+ return 2;
162
+ }
163
+ if (parsed.values.help) {
164
+ stdout.write(help);
165
+ return 0;
166
+ }
167
+ let port = defaultPort;
168
+ if (parsed.values.port !== undefined) {
169
+ port = Number(parsed.values.port);
170
+ if (!/^\d+$/.test(parsed.values.port) || port < 1 || port > 65535) {
171
+ stderr.write(`--port needs a number from 1 to 65535.\nUsage: ${usage}\n`);
172
+ return 2;
173
+ }
174
+ }
175
+
176
+ const config = await readConfig(cwd);
177
+ const dev = await startDev({
178
+ projectDir: path.resolve(cwd),
179
+ config,
180
+ port,
181
+ viewerDir,
182
+ runtimeDir,
183
+ onUpdate: ({ versions, warnings }) => {
184
+ for (const warning of warnings) stderr.write(`${warning}\n`);
185
+ stdout.write(`Updated: ${count(versions)}.\n`);
186
+ },
187
+ onError: error => stderr.write(`Could not update the history: ${error.message}\nStill serving the last good copy.\n`)
188
+ });
189
+ for (const warning of dev.warnings) stderr.write(`${warning}\n`);
190
+ stdout.write(`Afterimage is running at ${dev.url}\n`);
191
+ stdout.write('Your app keeps running on its own dev server; this only serves the history.\n');
192
+ await untilStopped(signal);
193
+ await dev.close();
194
+ return 0;
195
+ }