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,307 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { configName, readConfig, writeConfig } from '../config.js';
|
|
7
|
+
import { detectProject } from '../detect.js';
|
|
8
|
+
import { dependsOn, planNetlify, planVercel } from '../hosts.js';
|
|
9
|
+
import { parseJson, readJsonText, readText, writer } from '../project-files.js';
|
|
10
|
+
|
|
11
|
+
export const usage = 'afterimage init [--source <folder>] [--build "<command>"] [--agent claude|cursor] [--no-input]';
|
|
12
|
+
export const summary = 'Set up Afterimage in this project: config, scripts, git rules and host config.';
|
|
13
|
+
// What both "afterimage init --help" and "afterimage help init" print.
|
|
14
|
+
export const help = `Usage: ${usage}\n\n${summary}\n\n --source <folder> the folder that holds the prototype, or that your build writes to\n --build "<command>" the command that builds it, when there is one\n --agent claude|cursor also copy Afterimage's skill (Claude Code) or rule (Cursor) into this project\n --no-input ask nothing, even in a terminal, and print what is left to do instead\n`;
|
|
15
|
+
// Read by the docs tests, which check every flag the agent files name.
|
|
16
|
+
export const options = { source: { type: 'string' }, build: { type: 'string' }, agent: { type: 'string' }, 'no-input': { type: 'boolean' }, help: { type: 'boolean', short: 'h' } };
|
|
17
|
+
|
|
18
|
+
const scripts = {
|
|
19
|
+
'afterimage:save': 'afterimage save',
|
|
20
|
+
'afterimage:dev': 'afterimage dev',
|
|
21
|
+
'afterimage:build': 'afterimage build'
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// The skill and the Cursor rule the package ships, found from this file so they work from node_modules and a checkout.
|
|
25
|
+
const packagedAgentsDir = fileURLToPath(new URL('../../../agents/', import.meta.url));
|
|
26
|
+
|
|
27
|
+
// Where each agent reads Afterimage's instructions from. Init only writes there when --agent asks it to.
|
|
28
|
+
const agents = {
|
|
29
|
+
claude: { from: 'skill', to: '.claude/skills/afterimage', what: 'skill', loads: 'Claude Code loads the skill in a new session, or after /reload-skills.' },
|
|
30
|
+
cursor: { from: 'cursor/afterimage.mdc', to: '.cursor/rules/afterimage.mdc', what: 'rule', loads: null }
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const skillHint = [
|
|
34
|
+
'To let your coding agent save and compare versions, run one of:',
|
|
35
|
+
...Object.entries(agents).map(([name, agent]) => ` npx afterimage init --agent ${name} copies the ${agent.what} to ${agent.to}${agent.what === 'skill' ? '/' : ''}`)
|
|
36
|
+
].join('\n');
|
|
37
|
+
|
|
38
|
+
const nextStep = 'Next: npx afterimage save "First version"';
|
|
39
|
+
|
|
40
|
+
async function isFolder(file) {
|
|
41
|
+
try {
|
|
42
|
+
return (await fs.stat(file)).isDirectory();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT') return false;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const plural = (count, word) => `${count} ${word}${count === 1 ? '' : 's'}`;
|
|
50
|
+
|
|
51
|
+
// package.json keeps its own look (indent, line ending, final newline) so the diff in git is only the keys we add.
|
|
52
|
+
function writePackageText(original, value) {
|
|
53
|
+
const indent = original?.match(/^([ \t]+)"/m)?.[1] ?? ' ';
|
|
54
|
+
const eol = original?.includes('\r\n') ? '\r\n' : '\n';
|
|
55
|
+
const text = JSON.stringify(value, null, indent).replace(/\n/g, eol);
|
|
56
|
+
return original === null || /\n$/.test(original) ? text + eol : text;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The code Node's own aborted prompts use. An ask function rejects with it when the user cancels.
|
|
60
|
+
const abortCode = 'ABORT_ERR';
|
|
61
|
+
|
|
62
|
+
// Reads one answer from the terminal. A closed stdin (Ctrl-D, EOF) counts as no answer instead of hanging, and
|
|
63
|
+
// Ctrl-C aborts the whole init. Tests pass their own ctx.ask.
|
|
64
|
+
async function askInTerminal(question) {
|
|
65
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
66
|
+
const closed = new Promise(resolve => rl.once('close', () => resolve('')));
|
|
67
|
+
const interrupted = new Promise((_, reject) => rl.once('SIGINT', () => reject(Object.assign(new Error('Prompt cancelled.'), { code: abortCode }))));
|
|
68
|
+
try {
|
|
69
|
+
return await Promise.race([rl.question(`${question} `), closed, interrupted]);
|
|
70
|
+
} finally {
|
|
71
|
+
rl.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const needSource = reason => new Error(`${reason}\nSay where the prototype is: afterimage init --source <folder>\nAdd --build "<command>" if a build command produces that folder.`);
|
|
76
|
+
|
|
77
|
+
// Decides source, build and type for a fresh config. Flags win over detection. A given source keeps detection's type
|
|
78
|
+
// when it names the same folder; otherwise the type is 'plain' for a folder of static files (no build) and 'unknown'
|
|
79
|
+
// when a custom build produces it.
|
|
80
|
+
async function chooseSetup(cwd, flags, { isTTY, ask }) {
|
|
81
|
+
const detection = await detectProject(cwd);
|
|
82
|
+
// What would give the project a static build to save. For Astro with @astrojs/vercel that is only about saving: the
|
|
83
|
+
// adapter still writes .vercel/output, so deploying the history on Vercel needs it removed too (planVercel says so).
|
|
84
|
+
const serverFix = {
|
|
85
|
+
'next-server': "output: 'export' in next.config",
|
|
86
|
+
'astro-server': await dependsOn(cwd, '@astrojs/vercel')
|
|
87
|
+
? "output: 'static' in the astro config (to save its static build; the history can only deploy on Vercel without @astrojs/vercel)"
|
|
88
|
+
: "output: 'static' in the astro config",
|
|
89
|
+
'sveltekit-server': 'the adapter in svelte.config to @sveltejs/adapter-static (npm install -D @sveltejs/adapter-static)'
|
|
90
|
+
}[detection.type];
|
|
91
|
+
if (serverFix && !flags.source) {
|
|
92
|
+
throw new Error(`${detection.reason}\nSet ${serverFix} and run "afterimage init" again, or pass --source <folder> if a build writes static files somewhere else.`);
|
|
93
|
+
}
|
|
94
|
+
let source = flags.source;
|
|
95
|
+
if (!source && detection.source === null) {
|
|
96
|
+
if (!isTTY) throw needSource(detection.reason);
|
|
97
|
+
source = (await ask('Which folder holds your prototype, or which folder does your build write to?')).trim();
|
|
98
|
+
if (!source) throw needSource(detection.reason);
|
|
99
|
+
}
|
|
100
|
+
if (!source) {
|
|
101
|
+
return { source: detection.source, build: flags.build ?? detection.build, type: detection.type };
|
|
102
|
+
}
|
|
103
|
+
source = projectRelative(cwd, source);
|
|
104
|
+
const matches = detection.source !== null && source === projectRelative(cwd, detection.source);
|
|
105
|
+
const build = flags.build ?? (matches ? detection.build : null);
|
|
106
|
+
return { source, build, type: matches ? detection.type : build ? 'unknown' : 'plain' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Forward slashes, relative to the project, '.' for the project root. Anything outside the project is refused.
|
|
110
|
+
function projectRelative(cwd, folder) {
|
|
111
|
+
const relative = path.relative(cwd, path.resolve(cwd, folder)).replace(/\\/g, '/');
|
|
112
|
+
if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) {
|
|
113
|
+
throw new Error(`${folder} is outside this project. Pick a folder inside it.`);
|
|
114
|
+
}
|
|
115
|
+
return relative || '.';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Every step is split in two. A plan only reads and asks, and returns { change, notes, apply }. Nothing is written
|
|
119
|
+
// until every plan has succeeded, so a bad input leaves the project exactly as it was.
|
|
120
|
+
|
|
121
|
+
async function planConfig(cwd, flags, ctx) {
|
|
122
|
+
const before = await readText(path.join(cwd, configName));
|
|
123
|
+
if (before !== null && !flags.source && flags.build === undefined) {
|
|
124
|
+
return { config: await readConfig(cwd), change: null, notes: [], apply: null };
|
|
125
|
+
}
|
|
126
|
+
const existing = before === null ? null : await readConfig(cwd);
|
|
127
|
+
let config;
|
|
128
|
+
if (existing) {
|
|
129
|
+
config = { ...existing, source: flags.source ? projectRelative(cwd, flags.source) : existing.source, build: flags.build ?? existing.build };
|
|
130
|
+
} else {
|
|
131
|
+
const chosen = await chooseSetup(cwd, flags, ctx);
|
|
132
|
+
config = { source: projectRelative(cwd, chosen.source), build: chosen.build, history: 'afterimage', out: '.afterimage/site', exclude: [], type: chosen.type };
|
|
133
|
+
}
|
|
134
|
+
if (!config.build && !await isFolder(path.resolve(cwd, config.source))) {
|
|
135
|
+
throw new Error(`${config.source} does not exist yet. If a build creates it, run init again with --build "<command>"; otherwise check the folder name.`);
|
|
136
|
+
}
|
|
137
|
+
if (existing && existing.source === config.source && existing.build === config.build) {
|
|
138
|
+
return { config, change: null, notes: [], apply: null };
|
|
139
|
+
}
|
|
140
|
+
const detail = `source ${config.source}${config.build ? `, build "${config.build}"` : ''}`;
|
|
141
|
+
return {
|
|
142
|
+
config,
|
|
143
|
+
change: `${existing ? 'Updated' : 'Created'} ${configName} (${detail})`,
|
|
144
|
+
notes: [],
|
|
145
|
+
apply: () => writeConfig(cwd, config)
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function planPackage(cwd) {
|
|
150
|
+
const file = path.join(cwd, 'package.json');
|
|
151
|
+
const { bom, body: before } = await readJsonText(file);
|
|
152
|
+
const manifest = before === null ? { private: true, scripts: {} } : parseJson(before, 'package.json');
|
|
153
|
+
if (manifest.scripts !== undefined && (!manifest.scripts || typeof manifest.scripts !== 'object' || Array.isArray(manifest.scripts))) {
|
|
154
|
+
throw new Error('package.json "scripts" must be an object.\nFix it, then run "afterimage init" again.');
|
|
155
|
+
}
|
|
156
|
+
const existing = manifest.scripts ?? {};
|
|
157
|
+
const added = Object.keys(scripts).filter(name => !Object.hasOwn(existing, name));
|
|
158
|
+
const notes = [];
|
|
159
|
+
if (Object.hasOwn(existing, 'afterimage:build') && existing['afterimage:build'] !== scripts['afterimage:build']) {
|
|
160
|
+
notes.push(`package.json already has "afterimage:build": "${existing['afterimage:build']}". The host config runs that script, so it should be "${scripts['afterimage:build']}".`);
|
|
161
|
+
}
|
|
162
|
+
if (!added.length) return { change: null, notes, apply: null };
|
|
163
|
+
manifest.scripts = { ...existing, ...Object.fromEntries(added.map(name => [name, scripts[name]])) };
|
|
164
|
+
const what = `added ${plural(added.length, 'script')}`;
|
|
165
|
+
return {
|
|
166
|
+
change: `${before === null ? 'Created' : 'Updated'} package.json (${what})`,
|
|
167
|
+
notes,
|
|
168
|
+
apply: writer(file, bom + writePackageText(before, manifest))
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Adds each line that is not already there, in the file's own line ending.
|
|
173
|
+
async function planLines(cwd, name, lines, isPresent) {
|
|
174
|
+
const file = path.join(cwd, name);
|
|
175
|
+
const before = await readText(file);
|
|
176
|
+
const present = (before ?? '').split(/\r?\n/);
|
|
177
|
+
const added = lines.filter(line => !present.some(existing => isPresent(existing, line)));
|
|
178
|
+
if (!added.length) return { change: null, notes: [], apply: null };
|
|
179
|
+
const eol = before?.includes('\r\n') ? '\r\n' : '\n';
|
|
180
|
+
const gap = before && !/\n$/.test(before) ? eol : '';
|
|
181
|
+
return {
|
|
182
|
+
change: before === null ? `Created ${name}` : `Updated ${name} (added ${added.join(', ')})`,
|
|
183
|
+
notes: [],
|
|
184
|
+
apply: writer(file, `${before ?? ''}${gap}${added.join(eol)}${eol}`)
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// A rule counts as present when the pattern is there with -text, however the rest of the line reads.
|
|
189
|
+
const hasTextRule = (existing, rule) => {
|
|
190
|
+
const [pattern] = rule.split(' ');
|
|
191
|
+
const tokens = existing.trim().split(/\s+/);
|
|
192
|
+
return tokens[0] === pattern && tokens.includes('-text');
|
|
193
|
+
};
|
|
194
|
+
const stripSlashes = line => line.trim().replace(/^\/+|\/+$/g, '');
|
|
195
|
+
const hasIgnore = (existing, line) => stripSlashes(existing) === stripSlashes(line);
|
|
196
|
+
|
|
197
|
+
// Every file of the package's copy for this agent, as [package path, project path], forward slashes.
|
|
198
|
+
async function agentFiles({ from, to }) {
|
|
199
|
+
const source = path.join(packagedAgentsDir, from);
|
|
200
|
+
if (!(await fs.stat(source)).isDirectory()) return [[from, to]];
|
|
201
|
+
const names = (await fs.readdir(source, { recursive: true, withFileTypes: true })).filter(entry => entry.isFile());
|
|
202
|
+
return names.map(entry => {
|
|
203
|
+
const relative = path.relative(source, path.join(entry.parentPath, entry.name)).replace(/\\/g, '/');
|
|
204
|
+
return [`${from}/${relative}`, `${to}/${relative}`];
|
|
205
|
+
}).sort(([a], [b]) => (a < b ? -1 : 1));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A checkout with autocrlf can turn the copy's line endings, which is still the same copy, so it is not asked about.
|
|
209
|
+
const sameText = (a, b) => a.replace(/\r\n/g, '\n') === b.replace(/\r\n/g, '\n');
|
|
210
|
+
|
|
211
|
+
async function planAgent(cwd, name, { isTTY, ask }) {
|
|
212
|
+
if (!name) return { change: null, notes: [], apply: null };
|
|
213
|
+
const changes = [];
|
|
214
|
+
const notes = [];
|
|
215
|
+
const writes = [];
|
|
216
|
+
for (const [from, to] of await agentFiles(agents[name])) {
|
|
217
|
+
const text = await fs.readFile(path.join(packagedAgentsDir, from), 'utf8');
|
|
218
|
+
const before = await readText(path.join(cwd, to));
|
|
219
|
+
if (before !== null && sameText(before, text)) continue;
|
|
220
|
+
if (before !== null) {
|
|
221
|
+
// The file is the person's now; they may have edited it.
|
|
222
|
+
const agreed = isTTY && /^y(es)?$/i.test((await ask(`${to} differs from Afterimage's copy. Replace it? (y/N)`)).trim());
|
|
223
|
+
if (!agreed) {
|
|
224
|
+
notes.push(`${to} differs from Afterimage's copy, so Afterimage left it as it is. To replace it, run "npx afterimage init --agent ${name}" in a terminal and answer y.`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
changes.push(`${before === null ? 'Created' : 'Updated'} ${to}`);
|
|
229
|
+
writes.push([path.join(cwd, to), text]);
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
change: changes.join('\n') || null,
|
|
233
|
+
notes,
|
|
234
|
+
apply: writes.length ? async () => {
|
|
235
|
+
for (const [file, text] of writes) {
|
|
236
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
237
|
+
await fs.writeFile(file, text);
|
|
238
|
+
}
|
|
239
|
+
} : null
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function run(args, ctx) {
|
|
244
|
+
const { cwd, stdout, stderr } = ctx;
|
|
245
|
+
|
|
246
|
+
let parsed;
|
|
247
|
+
try {
|
|
248
|
+
parsed = parseArgs({ args, options });
|
|
249
|
+
} catch (error) {
|
|
250
|
+
if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
|
|
251
|
+
stderr.write(`${error.message}\nUsage: ${usage}\n`);
|
|
252
|
+
return 2;
|
|
253
|
+
}
|
|
254
|
+
if (parsed.values.help) {
|
|
255
|
+
stdout.write(help);
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
const flags = { source: parsed.values.source, build: parsed.values.build };
|
|
259
|
+
if (flags.source !== undefined && !flags.source.trim()) {
|
|
260
|
+
stderr.write(`--source needs a folder.\nUsage: ${usage}\n`);
|
|
261
|
+
return 2;
|
|
262
|
+
}
|
|
263
|
+
const agent = parsed.values.agent;
|
|
264
|
+
if (agent !== undefined && !Object.hasOwn(agents, agent)) {
|
|
265
|
+
stderr.write(`--agent needs claude or cursor.\nUsage: ${usage}\n`);
|
|
266
|
+
return 2;
|
|
267
|
+
}
|
|
268
|
+
// Prompts only happen with a real terminal. An agent's terminal can look like one, so --no-input rules them out.
|
|
269
|
+
// Tests inject both pieces.
|
|
270
|
+
const isTTY = !parsed.values['no-input'] && (ctx.isTTY ?? Boolean(process.stdin.isTTY));
|
|
271
|
+
const prompt = { isTTY, ask: ctx.ask ?? askInTerminal };
|
|
272
|
+
|
|
273
|
+
// Plan first, write after. package.json is read before the config step so a bad one stops init before any question,
|
|
274
|
+
// and detection (which reads package.json) still sees the project as it was.
|
|
275
|
+
let plans;
|
|
276
|
+
try {
|
|
277
|
+
const packagePlan = await planPackage(cwd);
|
|
278
|
+
const configPlan = await planConfig(cwd, flags, prompt);
|
|
279
|
+
plans = [
|
|
280
|
+
configPlan,
|
|
281
|
+
packagePlan,
|
|
282
|
+
await planLines(cwd, '.gitattributes', [`${configPlan.config.history}/** -text`], hasTextRule),
|
|
283
|
+
// The save's lock and stage folders too, which a killed save can leave in the history folder.
|
|
284
|
+
await planLines(cwd, '.gitignore', ['.afterimage/', `${configPlan.config.history}/.afterimage-*`, 'node_modules/'], hasIgnore),
|
|
285
|
+
await planVercel(cwd, prompt),
|
|
286
|
+
await planNetlify(cwd),
|
|
287
|
+
await planAgent(cwd, agent, prompt)
|
|
288
|
+
];
|
|
289
|
+
} catch (error) {
|
|
290
|
+
// Ctrl-C at a prompt. Nothing has been written yet, so stopping here is clean. 130 is the shell's code for SIGINT.
|
|
291
|
+
if (error?.code !== abortCode) throw error;
|
|
292
|
+
stderr.write('Stopped. Nothing was changed.\n');
|
|
293
|
+
return 130;
|
|
294
|
+
}
|
|
295
|
+
for (const plan of plans) await plan.apply?.();
|
|
296
|
+
|
|
297
|
+
const changes = plans.map(plan => plan.change).filter(Boolean);
|
|
298
|
+
const notes = plans.flatMap(plan => plan.notes);
|
|
299
|
+
|
|
300
|
+
if (changes.length) stdout.write(`${changes.join('\n')}\n`);
|
|
301
|
+
else stdout.write(notes.length ? 'No files changed. Still left for you:\n' : 'Everything is already set up. Nothing changed.\n');
|
|
302
|
+
if (notes.length) stdout.write(`${changes.length ? '\n' : ''}${notes.join('\n\n')}\n`);
|
|
303
|
+
// With --agent the copy is in place, and a fresh one needs loading; without it, say how to add it.
|
|
304
|
+
const agentLine = agent ? plans.at(-1).change && agents[agent].loads : skillHint;
|
|
305
|
+
stdout.write(`\n${agentLine ? `${agentLine}\n` : ''}${nextStep}\n`);
|
|
306
|
+
return 0;
|
|
307
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { readConfig, sourceExcludes } from '../config.js';
|
|
4
|
+
import { saveVersion } from '../history.js';
|
|
5
|
+
import { runBuild } from '../run-build.js';
|
|
6
|
+
|
|
7
|
+
export const usage = 'afterimage save "Name" [--note "..."]';
|
|
8
|
+
export const summary = 'Build the prototype and save it as the next version.';
|
|
9
|
+
// What both "afterimage save --help" and "afterimage help save" print.
|
|
10
|
+
export const help = `Usage: ${usage}\n\n${summary}\n\n --note "..." a note on the version, up to 500 characters; repeat it for more than one\n`;
|
|
11
|
+
// Read by the docs tests, which check every flag the agent files name.
|
|
12
|
+
export const options = { note: { type: 'string', multiple: true }, help: { type: 'boolean', short: 'h' } };
|
|
13
|
+
|
|
14
|
+
export async function run(args, { cwd, stdout, stderr, buildStdio, largeHistory = 100_000_000 }) {
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = parseArgs({ args, allowPositionals: true, options });
|
|
18
|
+
} catch (error) {
|
|
19
|
+
if (!error.code?.startsWith('ERR_PARSE_ARGS')) throw error;
|
|
20
|
+
stderr.write(`${error.message}\nUsage: ${usage}\n`);
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
if (parsed.values.help) {
|
|
24
|
+
stdout.write(help);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
27
|
+
const [name, ...extra] = parsed.positionals;
|
|
28
|
+
if (!name || extra.length) {
|
|
29
|
+
stderr.write(`${extra.length ? 'Save takes one name. Put it in quotes.' : 'Save needs a name for the version.'}\nUsage: ${usage}\n`);
|
|
30
|
+
return 2;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const config = await readConfig(cwd);
|
|
34
|
+
if (config.build) await runBuild(config.build, cwd, { stdio: buildStdio });
|
|
35
|
+
const { version, unchanged, bytes, history } = await saveVersion(
|
|
36
|
+
{ historyDir: path.resolve(cwd, config.history), sourceDir: path.resolve(cwd, config.source), exclude: await sourceExcludes(cwd, config) },
|
|
37
|
+
{ name, notes: parsed.values.note ?? [] }
|
|
38
|
+
);
|
|
39
|
+
if (unchanged) {
|
|
40
|
+
stdout.write(`Nothing changed since ${version.id} "${version.name}". Nothing was saved.\n`);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
stdout.write(`Saved ${version.id} "${version.name}".\n`);
|
|
44
|
+
stdout.write(`${version.id} adds ${formatSize(bytes)}. History: ${history.versions} ${history.versions === 1 ? 'version' : 'versions'}, ${formatSize(history.bytes)}.\n`);
|
|
45
|
+
// Every version is a full copy and every deploy carries all of them, so growth is said out loud rather than capped.
|
|
46
|
+
if (history.bytes >= largeHistory) {
|
|
47
|
+
stdout.write(`The history is ${formatSize(history.bytes)}, and every deploy carries all of it. Large images and video are the usual cause: ${sizeDocs}\n`);
|
|
48
|
+
}
|
|
49
|
+
return 0;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const sizeDocs = 'https://github.com/uxlucas/Afterimage/blob/main/docs/limits.md#history-size';
|
|
53
|
+
|
|
54
|
+
// Decimal units, as macOS Finder and npm count them.
|
|
55
|
+
export function formatSize(bytes) {
|
|
56
|
+
if (bytes < 1000) return `${bytes} ${bytes === 1 ? 'byte' : 'bytes'}`;
|
|
57
|
+
if (bytes < 999_500) return `${Math.round(bytes / 1000)} KB`;
|
|
58
|
+
if (bytes < 999_950_000) return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
59
|
+
return `${(bytes / 1_000_000_000).toFixed(1)} GB`;
|
|
60
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const configName = 'afterimage.config.json';
|
|
5
|
+
|
|
6
|
+
const defaults = { source: '.', build: null, history: 'afterimage', out: '.afterimage/site', exclude: [], type: 'unknown' };
|
|
7
|
+
|
|
8
|
+
// Names that are the project's own files, not the prototype. They only matter when the prototype is the project root.
|
|
9
|
+
// The host configs are the ones init writes, and the lockfiles are every package manager's.
|
|
10
|
+
const projectNames = [
|
|
11
|
+
'node_modules', 'afterimage', '.afterimage', 'package.json', 'package-lock.json', configName, 'vercel.json', 'netlify.toml',
|
|
12
|
+
'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb'
|
|
13
|
+
];
|
|
14
|
+
// And the project's own docs next to them: a README, a LICENSE, a CHANGELOG, a CONTRIBUTING guide and an agent's
|
|
15
|
+
// instructions (CLAUDE.md, AGENTS.md, GEMINI.md), as files, in any case, bare or with a text extension. Left in, they
|
|
16
|
+
// would be saved into every version and published at the live root. A page or a folder with one of these names
|
|
17
|
+
// (license.html, agents/) can be part of the prototype, so it stays.
|
|
18
|
+
const projectDoc = /^(readme|license|changelog|contributing|claude|agents|gemini)(\.(md|markdown|txt|rst))?$/i;
|
|
19
|
+
|
|
20
|
+
// Same shape readSnapshot expects: forward slashes, no leading ./, no trailing slash. "x/../site" is "site" and "src/.." is ".".
|
|
21
|
+
const cleanPath = value => {
|
|
22
|
+
const cleaned = path.posix.normalize(value.replace(/\\/g, '/')).replace(/\/+$/, '').replace(/^\.\//, '');
|
|
23
|
+
return cleaned || '.';
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Afterimage only works inside the project, so an absolute path or one that climbs out of it is a mistake.
|
|
27
|
+
const leavesProject = cleaned => cleaned.startsWith('/') || /^[a-zA-Z]:/.test(cleaned) || cleaned === '..' || cleaned.startsWith('../');
|
|
28
|
+
|
|
29
|
+
function validate(raw) {
|
|
30
|
+
const fail = problem => { throw new Error(`${configName} ${problem}.`); };
|
|
31
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('must be a JSON object');
|
|
32
|
+
const config = { ...defaults, ...raw };
|
|
33
|
+
for (const key of ['source', 'history', 'out']) {
|
|
34
|
+
if (typeof config[key] !== 'string' || !config[key]) fail(`needs "${key}" to be a non-empty string`);
|
|
35
|
+
}
|
|
36
|
+
if (config.build !== null && typeof config.build !== 'string') fail('needs "build" to be a string or null');
|
|
37
|
+
if (typeof config.type !== 'string') fail('needs "type" to be a string');
|
|
38
|
+
if (!Array.isArray(config.exclude) || config.exclude.some(entry => typeof entry !== 'string')) fail('needs "exclude" to be a list of strings');
|
|
39
|
+
const [source, history, out] = ['source', 'history', 'out'].map(key => {
|
|
40
|
+
const cleaned = cleanPath(config[key]);
|
|
41
|
+
if (leavesProject(cleaned)) fail(`needs "${key}" to be a folder inside the project, not an absolute path or one that starts with ".."`);
|
|
42
|
+
return cleaned;
|
|
43
|
+
});
|
|
44
|
+
return { source, build: config.build, history, out, exclude: [...config.exclude], type: config.type };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function readConfig(projectDir) {
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = await fs.readFile(path.join(projectDir, configName), 'utf8');
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === 'ENOENT') throw new Error(`No ${configName} here. Run "afterimage init" first.`);
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(text);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
throw new Error(`${configName} is not valid JSON: ${error.message}`);
|
|
60
|
+
}
|
|
61
|
+
return validate(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeConfig(projectDir, config) {
|
|
65
|
+
await fs.writeFile(path.join(projectDir, configName), `${JSON.stringify(validate(config), null, 2)}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// What the store leaves out when the prototype is the project root. A build folder has none of the project-level
|
|
69
|
+
// names inside it, so only the user's own excludes apply there, plus the history and out folders if a hand-written
|
|
70
|
+
// config puts them inside it. rootFiles are the files (not folders) at the top of the project as they are spelled on
|
|
71
|
+
// disk, so the docs among them are left out by their own spelling.
|
|
72
|
+
export function projectExcludes(config, rootFiles = []) {
|
|
73
|
+
const source = cleanPath(config.source);
|
|
74
|
+
if (source !== '.' && source !== '') {
|
|
75
|
+
const inside = [config.history, config.out].map(cleanPath).filter(dir => dir.startsWith(`${source}/`));
|
|
76
|
+
return [...new Set([...inside.map(dir => dir.slice(source.length + 1)), ...config.exclude])];
|
|
77
|
+
}
|
|
78
|
+
const docs = rootFiles.filter(name => projectDoc.test(name));
|
|
79
|
+
return [...new Set([...projectNames, ...docs, config.history, config.out, ...config.exclude].map(cleanPath))];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// projectExcludes for the project as it is on disk now.
|
|
83
|
+
export async function sourceExcludes(projectDir, config) {
|
|
84
|
+
const atRoot = cleanPath(config.source) === '.';
|
|
85
|
+
if (!atRoot) return projectExcludes(config);
|
|
86
|
+
const entries = await fs.readdir(path.resolve(projectDir, config.source), { withFileTypes: true });
|
|
87
|
+
return projectExcludes(config, entries.filter(entry => entry.isFile()).map(entry => entry.name));
|
|
88
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const found = (type, source, build, reason) => ({ type, source, build, reason });
|
|
5
|
+
const unknown = reason => found('unknown', null, null, reason);
|
|
6
|
+
|
|
7
|
+
async function readText(file) {
|
|
8
|
+
try {
|
|
9
|
+
// A byte order mark (common from Windows editors) would make a valid package.json fail JSON.parse.
|
|
10
|
+
return (await fs.readFile(file, 'utf8')).replace(/^/, '');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT') return null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Framework configs are code, so they are only searched as text and never run.
|
|
18
|
+
async function readFirst(projectDir, names) {
|
|
19
|
+
for (const name of names) {
|
|
20
|
+
const text = await readText(path.join(projectDir, name));
|
|
21
|
+
if (text !== null) return text;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// wrapped: the folder has a package.json that only holds afterimage-cli (see detectProject).
|
|
27
|
+
async function detectPlain(projectDir, wrapped = false) {
|
|
28
|
+
const entries = await fs.readdir(projectDir, { withFileTypes: true });
|
|
29
|
+
if (entries.some(entry => entry.isFile() && entry.name === 'index.html')) {
|
|
30
|
+
return found('plain', '.', null, 'Found index.html at the top of this folder; it is the prototype.');
|
|
31
|
+
}
|
|
32
|
+
const folders = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
35
|
+
if (await readText(path.join(projectDir, entry.name, 'index.html')) !== null) folders.push(entry.name);
|
|
36
|
+
}
|
|
37
|
+
if (folders.length === 1) return found('plain', folders[0], null, `Found index.html in ${folders[0]}/; it is the prototype.`);
|
|
38
|
+
if (folders.length > 1) return unknown(`Found index.html in ${folders.length} folders (${folders.join(', ')}), so there is no single prototype folder.`);
|
|
39
|
+
return unknown(wrapped ? 'Found a package.json with no framework and no build, and no index.html here.' : 'Found no package.json and no index.html here.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Astro's own server adapters, named in the message when the project depends on one.
|
|
43
|
+
const astroAdapters = ['@astrojs/vercel', '@astrojs/node', '@astrojs/netlify', '@astrojs/cloudflare'];
|
|
44
|
+
|
|
45
|
+
export async function detectProject(projectDir) {
|
|
46
|
+
const packageText = await readText(path.join(projectDir, 'package.json'));
|
|
47
|
+
if (packageText === null) return detectPlain(projectDir);
|
|
48
|
+
|
|
49
|
+
let manifest;
|
|
50
|
+
try {
|
|
51
|
+
manifest = JSON.parse(packageText);
|
|
52
|
+
} catch {
|
|
53
|
+
return unknown('package.json is not valid JSON, so the framework could not be read.');
|
|
54
|
+
}
|
|
55
|
+
const dependencies = { ...manifest?.dependencies, ...manifest?.devDependencies };
|
|
56
|
+
const has = name => name in dependencies;
|
|
57
|
+
|
|
58
|
+
if (has('next')) {
|
|
59
|
+
const config = await readFirst(projectDir, ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts', 'next.config.mts']);
|
|
60
|
+
if (config !== null && /\boutput\s*:\s*['"`]export['"`]/.test(config)) {
|
|
61
|
+
return found('next-static', 'out', 'npm run build', "Found next with output: 'export'; it builds to out/.");
|
|
62
|
+
}
|
|
63
|
+
return found('next-server', null, null, "Found next without output: 'export' in next.config, so it needs a server and has no static build to save.");
|
|
64
|
+
}
|
|
65
|
+
if (has('@sveltejs/kit')) {
|
|
66
|
+
// Every other adapter (adapter-auto, -node, -vercel, -netlify) builds for a server, so there is nothing to save.
|
|
67
|
+
if (!has('@sveltejs/adapter-static')) {
|
|
68
|
+
return found('sveltekit-server', null, null, 'Found SvelteKit without @sveltejs/adapter-static, so it has no static build to save.');
|
|
69
|
+
}
|
|
70
|
+
const config = await readFirst(projectDir, ['svelte.config.js', 'svelte.config.mjs', 'svelte.config.cjs', 'svelte.config.ts']);
|
|
71
|
+
const pages = config?.match(/\bpages\s*:\s*['"`]([^'"`]+)['"`]/)?.[1] ?? 'build';
|
|
72
|
+
return found('sveltekit-static', pages, 'npm run build', `Found SvelteKit with adapter-static; it builds to ${pages}/.`);
|
|
73
|
+
}
|
|
74
|
+
if (has('astro')) {
|
|
75
|
+
// An adapter (@astrojs/vercel, -node, -netlify, -cloudflare, or any other in the config) builds for a server: unless
|
|
76
|
+
// the config says output: 'static', there is no dist/ of plain pages to save. With output: 'static' the pages are in
|
|
77
|
+
// dist/ and can be saved. That is all it settles for @astrojs/vercel: it still writes .vercel/output on every build,
|
|
78
|
+
// which Vercel deploys in place of the Afterimage site (init says so).
|
|
79
|
+
const config = (await readFirst(projectDir, ['astro.config.mjs', 'astro.config.js', 'astro.config.cjs', 'astro.config.ts', 'astro.config.mts'])) ?? '';
|
|
80
|
+
const named = astroAdapters.find(has);
|
|
81
|
+
const inConfig = /\badapter\s*:/.test(config);
|
|
82
|
+
const serverOutput = /\boutput\s*:\s*['"`]server['"`]/.test(config);
|
|
83
|
+
if ((named || inConfig || serverOutput) && !/\boutput\s*:\s*['"`]static['"`]/.test(config)) {
|
|
84
|
+
const reason = named ? `Found astro with ${named}, a server adapter, without output: 'static' in the astro config`
|
|
85
|
+
: inConfig ? "Found an adapter in the astro config, which builds for a server, without output: 'static'"
|
|
86
|
+
: "Found astro with output: 'server' in the astro config";
|
|
87
|
+
return found('astro-server', null, null, `${reason}, so it has no static build to save.`);
|
|
88
|
+
}
|
|
89
|
+
return found('astro', 'dist', 'npm run build', 'Found astro in package.json; it builds to dist/.');
|
|
90
|
+
}
|
|
91
|
+
if (has('vite')) return found('vite', 'dist', 'npm run build', 'Found vite in package.json; it builds to dist/.');
|
|
92
|
+
// A plain HTML folder gets a package.json from the setup prompt (npm init -y, then the install). One that holds
|
|
93
|
+
// nothing but afterimage-cli and has no build is still that folder, so it is read the way a folder without one is.
|
|
94
|
+
if (Object.keys(dependencies).every(name => name === 'afterimage-cli') && !manifest?.scripts?.build) return detectPlain(projectDir, true);
|
|
95
|
+
return unknown('Found a package.json, but no framework Afterimage recognises (vite, next, astro or SvelteKit).');
|
|
96
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Errors the user can act on (a bad name, a lock, a failed build) print as one message. These built-in types mean a
|
|
2
|
+
// bug in Afterimage itself, so they keep their stack for the bug report.
|
|
3
|
+
const programmerErrors = [TypeError, ReferenceError, SyntaxError, RangeError, EvalError];
|
|
4
|
+
|
|
5
|
+
export const isUserError = error => error instanceof Error && !programmerErrors.some(type => error instanceof type);
|