@archastro/astroshot 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -3,20 +3,39 @@
3
3
  One command for deterministic React, Ink, and arbitrary terminal screenshots —
4
4
  plus **journey movies** into `.astroshot/`.
5
5
 
6
+ ## Start here (no prerequisites)
7
+
6
8
  ```bash
7
- npx --@archastro:registry=https://registry.npmjs.org \
8
- @archastro/astroshot init react
9
- npx --@archastro:registry=https://registry.npmjs.org \
10
- @archastro/astroshot init ink
11
- npx --@archastro:registry=https://registry.npmjs.org \
12
- @archastro/astroshot init pty
13
-
14
- npx --@archastro:registry=https://registry.npmjs.org \
15
- @archastro/astroshot react ./react.shot.tsx -o ./react.png
16
- npx --@archastro:registry=https://registry.npmjs.org \
17
- @archastro/astroshot ink ./ink.shot.tsx -o ./ink.png
18
- npx --@archastro:registry=https://registry.npmjs.org \
19
- @archastro/astroshot pty ./pty.shot.yaml -o ./terminal.png
9
+ # Write a real .astroshot/ set: stills + movie poster/video + manifest.json.
10
+ # No Chromium download, no assets of your own; works with the app closed.
11
+ npx astroshot demo
12
+
13
+ # Check Node, watched folders, app install/run state, Chromium, and macOS
14
+ # Screen Recording. Each failure prints the exact fix command. Read-only.
15
+ npx astroshot doctor
16
+
17
+ # Review the stream in your terminal (Ghostty, kitty, or WezTerm draw the
18
+ # pictures; ffmpeg plays movies). Same review.json as the macOS app.
19
+ npx astroshot review
20
+ ```
21
+
22
+ `demo` accepts `--feature <name>`, `--root <dir>`, and `--json`. `doctor`
23
+ accepts `--root <dir>`, `--json`, and `--skip-screen`, and exits non-zero when a
24
+ required check fails. Watched-folder coverage comes from the Astroshots app's
25
+ own live configuration, so an "empty tray" resolves to one of: first-launch
26
+ setup never completed, this project is outside every watched folder, or the
27
+ project is watched and the problem is elsewhere.
28
+
29
+ ## Capture
30
+
31
+ ```bash
32
+ npx astroshot init react
33
+ npx astroshot init ink
34
+ npx astroshot init pty
35
+
36
+ npx astroshot react ./react.shot.tsx -o ./react.png
37
+ npx astroshot ink ./ink.shot.tsx -o ./ink.png
38
+ npx astroshot pty ./pty.shot.yaml -o ./terminal.png
20
39
  ```
21
40
 
22
41
  ## Movies (`astroshot movie`)
@@ -57,8 +76,7 @@ npm install --save-dev ink@^7.1 react@^19
57
76
  Install the shared Chromium runtime once:
58
77
 
59
78
  ```bash
60
- npx --@archastro:registry=https://registry.npmjs.org \
61
- @archastro/astroshot install-browser
79
+ npx astroshot install-browser
62
80
  ```
63
81
 
64
82
  Use `react batch <manifest>` or `ink batch <manifest>` for maintained fixture
package/bin/astroshot.mjs CHANGED
@@ -6,11 +6,17 @@ import { fileURLToPath } from "node:url";
6
6
  import path from "node:path";
7
7
 
8
8
  import { writeFixtureTemplate } from "./templates.mjs";
9
+ import { demoHelp, runDemo } from "./demo.mjs";
10
+ import { doctorHelp, runDoctor } from "./doctor.mjs";
11
+ import { readWatchConfiguration } from "./mac-preferences.mjs";
9
12
 
10
13
  function help() {
11
14
  console.log(`astroshot — one CLI for React, Ink, PTY stills, and movies
12
15
 
13
16
  Usage:
17
+ astroshot review [<dir>...] [--root <dir>] [--no-graphics]
18
+ astroshot demo [--feature <name>] [--root <dir>] [--dry-run] [--clean]
19
+ astroshot doctor [--root <dir>] [--json]
14
20
  astroshot init react [fixture.tsx] [--force]
15
21
  astroshot init ink [fixture.tsx] [--force]
16
22
  astroshot init pty [fixture.yaml] [--force]
@@ -23,6 +29,11 @@ Usage:
23
29
  astroshot movie <command> [options]
24
30
  astroshot install-browser [--with-deps]
25
31
 
32
+ Start here:
33
+ review Review the .astroshot/ stream in your terminal (Kitty graphics)
34
+ demo Write a complete .astroshot/ example set (no prerequisites)
35
+ doctor Check Node, watched folders, app, Chromium, permissions
36
+
26
37
  Commands:
27
38
  react Capture an isolated React component (still PNG)
28
39
  ink Capture an Ink component fixture (still PNG)
@@ -96,6 +107,11 @@ Options:
96
107
  }
97
108
 
98
109
  function engineBin(mode) {
110
+ if (mode === "review") {
111
+ const entry = fileURLToPath(import.meta.resolve("@archastro/astroshot-review"));
112
+ const packageRoot = path.dirname(path.dirname(entry));
113
+ return path.join(packageRoot, "bin", "astroshot-review.mjs");
114
+ }
99
115
  if (mode === "movie") {
100
116
  const entry = fileURLToPath(import.meta.resolve("@archastro/movie-harness"));
101
117
  // package exports "." → dist/index.js → package root is two levels up from dist
@@ -181,6 +197,49 @@ function runInit(arguments_) {
181
197
  console.log(`Created ${result.label} fixture: ${result.absolutePath}`);
182
198
  }
183
199
 
200
+ /**
201
+ * `astroshot review`: the terminal tray. Without explicit roots it watches
202
+ * the same folders as the Astroshots app so both surfaces show one stream.
203
+ */
204
+ function runReview(arguments_) {
205
+ // `astroshot review help` mirrors the other modes; anything else is
206
+ // forwarded verbatim so a folder literally named "help" still works.
207
+ const wantsHelp =
208
+ arguments_[0] === "help" ||
209
+ arguments_.includes("-h") ||
210
+ arguments_.includes("--help");
211
+ const forwarded = wantsHelp ? ["--help"] : [...arguments_];
212
+ const hasRoots =
213
+ forwarded.includes("--root") ||
214
+ forwarded.some((value) => !value.startsWith("-"));
215
+ if (!hasRoots && !wantsHelp) {
216
+ const configuration = readWatchConfiguration();
217
+ if (configuration.available && configuration.roots.length > 0) {
218
+ for (const root of configuration.roots) forwarded.push("--root", root);
219
+ forwarded.push("--roots-source", "app");
220
+ }
221
+ }
222
+ let engine;
223
+ try {
224
+ engine = engineBin("review");
225
+ } catch (error) {
226
+ console.error(
227
+ `astroshot could not find the review tray engine (@archastro/astroshot-review): ${error instanceof Error ? error.message : error}`,
228
+ );
229
+ console.error("Reinstall @archastro/astroshot (or the unscoped astroshot package) and retry.");
230
+ process.exit(1);
231
+ }
232
+ const result = spawnSync(process.execPath, [engine, ...forwarded], {
233
+ stdio: "inherit",
234
+ });
235
+ if (result.error) {
236
+ console.error(`astroshot could not start the review tray: ${result.error.message}`);
237
+ process.exit(1);
238
+ }
239
+ if (result.signal) process.kill(process.pid, result.signal);
240
+ process.exit(result.status ?? 1);
241
+ }
242
+
184
243
  const [command, ...arguments_] = process.argv.slice(2);
185
244
 
186
245
  if (command === "-v" || command === "--version") {
@@ -196,6 +255,28 @@ if (!command || command === "help" || command === "-h" || command === "--help")
196
255
  process.exit(command ? 0 : 1);
197
256
  }
198
257
 
258
+ if (command === "demo") {
259
+ try {
260
+ process.exit(runDemo(arguments_));
261
+ } catch (error) {
262
+ console.error(error instanceof Error ? error.message : error);
263
+ console.error("");
264
+ console.error(demoHelp());
265
+ process.exit(1);
266
+ }
267
+ }
268
+
269
+ if (command === "doctor") {
270
+ try {
271
+ process.exit(runDoctor(arguments_));
272
+ } catch (error) {
273
+ console.error(error instanceof Error ? error.message : error);
274
+ console.error("");
275
+ console.error(doctorHelp());
276
+ process.exit(1);
277
+ }
278
+ }
279
+
199
280
  if (command === "install-browser") {
200
281
  runEngine("react", ["install-browser", ...arguments_]);
201
282
  }
@@ -224,6 +305,10 @@ if (command === "react" || command === "ink" || command === "tui" || command ===
224
305
  runEngine(canonicalMode, arguments_);
225
306
  }
226
307
 
308
+ if (command === "review" || command === "tray") {
309
+ runReview(arguments_);
310
+ }
311
+
227
312
  if (command === "movie") {
228
313
  // Always forward to movie-harness (including --help / which-source).
229
314
  const result = spawnSync(
package/bin/demo.mjs ADDED
@@ -0,0 +1,331 @@
1
+ /**
2
+ * `astroshot demo` — seed a real .astroshot/<feature>/ set with zero
3
+ * prerequisites.
4
+ *
5
+ * The payload is bundled PNG/WebM bytes under fixtures/demo/, so this command
6
+ * needs no managed Chromium, no ffmpeg, and no user-supplied assets. It only
7
+ * writes files, so it also works while the macOS app is closed; the caller is
8
+ * told where to look.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { execFileSync } from "node:child_process";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import {
16
+ evaluateWatchCoverage,
17
+ readWatchConfiguration,
18
+ } from "./mac-preferences.mjs";
19
+
20
+ const FIXTURES_DIR = fileURLToPath(new URL("../fixtures/demo/", import.meta.url));
21
+ export const DEFAULT_DEMO_FEATURE = "astroshot-demo";
22
+
23
+ export function demoHelp() {
24
+ return `astroshot demo — write a complete .astroshot/ example set (no prerequisites)
25
+
26
+ Usage:
27
+ astroshot demo [options]
28
+
29
+ Options:
30
+ --feature <name> Feature directory under .astroshot/ (default: ${DEFAULT_DEMO_FEATURE})
31
+ --root <dir> Worktree root (default: git root, else cwd)
32
+ --json Print the written paths as JSON
33
+ --dry-run Print planned paths without writing
34
+ --clean Accepted; does not delete yet
35
+ -h, --help Show this help
36
+
37
+ Writes two stills, one movie poster + video pair, and manifest.json using
38
+ bundled fixtures. No Chromium download, no ffmpeg, no assets of your own.
39
+ Re-running starts a fresh run and appends new numbered frames.
40
+
41
+ After it runs, open the Astroshots menu-bar icon → Shots. If nothing appears,
42
+ run "astroshot doctor".`;
43
+ }
44
+
45
+ function resolveRoot(explicitRoot) {
46
+ if (explicitRoot) return path.resolve(explicitRoot);
47
+ try {
48
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
49
+ encoding: "utf8",
50
+ stdio: ["ignore", "pipe", "ignore"],
51
+ }).trim();
52
+ } catch {
53
+ return process.cwd();
54
+ }
55
+ }
56
+
57
+ function assertKebabCase(value, label) {
58
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
59
+ throw new Error(
60
+ `${label} must be kebab-case [a-z0-9-]+, got ${JSON.stringify(value)}`,
61
+ );
62
+ }
63
+ }
64
+
65
+ function nextSequence(featureDirectory) {
66
+ let max = 0;
67
+ const entries = fs.existsSync(featureDirectory)
68
+ ? fs.readdirSync(featureDirectory)
69
+ : [];
70
+ for (const name of entries) {
71
+ const match = /^(\d{4})-/.exec(name);
72
+ if (match) max = Math.max(max, Number(match[1]));
73
+ }
74
+ return max + 1;
75
+ }
76
+
77
+ function writeAtomic(filePath, contents) {
78
+ const temporary = path.join(
79
+ path.dirname(filePath),
80
+ `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`,
81
+ );
82
+ fs.writeFileSync(temporary, contents);
83
+ fs.renameSync(temporary, filePath);
84
+ }
85
+
86
+ function copyAtomic(source, destination) {
87
+ const temporary = path.join(
88
+ path.dirname(destination),
89
+ `.${path.basename(destination)}.tmp.${process.pid}.${Date.now()}`,
90
+ );
91
+ fs.copyFileSync(source, temporary);
92
+ fs.renameSync(temporary, destination);
93
+ }
94
+
95
+ export function loadDemoFixtures(fixturesDirectory = FIXTURES_DIR) {
96
+ const indexPath = path.join(fixturesDirectory, "fixtures.json");
97
+ if (!fs.existsSync(indexPath)) {
98
+ throw new Error(
99
+ `bundled demo fixtures are missing at ${fixturesDirectory}. Reinstall @archastro/astroshot.`,
100
+ );
101
+ }
102
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
103
+ if (!Array.isArray(index.shots) || index.shots.length === 0) {
104
+ throw new Error(`bundled demo fixtures are empty: ${indexPath}`);
105
+ }
106
+ for (const shot of index.shots) {
107
+ for (const asset of [shot.asset, shot.video].filter(Boolean)) {
108
+ const assetPath = path.join(fixturesDirectory, asset);
109
+ if (!fs.existsSync(assetPath)) {
110
+ throw new Error(`bundled demo asset is missing: ${assetPath}`);
111
+ }
112
+ }
113
+ }
114
+ return index;
115
+ }
116
+
117
+ function publicDemoResult(plan) {
118
+ return {
119
+ root: plan.root,
120
+ feature: plan.feature,
121
+ featureDirectory: plan.featureDirectory,
122
+ manifestPath: plan.manifestPath,
123
+ runId: plan.runId,
124
+ shots: plan.shots,
125
+ files: plan.files,
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Read-only plan of the next demo write: same root/feature resolve, kebab-case
131
+ * check, fixtures, next sequence, and file names (including manifest.json).
132
+ * Does not mkdir or write.
133
+ */
134
+ export function planDemo({
135
+ root,
136
+ feature = DEFAULT_DEMO_FEATURE,
137
+ fixturesDirectory = FIXTURES_DIR,
138
+ now = new Date(),
139
+ } = {}) {
140
+ assertKebabCase(feature, "feature");
141
+ const resolvedRoot = resolveRoot(root);
142
+ const featureDirectory = path.join(resolvedRoot, ".astroshot", feature);
143
+ const index = loadDemoFixtures(fixturesDirectory);
144
+
145
+ const startSequence = nextSequence(featureDirectory);
146
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
147
+ // The first sequence of this run keeps the id unique when two runs land in
148
+ // the same second from the same process.
149
+ const runId = `${feature}-${stamp}-${process.pid}-${String(startSequence).padStart(4, "0")}`;
150
+ const capturedAt = now.toISOString();
151
+
152
+ const shots = [];
153
+ const files = [];
154
+ const copies = [];
155
+ index.shots.forEach((fixture, offset) => {
156
+ const sequence = String(startSequence + offset).padStart(4, "0");
157
+ const posterName = `${sequence}-${fixture.slug}.png`;
158
+ const posterPath = path.join(featureDirectory, posterName);
159
+ copies.push({
160
+ source: path.join(fixturesDirectory, fixture.asset),
161
+ destination: posterPath,
162
+ });
163
+ files.push(posterPath);
164
+
165
+ const shot = {
166
+ id: sequence,
167
+ file: posterName,
168
+ slug: fixture.slug,
169
+ title: fixture.title,
170
+ description: fixture.description,
171
+ captured_at: capturedAt,
172
+ viewport: index.viewport,
173
+ };
174
+
175
+ if (fixture.video) {
176
+ const videoExtension = path.extname(fixture.video) || ".webm";
177
+ const videoName = `${sequence}-${fixture.slug}${videoExtension}`;
178
+ const videoPath = path.join(featureDirectory, videoName);
179
+ copies.push({
180
+ source: path.join(fixturesDirectory, fixture.video),
181
+ destination: videoPath,
182
+ });
183
+ files.push(videoPath);
184
+ shot.kind = "movie";
185
+ shot.video = videoName;
186
+ shot.duration_ms = fixture.duration_ms;
187
+ shot.source = fixture.source ?? "frames";
188
+ if (Array.isArray(fixture.chapters)) shot.chapters = fixture.chapters;
189
+ }
190
+
191
+ shots.push(shot);
192
+ });
193
+
194
+ const manifestPath = path.join(featureDirectory, "manifest.json");
195
+ const manifest = {
196
+ version: 1,
197
+ feature,
198
+ run_id: runId,
199
+ status: "pass",
200
+ description:
201
+ "Synthetic proof set written by `astroshot demo` — stills plus one journey movie.",
202
+ shots,
203
+ };
204
+ files.push(manifestPath);
205
+
206
+ return {
207
+ root: resolvedRoot,
208
+ feature,
209
+ featureDirectory,
210
+ manifestPath,
211
+ manifest,
212
+ runId,
213
+ shots,
214
+ files,
215
+ copies,
216
+ };
217
+ }
218
+
219
+ /**
220
+ * Write the demo set and return the paths, without printing anything.
221
+ *
222
+ * Each invocation is its own run: a new `run_id` with a fresh shot list, while
223
+ * earlier numbered frames stay on disk as prior-run evidence. That matches the
224
+ * documented lifecycle used by astroshot-capture and the movie harness.
225
+ */
226
+ export function writeDemo(options = {}) {
227
+ const plan = planDemo(options);
228
+ fs.mkdirSync(plan.featureDirectory, { recursive: true });
229
+ for (const { source, destination } of plan.copies) {
230
+ copyAtomic(source, destination);
231
+ }
232
+ writeAtomic(plan.manifestPath, `${JSON.stringify(plan.manifest, null, 2)}\n`);
233
+ return publicDemoResult(plan);
234
+ }
235
+
236
+ function coverageAdvice(root) {
237
+ const configuration = readWatchConfiguration();
238
+ const coverage = evaluateWatchCoverage(root, configuration);
239
+ switch (coverage.state) {
240
+ case "inside-root":
241
+ return [
242
+ `Watched by Astroshots via ${coverage.matchedRoot}.`,
243
+ "Open the Astroshots menu-bar icon → Shots to see these frames.",
244
+ ];
245
+ case "setup-incomplete":
246
+ return [
247
+ "Astroshots has no watched folders yet, so it will not pick these up.",
248
+ "Fix: open Astroshots and choose a folder, or menu-bar icon → gear → Add folders…",
249
+ "Then re-check with: astroshot doctor",
250
+ ];
251
+ case "outside-roots":
252
+ return [
253
+ `This project is outside every watched folder (${coverage.roots.join(", ")}).`,
254
+ "Fix: Astroshots menu-bar icon → gear → Add folders… and add a parent of this project.",
255
+ "Then re-check with: astroshot doctor",
256
+ ];
257
+ case "unsupported":
258
+ return [
259
+ "The Astroshots app is macOS-only; the files above still follow the .astroshot contract.",
260
+ ];
261
+ default:
262
+ // Unknown — do not claim the user has no watched folders.
263
+ return [
264
+ "Could not read Astroshots' watched folders, so whether this project is",
265
+ "watched is unknown (the files above were still written correctly).",
266
+ "Check with: astroshot doctor",
267
+ ];
268
+ }
269
+ }
270
+
271
+ export function runDemo(argv, { log = console.log } = {}) {
272
+ const options = {
273
+ feature: DEFAULT_DEMO_FEATURE,
274
+ root: undefined,
275
+ json: false,
276
+ dryRun: false,
277
+ clean: false,
278
+ };
279
+ for (let index = 0; index < argv.length; index += 1) {
280
+ const token = argv[index];
281
+ if (token === "-h" || token === "--help" || token === "help") {
282
+ log(demoHelp());
283
+ return 0;
284
+ }
285
+ if (token === "--json") {
286
+ options.json = true;
287
+ continue;
288
+ }
289
+ if (token === "--dry-run") {
290
+ options.dryRun = true;
291
+ continue;
292
+ }
293
+ if (token === "--clean") {
294
+ options.clean = true;
295
+ continue;
296
+ }
297
+ if (token === "--feature" || token === "--root") {
298
+ const value = argv[index + 1];
299
+ if (!value || value.startsWith("-")) {
300
+ throw new Error(`${token} requires a value`);
301
+ }
302
+ options[token === "--feature" ? "feature" : "root"] = value;
303
+ index += 1;
304
+ continue;
305
+ }
306
+ throw new Error(`Unknown demo argument: ${token}`);
307
+ }
308
+
309
+ const result = options.dryRun
310
+ ? publicDemoResult(planDemo(options))
311
+ : writeDemo(options);
312
+ if (options.json) {
313
+ log(JSON.stringify({ ...result, advice: coverageAdvice(result.root) }, null, 2));
314
+ return 0;
315
+ }
316
+
317
+ log(`astroshot demo → ${result.featureDirectory}`);
318
+ for (const file of result.files) {
319
+ log(` ${path.relative(result.root, file)}`);
320
+ }
321
+ if (options.dryRun) return 0;
322
+
323
+ const movies = result.shots.filter((shot) => shot.kind === "movie").length;
324
+ const stills = result.shots.length - movies;
325
+ log("");
326
+ log(
327
+ `Wrote ${stills} still${stills === 1 ? "" : "s"}, ${movies} movie${movies === 1 ? "" : "s"} (poster + video), and manifest.json.`,
328
+ );
329
+ for (const line of coverageAdvice(result.root)) log(line);
330
+ return 0;
331
+ }