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 +20 -0
- package/LICENSE +21 -0
- package/README.md +80 -3
- package/agents/cursor/afterimage.mdc +85 -0
- package/agents/skill/SKILL.md +85 -0
- package/bin/afterimage.mjs +4 -0
- package/package.json +61 -6
- package/src/cli/commands/build.js +36 -0
- package/src/cli/commands/dev.js +195 -0
- package/src/cli/commands/init.js +307 -0
- package/src/cli/commands/save.js +60 -0
- package/src/cli/config.js +88 -0
- package/src/cli/detect.js +96 -0
- package/src/cli/errors.js +5 -0
- package/src/cli/history.js +232 -0
- package/src/cli/hosts.js +194 -0
- package/src/cli/main.js +47 -0
- package/src/cli/project-files.js +38 -0
- package/src/cli/retry.js +16 -0
- package/src/cli/run-build.js +23 -0
- package/src/cli/site.js +171 -0
- package/src/cli/static-files.js +46 -0
- package/src/engine/manifest.js +19 -0
- package/src/runtime/frame.js +84 -0
- package/src/runtime/map.js +79 -0
- package/src/runtime/sw.js +218 -0
- package/viewer-dist/assets/index-DLk_P7m5.js +9 -0
- package/viewer-dist/assets/index-DSNYkIru.css +1 -0
- package/viewer-dist/index.html +14 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { validateManifest } from '../engine/manifest.js';
|
|
5
|
+
import { injectFrameScript } from '../runtime/map.js';
|
|
6
|
+
import { retryLocked } from './retry.js';
|
|
7
|
+
|
|
8
|
+
// Finder, Explorer and macOS file copies leave these in any folder they touch. They are never part of a
|
|
9
|
+
// prototype, so they are skipped rather than saved or treated as an error.
|
|
10
|
+
export const isSystemFile = name => ['.DS_Store', 'Thumbs.db', 'desktop.ini'].includes(name) || name.startsWith('._');
|
|
11
|
+
|
|
12
|
+
// An exclude is a path relative to the folder being read. It matches that file, or anything under that folder.
|
|
13
|
+
const isExcluded = (relative, exclude) => exclude.some(entry => relative === entry || relative.startsWith(`${entry}/`));
|
|
14
|
+
const cleanExclude = entry => entry.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
15
|
+
|
|
16
|
+
// Every file under directory, by forward-slash path in name order, minus OS clutter, excludes and whatever
|
|
17
|
+
// skip(name, entry) leaves out. skip may also throw, to refuse a file outright.
|
|
18
|
+
async function readFiles(directory, exclude, skip) {
|
|
19
|
+
const excluded = exclude.map(cleanExclude);
|
|
20
|
+
const files = new Map();
|
|
21
|
+
async function visit(relative = '') {
|
|
22
|
+
const folder = path.join(directory, relative);
|
|
23
|
+
if (!(await fs.lstat(folder)).isDirectory()) throw new Error(`Expected a regular directory: ${folder}`);
|
|
24
|
+
for (const entry of (await fs.readdir(folder, { withFileTypes: true })).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
25
|
+
const name = relative ? `${relative}/${entry.name}` : entry.name;
|
|
26
|
+
// Excludes come first so a project can leave out a folder (a linked node_modules, say) without it being inspected.
|
|
27
|
+
if (isSystemFile(entry.name) || isExcluded(name, excluded) || skip(name, entry)) continue;
|
|
28
|
+
if (entry.isSymbolicLink()) throw new Error(`Remove the link from the public prototype, or add it to exclude: ${name}`);
|
|
29
|
+
if (entry.isDirectory()) await visit(name);
|
|
30
|
+
else if (entry.isFile()) files.set(name, await fs.readFile(path.join(directory, name)));
|
|
31
|
+
else throw new Error(`Not a regular file or folder: ${name}. Add it to exclude if it is not part of the prototype.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await visit();
|
|
35
|
+
if (!files.has('index.html')) throw new Error(`Missing index.html in ${directory}`);
|
|
36
|
+
return files;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// What a saved version keeps: every file the prototype has, whatever its type, except hidden files and folders (an
|
|
40
|
+
// .env, a .git) and source maps, which are build output nobody browses in a saved version.
|
|
41
|
+
// Strict is for saved snapshots: anything save would have skipped means the folder was edited after saving, and a
|
|
42
|
+
// deploy copying it would publish that file.
|
|
43
|
+
export function readSnapshot(directory, { exclude = [], strict = false } = {}) {
|
|
44
|
+
return readFiles(directory, exclude, (name, entry) => {
|
|
45
|
+
const hidden = entry.name.startsWith('.');
|
|
46
|
+
if (!hidden && !(entry.isFile() && path.extname(name).toLowerCase() === '.map')) return false;
|
|
47
|
+
if (strict) throw new Error(`Unexpected ${hidden ? 'hidden file' : 'file'} in a saved version: ${name}`);
|
|
48
|
+
return true;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// What the live root gets: the app's own output as it is, source maps, dotfiles and host files (_redirects,
|
|
53
|
+
// .well-known/) included, because the app's own build ships them. A repository or installed packages are never site
|
|
54
|
+
// files. A plain folder (the project root, or any folder no build writes) also holds hidden files of its own (.env,
|
|
55
|
+
// .gitignore, .vscode/), which must never go public, so there only .well-known/ is kept.
|
|
56
|
+
export function readApp(directory, { exclude = [], plain = false } = {}) {
|
|
57
|
+
return readFiles(directory, exclude, (name, entry) => {
|
|
58
|
+
if (entry.isDirectory() && (entry.name === '.git' || entry.name === 'node_modules')) return true;
|
|
59
|
+
return plain && entry.name.startsWith('.') && name !== '.well-known';
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function fingerprint(files) {
|
|
64
|
+
const entries = [...files].map(([name, bytes]) => [name, createHash('sha256').update(bytes).digest('hex')]);
|
|
65
|
+
return createHash('sha256').update(JSON.stringify(entries)).digest('hex');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Every saved page loads the frame runtime first, so a built app finds its files and its routes inside the viewer
|
|
69
|
+
// (src/runtime/sw.js says how). latin1 turns each byte into one character and back, so only the inserted tag
|
|
70
|
+
// changes. A UTF-16 page would be corrupted by single-byte text, so it is saved as it is. The entry page, index.html at
|
|
71
|
+
// the top, is a page however bare it is.
|
|
72
|
+
function withFrameScript(files, id) {
|
|
73
|
+
const isUtf16 = bytes => (bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff);
|
|
74
|
+
return new Map([...files].map(([name, bytes]) => [name, path.extname(name).toLowerCase() === '.html' && !isUtf16(bytes)
|
|
75
|
+
? Buffer.from(injectFrameScript(bytes.toString('latin1'), id, name === 'index.html' ? { page: true } : undefined), 'latin1')
|
|
76
|
+
: bytes]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const manifestPath = historyDir => path.join(historyDir, 'versions.json');
|
|
80
|
+
|
|
81
|
+
// No versions.json means nothing has been saved yet, which is a normal state and not an error.
|
|
82
|
+
export async function readHistory(historyDir) {
|
|
83
|
+
let text;
|
|
84
|
+
try {
|
|
85
|
+
text = await fs.readFile(manifestPath(historyDir), 'utf8');
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error.code === 'ENOENT') return null;
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
return validateManifest(JSON.parse(text));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const snapshotBytes = files => [...files.values()].reduce((total, bytes) => total + bytes.length, 0);
|
|
94
|
+
|
|
95
|
+
export async function verifyHistory(historyDir) {
|
|
96
|
+
return (await checkHistory(historyDir)).manifest;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Checks every saved version against its fingerprint, and adds up their bytes on the way, which a save reports.
|
|
100
|
+
async function checkHistory(historyDir) {
|
|
101
|
+
const manifest = await readHistory(historyDir);
|
|
102
|
+
let bytes = 0;
|
|
103
|
+
const versionsDir = path.join(historyDir, 'versions');
|
|
104
|
+
let directories = [];
|
|
105
|
+
try {
|
|
106
|
+
directories = await fs.readdir(versionsDir);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (error.code !== 'ENOENT') throw error;
|
|
109
|
+
}
|
|
110
|
+
const expected = new Set((manifest?.versions ?? []).map(version => version.id));
|
|
111
|
+
for (const name of directories) {
|
|
112
|
+
if (!expected.has(name) && !isSystemFile(name)) throw new Error(`Unlisted snapshot found at ${path.join(versionsDir, name)}. Resolve it before saving or deploying.`);
|
|
113
|
+
}
|
|
114
|
+
for (const version of manifest?.versions ?? []) {
|
|
115
|
+
const files = await readSnapshot(path.join(versionsDir, version.id), { strict: true });
|
|
116
|
+
if (fingerprint(files) !== version.sha256) {
|
|
117
|
+
throw new Error(`${version.id} has changed since it was saved. Restore its original files; saved versions are never edited.`);
|
|
118
|
+
}
|
|
119
|
+
bytes += snapshotBytes(files);
|
|
120
|
+
}
|
|
121
|
+
return { manifest, bytes };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lockName = '.afterimage-save.lock';
|
|
125
|
+
const stagePrefix = '.afterimage-stage-';
|
|
126
|
+
const busy = () => new Error(`Another save is active. If it crashed, delete ${lockName} in the history folder once no save is running.`);
|
|
127
|
+
|
|
128
|
+
// Whether the save that wrote a lock is still running. Only a lock this code wrote, naming a process that no longer
|
|
129
|
+
// exists (ESRCH), counts as dead; anything else (unreadable, another user's process) is left to its owner.
|
|
130
|
+
function holderAlive(text) {
|
|
131
|
+
let pid;
|
|
132
|
+
try {
|
|
133
|
+
pid = JSON.parse(text)?.pid;
|
|
134
|
+
} catch {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return true;
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return error.code !== 'ESRCH';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// One save at a time. A save that was killed (Ctrl-C, an agent's timeout) never reaches its cleanup and leaves its lock
|
|
147
|
+
// and stage folder behind; once its process is gone, both are cleared and this save goes ahead.
|
|
148
|
+
async function takeLock(historyDir, lockPath) {
|
|
149
|
+
try {
|
|
150
|
+
return await fs.open(lockPath, 'wx');
|
|
151
|
+
} catch (error) {
|
|
152
|
+
if (error.code !== 'EEXIST') throw error;
|
|
153
|
+
}
|
|
154
|
+
if (holderAlive(await fs.readFile(lockPath, 'utf8').catch(() => ''))) throw busy();
|
|
155
|
+
for (const name of await fs.readdir(historyDir)) {
|
|
156
|
+
if (name.startsWith(stagePrefix)) await fs.rm(path.join(historyDir, name), { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
await fs.rm(lockPath, { force: true });
|
|
159
|
+
try {
|
|
160
|
+
return await fs.open(lockPath, 'wx');
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code === 'EEXIST') throw busy();
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function saveVersion({ historyDir, sourceDir, exclude = [] }, { name, notes = [] }) {
|
|
168
|
+
if (typeof name !== 'string' || !name.trim() || name.length > 120 ||
|
|
169
|
+
!Array.isArray(notes) || notes.some(note => typeof note !== 'string' || !note.trim() || note.length > 500)) {
|
|
170
|
+
throw new Error('Provide a version name up to 120 characters and notes up to 500 characters each.');
|
|
171
|
+
}
|
|
172
|
+
await fs.mkdir(historyDir, { recursive: true });
|
|
173
|
+
const lockPath = path.join(historyDir, lockName);
|
|
174
|
+
const lock = await takeLock(historyDir, lockPath);
|
|
175
|
+
const stage = path.join(historyDir, `${stagePrefix}${randomUUID()}`);
|
|
176
|
+
let destination;
|
|
177
|
+
let promoted = false;
|
|
178
|
+
let committed = false;
|
|
179
|
+
try {
|
|
180
|
+
await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
181
|
+
const checked = await checkHistory(historyDir);
|
|
182
|
+
const manifest = checked.manifest ?? { schemaVersion: 1, versions: [] };
|
|
183
|
+
const latest = manifest.versions[0];
|
|
184
|
+
const source = await readSnapshot(sourceDir, { exclude });
|
|
185
|
+
const sourceSha = fingerprint(source);
|
|
186
|
+
// The latest version's bytes carry its own id in the tag, so the same build is compared as it would have been
|
|
187
|
+
// saved then. A version saved without the runtime (Northwind) matches the source as it is.
|
|
188
|
+
if (latest && (fingerprint(withFrameScript(source, latest.id)) === latest.sha256 || sourceSha === latest.sha256)) {
|
|
189
|
+
return { version: latest, unchanged: true };
|
|
190
|
+
}
|
|
191
|
+
const number = latest ? Number(latest.id.slice(1)) + 1 : 1;
|
|
192
|
+
if (!Number.isSafeInteger(number)) throw new Error('Version number exceeds the supported range.');
|
|
193
|
+
const files = withFrameScript(source, `v${number}`);
|
|
194
|
+
const sha256 = fingerprint(files);
|
|
195
|
+
const version = {
|
|
196
|
+
id: `v${number}`, name: name.trim(), label: `Version ${number}`,
|
|
197
|
+
file: `versions/v${number}/index.html`, notes: notes.map(note => note.trim()),
|
|
198
|
+
savedAt: new Date().toISOString(), sha256,
|
|
199
|
+
};
|
|
200
|
+
const stagedSnapshot = path.join(stage, 'snapshot');
|
|
201
|
+
await fs.mkdir(stagedSnapshot, { recursive: true });
|
|
202
|
+
for (const [name, bytes] of files) {
|
|
203
|
+
const target = path.join(stagedSnapshot, name);
|
|
204
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
205
|
+
await fs.writeFile(target, bytes, { flag: 'wx' });
|
|
206
|
+
}
|
|
207
|
+
if (fingerprint(await readSnapshot(sourceDir, { exclude })) !== sourceSha) {
|
|
208
|
+
throw new Error('The prototype changed during the save. Run the command again after editing stops.');
|
|
209
|
+
}
|
|
210
|
+
manifest.versions.unshift(version);
|
|
211
|
+
const stagedManifest = path.join(stage, 'versions.json');
|
|
212
|
+
await fs.writeFile(stagedManifest, JSON.stringify(manifest, null, 2) + '\n');
|
|
213
|
+
await fs.mkdir(path.join(historyDir, 'versions'), { recursive: true });
|
|
214
|
+
destination = path.join(historyDir, 'versions', version.id);
|
|
215
|
+
await retryLocked(() => fs.rename(stagedSnapshot, destination));
|
|
216
|
+
promoted = true;
|
|
217
|
+
// Publish the manifest last so readers never see a half-written snapshot.
|
|
218
|
+
await retryLocked(() => fs.rename(stagedManifest, manifestPath(historyDir)));
|
|
219
|
+
committed = true;
|
|
220
|
+
const bytes = snapshotBytes(files);
|
|
221
|
+
return { version, unchanged: false, bytes, history: { versions: manifest.versions.length, bytes: checked.bytes + bytes } };
|
|
222
|
+
} finally {
|
|
223
|
+
// Cleanup never replaces the outcome: a committed save is reported as saved, a failed one by its own error. What
|
|
224
|
+
// a failed step leaves behind is safe: an unlisted snapshot stops the next save by name, and the next save clears
|
|
225
|
+
// a lock and stage folder whose process is gone.
|
|
226
|
+
const quietly = step => step().catch(() => {});
|
|
227
|
+
if (promoted && !committed) await quietly(() => fs.rm(destination, { recursive: true, force: true }));
|
|
228
|
+
await quietly(() => fs.rm(stage, { recursive: true, force: true }));
|
|
229
|
+
await quietly(() => lock.close());
|
|
230
|
+
await quietly(() => fs.unlink(lockPath));
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/cli/hosts.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The host config for Vercel and Netlify. init plans it with its other steps: a plan only reads and asks, and returns
|
|
2
|
+
// { change, notes, apply }.
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { parseJson, readJsonText, readText, writer } from './project-files.js';
|
|
5
|
+
|
|
6
|
+
// What a host runs to deploy the history. It goes through the package script so the local bin resolves on any host.
|
|
7
|
+
const hostBuild = 'npm run afterimage:build';
|
|
8
|
+
const hostOutput = '.afterimage/site';
|
|
9
|
+
|
|
10
|
+
// Clean URLs for the live app at the root, on Vercel: /about is answered from about.html (Next's and SvelteKit's static
|
|
11
|
+
// exports write that and link to /about). Vercel only does so by default for a framework it builds itself (Next.js, until
|
|
12
|
+
// framework: null, below), and not for a plain folder. cleanUrls: true would give the same, site-wide, and
|
|
13
|
+
// redirect /afterimage/versions/vN/index.html to a path that is no longer inside the version folder, which the frame
|
|
14
|
+
// runtime and the viewer cannot follow. A rewrite is not a redirect, only applies where no file has the path (Vercel
|
|
15
|
+
// checks the filesystem first), and this one leaves /afterimage/ out altogether. tests/e2e/harness.mjs reads it.
|
|
16
|
+
export const cleanUrlRewrite = { source: '/:path((?!afterimage/|afterimage$).+)', destination: '/:path*.html' };
|
|
17
|
+
|
|
18
|
+
// What vercel.json needs for the history to deploy, and for the live app to stay as it is.
|
|
19
|
+
//
|
|
20
|
+
// Next.js: "framework": null ("Other") makes Vercel serve the output folder as plain files. With Next.js detected, Vercel's
|
|
21
|
+
// own builder takes over: @vercel/next finds .next/export-detail.json after the build and serves the export's folder (out/),
|
|
22
|
+
// not outputDirectory, so /afterimage/ would not deploy. Vercel detects the framework from package.json, so this is keyed
|
|
23
|
+
// on a dependency on next and not on the type init chose. It costs the live app the builder's _next/static caching and its
|
|
24
|
+
// .html stripping, which nextStaticCache and the rewrite below bring back. Other presets (Vite, Astro, SvelteKit) run in
|
|
25
|
+
// @vercel/static-build, which uses outputDirectory ahead of the preset's own folder, and null would cost them their cache
|
|
26
|
+
// headers or SPA fallback, so they are left as they are.
|
|
27
|
+
// Source: https://github.com/vercel/vercel/blob/main/packages/next/src/index.ts
|
|
28
|
+
const hostSettings = (next) => (next ? { framework: null, buildCommand: hostBuild, outputDirectory: hostOutput } : { buildCommand: hostBuild, outputDirectory: hostOutput });
|
|
29
|
+
|
|
30
|
+
// /afterimage without its slash is answered by Vercel with the viewer's page and a 200, and the viewer's relative assets
|
|
31
|
+
// then load from the root. The redirect adds the slash. Vercel's redirect source matches that path exactly, so /afterimage/
|
|
32
|
+
// is not caught and nothing loops.
|
|
33
|
+
export const slashRedirect = { source: '/afterimage', destination: '/afterimage/' };
|
|
34
|
+
|
|
35
|
+
// The long cache on a Next.js app's hashed files (/_next/static: content-hashed names, or the build id in the path), which
|
|
36
|
+
// Vercel's Next.js builder and Netlify's Next.js runtime set and which framework: null and NETLIFY_NEXT_PLUGIN_SKIP give up.
|
|
37
|
+
// Both hosts then serve them with max-age=0 (seen on real deploys, docs/checking-a-deploy.md). The source matches the live
|
|
38
|
+
// app's paths only: a version's copies, under /afterimage/versions/vN/_next/static/, keep the host's default.
|
|
39
|
+
const immutable = 'public, max-age=31536000, immutable';
|
|
40
|
+
export const nextStaticCache = { source: '/_next/static/(.*)', headers: [{ key: 'Cache-Control', value: immutable }] };
|
|
41
|
+
// Any rule of the project's own for those paths counts, whatever it sets.
|
|
42
|
+
const isStaticCache = rule => typeof rule?.source === 'string' && rule.source.trim().startsWith('/_next/static/');
|
|
43
|
+
|
|
44
|
+
// A rule counts as ours when it points at <path>.html and keeps /afterimage out, however the rest is spelled.
|
|
45
|
+
const isCleanUrlRewrite = rule => typeof rule?.destination === 'string' && /^\/:\w+\*?\.html$/.test(rule.destination) && typeof rule.source === 'string' && rule.source.includes('afterimage');
|
|
46
|
+
const fromAfterimage = rule => typeof rule?.source === 'string' && rule.source.trim().replace(/\/+$/, '') === slashRedirect.source;
|
|
47
|
+
// Ours is a redirect from /afterimage to /afterimage/. One from there to anywhere else is the project's, and shadows the history.
|
|
48
|
+
const isSlashRedirect = rule => fromAfterimage(rule) && typeof rule.destination === 'string' && rule.destination.trim() === slashRedirect.destination;
|
|
49
|
+
|
|
50
|
+
const cleanUrlsNote = 'vercel.json has "cleanUrls": true. It also redirects the history\'s own pages (/afterimage/versions/vN/index.html), so the history will not open. Remove it: Afterimage\'s rewrite gives the live app clean URLs without touching /afterimage/.';
|
|
51
|
+
// @astrojs/vercel writes Vercel's own .vercel/output on every build, whatever the output mode (astro:build:done, no check
|
|
52
|
+
// of the environment): https://github.com/withastro/astro/blob/main/packages/integrations/vercel/src/index.ts. After a
|
|
53
|
+
// build Vercel's static builder deploys that folder in place of outputDirectory, so /afterimage/ would not be there. Not
|
|
54
|
+
// tried on a real deploy; the files are the project's, so init only says so.
|
|
55
|
+
const astroVercelNote = 'package.json depends on @astrojs/vercel. The adapter writes its own .vercel/output on every build, and Vercel deploys that folder instead of the Afterimage site, so /afterimage/ will not be there. A static site does not need the adapter: remove @astrojs/vercel (and its adapter line in the astro config) to deploy the history on Vercel.';
|
|
56
|
+
const trailingSlashNote = value => `vercel.json has "trailingSlash": ${value}. It redirects paths under /afterimage/ ${value ? 'to a trailing slash, so a version\'s pages are never asked for the way the worker expects' : 'away from the trailing slash, so the viewer\'s relative files load from the wrong folder'}, and the history will not open. Remove it. Afterimage left the rest of vercel.json as it was.`;
|
|
57
|
+
|
|
58
|
+
// Whether a list in vercel.json (rewrites, redirects) needs our entry added after the project's own, which keep their order.
|
|
59
|
+
// → { add } or { note } when the value is not a list, or neither when the entry is already there.
|
|
60
|
+
function planList(settings, key, entry, isPresent, purpose) {
|
|
61
|
+
const list = settings[key];
|
|
62
|
+
if (list === undefined) return { add: [entry] };
|
|
63
|
+
if (!Array.isArray(list)) return { note: `vercel.json "${key}" is not a list, so Afterimage left it alone. Add this to it ${purpose}:\n${JSON.stringify(entry, null, 2)}` };
|
|
64
|
+
return list.some(isPresent) ? {} : { add: [...list, entry] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// What init asks before it switches the settings vercel.json already has (differs) to Afterimage's. One yes switches
|
|
68
|
+
// every key, so a question about only one would hide the other change.
|
|
69
|
+
function switchQuestion(settings, differs) {
|
|
70
|
+
if (differs.includes('framework')) {
|
|
71
|
+
const found = [
|
|
72
|
+
settings.buildCommand != null && settings.buildCommand !== hostBuild && `builds with ${settings.buildCommand}`,
|
|
73
|
+
settings.outputDirectory != null && settings.outputDirectory !== hostOutput && `serves ${settings.outputDirectory}`,
|
|
74
|
+
`uses the ${settings.framework} framework preset, which can serve its own build instead of the history`
|
|
75
|
+
].filter(Boolean);
|
|
76
|
+
return `vercel.json ${found.join(', ')}. Switch to Afterimage's settings? (y/N)`;
|
|
77
|
+
}
|
|
78
|
+
if (differs.length === 2) return `vercel.json builds with ${settings.buildCommand} and serves ${settings.outputDirectory}. Switch both to Afterimage's? (y/N)`;
|
|
79
|
+
if (differs[0] === 'buildCommand') return `vercel.json already builds with ${settings.buildCommand}. Switch it to Afterimage's build? (y/N)`;
|
|
80
|
+
return `vercel.json already serves ${settings.outputDirectory}. Switch it to Afterimage's output? (y/N)`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function planVercel(cwd, { isTTY, ask }) {
|
|
84
|
+
const file = path.join(cwd, 'vercel.json');
|
|
85
|
+
const { bom, body: before } = await readJsonText(file);
|
|
86
|
+
const settings = before === null ? {} : parseJson(before, 'vercel.json');
|
|
87
|
+
const next = await usesNext(cwd);
|
|
88
|
+
const wanted = hostSettings(next);
|
|
89
|
+
const notes = [];
|
|
90
|
+
if (await dependsOn(cwd, '@astrojs/vercel')) notes.push(astroVercelNote);
|
|
91
|
+
|
|
92
|
+
// null means "use the default" in vercel.json, so it is as free as a missing key. For framework it is the wanted
|
|
93
|
+
// value itself ("Other"), so only a framework that is named differs.
|
|
94
|
+
const differs = ['buildCommand', 'outputDirectory'].filter(key => settings[key] != null && settings[key] !== wanted[key]);
|
|
95
|
+
if (Object.hasOwn(wanted, 'framework') && typeof settings.framework === 'string') differs.push('framework');
|
|
96
|
+
const set = Object.keys(wanted).filter(key => (key === 'framework' ? !Object.hasOwn(settings, key) : settings[key] == null));
|
|
97
|
+
|
|
98
|
+
// cleanUrls and trailingSlash redirect paths under /afterimage/ site-wide. They are the person's, so they stay and are
|
|
99
|
+
// called out. A trailingSlash also makes both entries below pointless (or, set to false, loop with the redirect).
|
|
100
|
+
const lists = {};
|
|
101
|
+
if (settings.cleanUrls === true) notes.push(cleanUrlsNote);
|
|
102
|
+
if (typeof settings.trailingSlash === 'boolean') {
|
|
103
|
+
notes.push(trailingSlashNote(settings.trailingSlash));
|
|
104
|
+
} else {
|
|
105
|
+
if (settings.cleanUrls !== true) lists.rewrites = planList(settings, 'rewrites', cleanUrlRewrite, isCleanUrlRewrite, 'for clean URLs on the live app');
|
|
106
|
+
const shadowing = Array.isArray(settings.redirects) && settings.redirects.find(rule => fromAfterimage(rule) && !isSlashRedirect(rule));
|
|
107
|
+
if (shadowing) {
|
|
108
|
+
// A second redirect from the same path would never run, so none is added.
|
|
109
|
+
notes.push(`vercel.json redirects ${shadowing.source} to ${shadowing.destination}, which shadows the history: the viewer cannot open there. Afterimage left it alone. Remove it, or point it at ${slashRedirect.destination}.`);
|
|
110
|
+
} else {
|
|
111
|
+
lists.redirects = planList(settings, 'redirects', slashRedirect, isSlashRedirect, 'so /afterimage opens the viewer');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (next) lists.headers = planList(settings, 'headers', nextStaticCache, isStaticCache, "for the long cache on the live app's /_next/static");
|
|
115
|
+
const added = {};
|
|
116
|
+
for (const [key, plan] of Object.entries(lists)) {
|
|
117
|
+
if (plan.note) notes.push(plan.note);
|
|
118
|
+
if (plan.add) {
|
|
119
|
+
added[key] = plan.add;
|
|
120
|
+
set.push(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (!differs.length && !set.length) return { change: null, notes, apply: null };
|
|
124
|
+
|
|
125
|
+
if (differs.length) {
|
|
126
|
+
const agreed = isTTY && /^y(es)?$/i.test((await ask(switchQuestion(settings, differs))).trim());
|
|
127
|
+
if (!agreed) {
|
|
128
|
+
// Values to set, and entries to add to lists the project may already have: pasting must never replace its own rules.
|
|
129
|
+
const lines = [`Left vercel.json as it is. To deploy the history with Afterimage, set:\n${JSON.stringify(wanted, null, 2).slice(2, -2)}`];
|
|
130
|
+
for (const [key, entry] of [['rewrites', cleanUrlRewrite], ['redirects', slashRedirect], ['headers', nextStaticCache]]) {
|
|
131
|
+
if (key in added) lines.push(`and add this to "${key}", keeping the rules already there:\n${JSON.stringify(entry, null, 2)}`);
|
|
132
|
+
}
|
|
133
|
+
return { change: null, notes: [lines.join('\n'), ...notes], apply: null };
|
|
134
|
+
}
|
|
135
|
+
set.unshift(...differs.filter(key => !set.includes(key)));
|
|
136
|
+
}
|
|
137
|
+
const order = [...Object.keys(wanted), 'rewrites', 'redirects', 'headers'];
|
|
138
|
+
const text = JSON.stringify({ ...settings, ...wanted, ...added }, null, 2);
|
|
139
|
+
return {
|
|
140
|
+
change: before === null ? 'Created vercel.json' : `Updated vercel.json (set ${order.filter(key => set.includes(key)).join(', ')})`,
|
|
141
|
+
notes,
|
|
142
|
+
apply: writer(file, `${bom}${text}\n`)
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Whether the project depends on Next.js, however init was told where its prototype is. Netlify and Vercel detect the
|
|
147
|
+
// framework from package.json, not from what Afterimage decided.
|
|
148
|
+
export async function dependsOn(cwd, name) {
|
|
149
|
+
const { body } = await readJsonText(path.join(cwd, 'package.json'));
|
|
150
|
+
try {
|
|
151
|
+
const manifest = JSON.parse(body ?? '{}');
|
|
152
|
+
return name in { ...manifest?.dependencies, ...manifest?.devDependencies };
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const usesNext = cwd => dependsOn(cwd, 'next');
|
|
159
|
+
|
|
160
|
+
// Netlify's Next.js runtime (OpenNext), which Netlify installs for a Next.js project, treats a static export as its own
|
|
161
|
+
// to deploy: after the build it copies the export's folder (out/) over the publish directory, so /afterimage/ would not
|
|
162
|
+
// deploy. NETLIFY_NEXT_PLUGIN_SKIP makes it step aside. Read in opennextjs-netlify's source (src/index.ts,
|
|
163
|
+
// src/build/content/static.ts), not tried on a real deploy. Netlify answers /about from about.html on its own.
|
|
164
|
+
const skipRuntime = 'NETLIFY_NEXT_PLUGIN_SKIP';
|
|
165
|
+
// nextStaticCache, for Netlify. Its paths match the live app's only, as on Vercel.
|
|
166
|
+
const netlifyStaticCache = `[[headers]]\n for = "/_next/static/*"\n [headers.values]\n Cache-Control = "${immutable}"\n`;
|
|
167
|
+
|
|
168
|
+
export async function planNetlify(cwd) {
|
|
169
|
+
const file = path.join(cwd, 'netlify.toml');
|
|
170
|
+
const before = await readText(file);
|
|
171
|
+
const next = await usesNext(cwd);
|
|
172
|
+
if (before === null) {
|
|
173
|
+
const nextOnly = next ? `\n[build.environment]\n ${skipRuntime} = "true"\n\n${netlifyStaticCache}` : '';
|
|
174
|
+
return { change: 'Created netlify.toml', notes: [], apply: writer(file, `[build]\n command = "${hostBuild}"\n publish = "${hostOutput}"\n${nextOnly}`) };
|
|
175
|
+
}
|
|
176
|
+
// TOML is not parsed or rewritten here: a hand edit is safer than a guessed merge.
|
|
177
|
+
const hasCommand = new RegExp(`^\\s*command\\s*=\\s*["']${hostBuild}["']`, 'm').test(before);
|
|
178
|
+
const hasPublish = new RegExp(`^\\s*publish\\s*=\\s*["']${hostOutput.replace('.', '\\.')}["']`, 'm').test(before);
|
|
179
|
+
const hasSkip = new RegExp(`^\\s*${skipRuntime}\\s*=\\s*["']?(true|1)["']?\\s*$`, 'm').test(before);
|
|
180
|
+
// Any [[headers]] rule of the project's own for those paths counts, whatever it sets.
|
|
181
|
+
const hasCache = /^\s*for\s*=\s*["']\/_next\/static\//m.test(before);
|
|
182
|
+
const wantsSkip = next && !hasSkip;
|
|
183
|
+
const wantsCache = next && !hasCache;
|
|
184
|
+
if (hasCommand && hasPublish && !wantsSkip && !wantsCache) return { change: null, notes: [], apply: null };
|
|
185
|
+
const lines = [];
|
|
186
|
+
if (!hasCommand || !hasPublish) lines.push(`Under [build], set:\n command = "${hostBuild}"\n publish = "${hostOutput}"`);
|
|
187
|
+
if (wantsSkip) lines.push(`Under [build.environment], set (so Netlify's Next.js runtime leaves the history in place instead of deploying out/):\n ${skipRuntime} = "true"`);
|
|
188
|
+
if (wantsCache) lines.push(`To keep the long cache on the live app's /_next/static, which the runtime would have set, add:\n${netlifyStaticCache.trimEnd()}`);
|
|
189
|
+
return {
|
|
190
|
+
change: null,
|
|
191
|
+
notes: [`netlify.toml already exists, so Afterimage left it alone. ${lines.join('\n')}`],
|
|
192
|
+
apply: null
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/cli/main.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import * as init from './commands/init.js';
|
|
2
|
+
import * as save from './commands/save.js';
|
|
3
|
+
import * as build from './commands/build.js';
|
|
4
|
+
import * as dev from './commands/dev.js';
|
|
5
|
+
import { isUserError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
// One entry per command. Each module exports run(args, ctx) → exit code, a one-line usage, a summary, its help (what
|
|
8
|
+
// both "afterimage help <command>" and "<command> --help" print) and its options.
|
|
9
|
+
export const commands = { init, save, dev, build };
|
|
10
|
+
|
|
11
|
+
function overview() {
|
|
12
|
+
const lines = Object.values(commands).map(command => ` ${command.usage}\n ${command.summary}`);
|
|
13
|
+
return `Usage: afterimage <command>\n\nCommands:\n${lines.join('\n')}\n\nRun "afterimage help <command>" for one command.\n`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Usage mistakes exit 2, failures exit 1, success 0.
|
|
17
|
+
export async function main(argv, ctx) {
|
|
18
|
+
const { stdout, stderr } = ctx;
|
|
19
|
+
const [first, ...rest] = argv;
|
|
20
|
+
if (first === undefined || first === '--help' || first === '-h') {
|
|
21
|
+
stdout.write(overview());
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
if (first === 'help') {
|
|
25
|
+
if (rest.length === 0) {
|
|
26
|
+
stdout.write(overview());
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
const command = Object.hasOwn(commands, rest[0]) ? commands[rest[0]] : null;
|
|
30
|
+
if (!command) {
|
|
31
|
+
stderr.write(`Unknown command "${rest[0]}".\n\n${overview()}`);
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
stdout.write(command.help);
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
if (!Object.hasOwn(commands, first)) {
|
|
38
|
+
stderr.write(`Unknown command "${first}".\n\n${overview()}`);
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
return await commands[first].run(rest, ctx);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
stderr.write(isUserError(error) ? `${error.message}\n` : `${error?.stack ?? error}\n`);
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Reading and writing the project's own files, for init and the host config it plans.
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
|
|
4
|
+
// Windows editors often save JSON with a byte order mark. JSON.parse rejects it, so it is split off and put back.
|
|
5
|
+
const bomMark = '';
|
|
6
|
+
|
|
7
|
+
export async function readText(file) {
|
|
8
|
+
try {
|
|
9
|
+
return await fs.readFile(file, 'utf8');
|
|
10
|
+
} catch (error) {
|
|
11
|
+
if (error.code === 'ENOENT') return null;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// → { bom, body } with body null when the file does not exist.
|
|
17
|
+
export async function readJsonText(file) {
|
|
18
|
+
const raw = await readText(file);
|
|
19
|
+
if (raw === null) return { bom: '', body: null };
|
|
20
|
+
const bom = raw.startsWith(bomMark) ? bomMark : '';
|
|
21
|
+
return { bom, body: raw.slice(bom.length) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseJson(text, name) {
|
|
25
|
+
let value;
|
|
26
|
+
try {
|
|
27
|
+
value = JSON.parse(text);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
throw new Error(`${name} is not valid JSON: ${error.message}\nFix it, then run "afterimage init" again.`);
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
32
|
+
throw new Error(`${name} must be a JSON object.\nFix it, then run "afterimage init" again.`);
|
|
33
|
+
}
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A step's write, run only once every step has been planned.
|
|
38
|
+
export const writer = (file, text) => () => fs.writeFile(file, text);
|
package/src/cli/retry.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// A file that something else holds open (a static server, an indexer, Windows antivirus scanning a file just written)
|
|
2
|
+
// often frees itself within a moment, so an operation that hits one is tried once more before giving up.
|
|
3
|
+
const lockedCodes = new Set(['EPERM', 'EBUSY', 'EACCES']);
|
|
4
|
+
|
|
5
|
+
export const isLocked = error => lockedCodes.has(error?.code);
|
|
6
|
+
|
|
7
|
+
// Runs operation, and once more after delayMs when it fails on a locked file. The second failure is thrown as it is.
|
|
8
|
+
export async function retryLocked(operation, delayMs = 250) {
|
|
9
|
+
try {
|
|
10
|
+
return await operation();
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (!isLocked(error)) throw error;
|
|
13
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
14
|
+
return operation();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// The command is the user's own (from afterimage.config.json), so it runs through the platform shell like an npm script.
|
|
4
|
+
// Tests pass 'ignore' or 'pipe' so a build's output does not land in the test report.
|
|
5
|
+
// Each caller says what a failed build leaves undone: "Nothing was saved." for save, "Nothing was built." for build.
|
|
6
|
+
//
|
|
7
|
+
// The build runs without the VERCEL variable, and with everything else as it was (VERCEL_URL and the rest stay).
|
|
8
|
+
// Vercel sets VERCEL while it builds, and some tools take it as a cue to write for Vercel's own deployment instead of
|
|
9
|
+
// plain files: SvelteKit's adapter-static then writes .vercel/output and never its usual folder
|
|
10
|
+
// (https://github.com/sveltejs/kit/blob/main/packages/adapter-static/platforms.js). Afterimage saves and deploys the
|
|
11
|
+
// plain static build, so the build must behave the same on a host as on a laptop.
|
|
12
|
+
export function runBuild(command, cwd, { stdio = 'inherit', closing = 'Nothing was saved.' } = {}) {
|
|
13
|
+
const { VERCEL, ...env } = process.env;
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const child = spawn(command, { shell: true, cwd, stdio, env });
|
|
16
|
+
child.on('error', error => reject(new Error(`The build could not start: ${error.message}. ${closing}`)));
|
|
17
|
+
child.on('close', (code, signal) => {
|
|
18
|
+
if (code === 0) resolve();
|
|
19
|
+
else if (code === null) reject(new Error(`The build was stopped by ${signal}. ${closing}`));
|
|
20
|
+
else reject(new Error(`The build failed (exit code ${code}). ${closing}`));
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|