@archastro/astroshot 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,23 +1,63 @@
1
1
  # `@archastro/astroshot`
2
2
 
3
- One command for deterministic React, Ink, and arbitrary terminal screenshots.
3
+ One command for deterministic React, Ink, and arbitrary terminal screenshots
4
+ plus **journey movies** into `.astroshot/`.
5
+
6
+ ## Start here (no prerequisites)
7
+
8
+ ```bash
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
+
18
+ `demo` accepts `--feature <name>`, `--root <dir>`, and `--json`. `doctor`
19
+ accepts `--root <dir>`, `--json`, and `--skip-screen`, and exits non-zero when a
20
+ required check fails. Watched-folder coverage comes from the Astroshots app's
21
+ own live configuration, so an "empty tray" resolves to one of: first-launch
22
+ setup never completed, this project is outside every watched folder, or the
23
+ project is watched and the problem is elsewhere.
24
+
25
+ ## Capture
4
26
 
5
27
  ```bash
6
- npx --@archastro:registry=https://registry.npmjs.org \
7
- @archastro/astroshot init react
8
- npx --@archastro:registry=https://registry.npmjs.org \
9
- @archastro/astroshot init ink
10
- npx --@archastro:registry=https://registry.npmjs.org \
11
- @archastro/astroshot init pty
12
-
13
- npx --@archastro:registry=https://registry.npmjs.org \
14
- @archastro/astroshot react ./react.shot.tsx -o ./react.png
15
- npx --@archastro:registry=https://registry.npmjs.org \
16
- @archastro/astroshot ink ./ink.shot.tsx -o ./ink.png
17
- npx --@archastro:registry=https://registry.npmjs.org \
18
- @archastro/astroshot pty ./pty.shot.yaml -o ./terminal.png
28
+ npx astroshot init react
29
+ npx astroshot init ink
30
+ npx astroshot init pty
31
+
32
+ npx astroshot react ./react.shot.tsx -o ./react.png
33
+ npx astroshot ink ./ink.shot.tsx -o ./ink.png
34
+ npx astroshot pty ./pty.shot.yaml -o ./terminal.png
19
35
  ```
20
36
 
37
+ ## Movies (`astroshot movie`)
38
+
39
+ Same binary. Agents: **run `astroshot movie which-source "…"` first**.
40
+
41
+ | You need to record… | `--source` |
42
+ |---------------------|------------|
43
+ | Web / SPA / agent-browser | `browser` |
44
+ | TUI / CLI / truecolor terminal | `pty` (never desktop of Terminal.app) |
45
+ | Native macOS app window | `desktop.window` |
46
+ | Your own PNG sequence | `frames` |
47
+
48
+ ```bash
49
+ astroshot movie --help
50
+ astroshot movie which-source "ratatui truecolor dashboard"
51
+ astroshot movie run --source browser --feature web --slug home --url https://example.com
52
+ astroshot movie run --source desktop.window --feature app --slug onboard \
53
+ --bundle-id com.example.App --duration-ms 4000
54
+ astroshot movie list-windows # macOS
55
+ ```
56
+
57
+ `desktop.window` uses macOS `/usr/sbin/screencapture` (already on the system)
58
+ plus a Swift window list shipped in the package — no separate capture binary
59
+ download. Requires Screen Recording permission for your terminal/IDE.
60
+
21
61
  Use `react` for isolated browser components, `ink` for in-process Ink fixture
22
62
  trees, and `pty` for executable terminal applications such as Ratatui, Bubble
23
63
  Tea, Textual, and curses programs. `tui` remains an alias for `ink` for
@@ -32,8 +72,7 @@ npm install --save-dev ink@^7.1 react@^19
32
72
  Install the shared Chromium runtime once:
33
73
 
34
74
  ```bash
35
- npx --@archastro:registry=https://registry.npmjs.org \
36
- @archastro/astroshot install-browser
75
+ npx astroshot install-browser
37
76
  ```
38
77
 
39
78
  Use `react batch <manifest>` or `ink batch <manifest>` for maintained fixture
package/bin/astroshot.mjs CHANGED
@@ -6,11 +6,15 @@ 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";
9
11
 
10
12
  function help() {
11
- console.log(`astroshot — one CLI for React, Ink, and PTY screenshots
13
+ console.log(`astroshot — one CLI for React, Ink, PTY stills, and movies
12
14
 
13
15
  Usage:
16
+ astroshot demo [--feature <name>] [--root <dir>]
17
+ astroshot doctor [--root <dir>] [--json]
14
18
  astroshot init react [fixture.tsx] [--force]
15
19
  astroshot init ink [fixture.tsx] [--force]
16
20
  astroshot init pty [fixture.yaml] [--force]
@@ -20,15 +24,27 @@ Usage:
20
24
  astroshot ink <fixture.tsx> -o <out.png> [options]
21
25
  astroshot ink batch <manifest.yaml|json> [options]
22
26
  astroshot pty <fixture.yaml|json> -o <out.png> [options]
27
+ astroshot movie <command> [options]
23
28
  astroshot install-browser [--with-deps]
24
29
 
30
+ Start here:
31
+ demo Write a complete .astroshot/ example set (no prerequisites)
32
+ doctor Check Node, watched folders, app, Chromium, permissions
33
+
25
34
  Commands:
26
- react Capture an isolated React component
27
- ink Capture an Ink component fixture
28
- pty Capture any executable in a pseudoterminal
35
+ react Capture an isolated React component (still PNG)
36
+ ink Capture an Ink component fixture (still PNG)
37
+ pty Capture any executable in a pseudoterminal (still PNG)
38
+ movie Record a journey movie into .astroshot/ (poster + video)
29
39
  init Generate a React, Ink, or PTY fixture template
30
40
  install-browser Install the shared Chromium runtime
31
41
 
42
+ Movie sources (see "astroshot movie which-source"):
43
+ browser Web / agent-browser / Playwright viewport
44
+ pty Truecolor TUI/CLI (never screenshot Terminal.app)
45
+ desktop.window Native macOS app window (uses OS screencapture)
46
+ frames Push your own PNG/JPEG sequence
47
+
32
48
  Compatibility: "astroshot tui" remains an alias for "astroshot ink".
33
49
  Run "astroshot <mode> --help" for mode options.`);
34
50
  }
@@ -88,6 +104,12 @@ Options:
88
104
  }
89
105
 
90
106
  function engineBin(mode) {
107
+ if (mode === "movie") {
108
+ const entry = fileURLToPath(import.meta.resolve("@archastro/movie-harness"));
109
+ // package exports "." → dist/index.js → package root is two levels up from dist
110
+ const packageRoot = path.dirname(path.dirname(entry));
111
+ return path.join(packageRoot, "bin", "astroshot-movie.mjs");
112
+ }
91
113
  const packageName =
92
114
  mode === "react" ? "@archastro/react-shot" : "@archastro/tui-shot";
93
115
  const executable = mode === "react" ? "react-shot.mjs" : "tui-shot.mjs";
@@ -182,6 +204,28 @@ if (!command || command === "help" || command === "-h" || command === "--help")
182
204
  process.exit(command ? 0 : 1);
183
205
  }
184
206
 
207
+ if (command === "demo") {
208
+ try {
209
+ process.exit(runDemo(arguments_));
210
+ } catch (error) {
211
+ console.error(error instanceof Error ? error.message : error);
212
+ console.error("");
213
+ console.error(demoHelp());
214
+ process.exit(1);
215
+ }
216
+ }
217
+
218
+ if (command === "doctor") {
219
+ try {
220
+ process.exit(runDoctor(arguments_));
221
+ } catch (error) {
222
+ console.error(error instanceof Error ? error.message : error);
223
+ console.error("");
224
+ console.error(doctorHelp());
225
+ process.exit(1);
226
+ }
227
+ }
228
+
185
229
  if (command === "install-browser") {
186
230
  runEngine("react", ["install-browser", ...arguments_]);
187
231
  }
@@ -210,6 +254,23 @@ if (command === "react" || command === "ink" || command === "tui" || command ===
210
254
  runEngine(canonicalMode, arguments_);
211
255
  }
212
256
 
257
+ if (command === "movie") {
258
+ // Always forward to movie-harness (including --help / which-source).
259
+ const result = spawnSync(
260
+ process.execPath,
261
+ [engineBin("movie"), ...arguments_],
262
+ { stdio: "inherit" },
263
+ );
264
+ if (result.error) {
265
+ console.error(
266
+ `astroshot could not start movie harness: ${result.error.message}`,
267
+ );
268
+ process.exit(1);
269
+ }
270
+ if (result.signal) process.kill(process.pid, result.signal);
271
+ process.exit(result.status ?? 1);
272
+ }
273
+
213
274
  console.error(`Unknown command: ${command}`);
214
275
  help();
215
276
  process.exit(1);
package/bin/demo.mjs ADDED
@@ -0,0 +1,275 @@
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
+ -h, --help Show this help
34
+
35
+ Writes two stills, one movie poster + video pair, and manifest.json using
36
+ bundled fixtures. No Chromium download, no ffmpeg, no assets of your own.
37
+ Re-running starts a fresh run and appends new numbered frames.
38
+
39
+ After it runs, open the Astroshots menu-bar icon → Shots. If nothing appears,
40
+ run "astroshot doctor".`;
41
+ }
42
+
43
+ function resolveRoot(explicitRoot) {
44
+ if (explicitRoot) return path.resolve(explicitRoot);
45
+ try {
46
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "ignore"],
49
+ }).trim();
50
+ } catch {
51
+ return process.cwd();
52
+ }
53
+ }
54
+
55
+ function assertKebabCase(value, label) {
56
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(value)) {
57
+ throw new Error(
58
+ `${label} must be kebab-case [a-z0-9-]+, got ${JSON.stringify(value)}`,
59
+ );
60
+ }
61
+ }
62
+
63
+ function nextSequence(featureDirectory) {
64
+ let max = 0;
65
+ const entries = fs.existsSync(featureDirectory)
66
+ ? fs.readdirSync(featureDirectory)
67
+ : [];
68
+ for (const name of entries) {
69
+ const match = /^(\d{4})-/.exec(name);
70
+ if (match) max = Math.max(max, Number(match[1]));
71
+ }
72
+ return max + 1;
73
+ }
74
+
75
+ function writeAtomic(filePath, contents) {
76
+ const temporary = path.join(
77
+ path.dirname(filePath),
78
+ `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`,
79
+ );
80
+ fs.writeFileSync(temporary, contents);
81
+ fs.renameSync(temporary, filePath);
82
+ }
83
+
84
+ function copyAtomic(source, destination) {
85
+ const temporary = path.join(
86
+ path.dirname(destination),
87
+ `.${path.basename(destination)}.tmp.${process.pid}.${Date.now()}`,
88
+ );
89
+ fs.copyFileSync(source, temporary);
90
+ fs.renameSync(temporary, destination);
91
+ }
92
+
93
+ export function loadDemoFixtures(fixturesDirectory = FIXTURES_DIR) {
94
+ const indexPath = path.join(fixturesDirectory, "fixtures.json");
95
+ if (!fs.existsSync(indexPath)) {
96
+ throw new Error(
97
+ `bundled demo fixtures are missing at ${fixturesDirectory}. Reinstall @archastro/astroshot.`,
98
+ );
99
+ }
100
+ const index = JSON.parse(fs.readFileSync(indexPath, "utf8"));
101
+ if (!Array.isArray(index.shots) || index.shots.length === 0) {
102
+ throw new Error(`bundled demo fixtures are empty: ${indexPath}`);
103
+ }
104
+ for (const shot of index.shots) {
105
+ for (const asset of [shot.asset, shot.video].filter(Boolean)) {
106
+ const assetPath = path.join(fixturesDirectory, asset);
107
+ if (!fs.existsSync(assetPath)) {
108
+ throw new Error(`bundled demo asset is missing: ${assetPath}`);
109
+ }
110
+ }
111
+ }
112
+ return index;
113
+ }
114
+
115
+ /**
116
+ * Write the demo set and return the paths, without printing anything.
117
+ *
118
+ * Each invocation is its own run: a new `run_id` with a fresh shot list, while
119
+ * earlier numbered frames stay on disk as prior-run evidence. That matches the
120
+ * documented lifecycle used by astroshot-capture and the movie harness.
121
+ */
122
+ export function writeDemo({
123
+ root,
124
+ feature = DEFAULT_DEMO_FEATURE,
125
+ fixturesDirectory = FIXTURES_DIR,
126
+ now = new Date(),
127
+ } = {}) {
128
+ assertKebabCase(feature, "feature");
129
+ const resolvedRoot = resolveRoot(root);
130
+ const featureDirectory = path.join(resolvedRoot, ".astroshot", feature);
131
+ const index = loadDemoFixtures(fixturesDirectory);
132
+
133
+ fs.mkdirSync(featureDirectory, { recursive: true });
134
+ const startSequence = nextSequence(featureDirectory);
135
+ const stamp = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
136
+ // The first sequence of this run keeps the id unique when two runs land in
137
+ // the same second from the same process.
138
+ const runId = `${feature}-${stamp}-${process.pid}-${String(startSequence).padStart(4, "0")}`;
139
+ const capturedAt = now.toISOString();
140
+
141
+ const shots = [];
142
+ const files = [];
143
+ index.shots.forEach((fixture, offset) => {
144
+ const sequence = String(startSequence + offset).padStart(4, "0");
145
+ const posterName = `${sequence}-${fixture.slug}.png`;
146
+ copyAtomic(path.join(fixturesDirectory, fixture.asset), path.join(featureDirectory, posterName));
147
+ files.push(path.join(featureDirectory, posterName));
148
+
149
+ const shot = {
150
+ id: sequence,
151
+ file: posterName,
152
+ slug: fixture.slug,
153
+ title: fixture.title,
154
+ description: fixture.description,
155
+ captured_at: capturedAt,
156
+ viewport: index.viewport,
157
+ };
158
+
159
+ if (fixture.video) {
160
+ const videoExtension = path.extname(fixture.video) || ".webm";
161
+ const videoName = `${sequence}-${fixture.slug}${videoExtension}`;
162
+ copyAtomic(path.join(fixturesDirectory, fixture.video), path.join(featureDirectory, videoName));
163
+ files.push(path.join(featureDirectory, videoName));
164
+ shot.kind = "movie";
165
+ shot.video = videoName;
166
+ shot.duration_ms = fixture.duration_ms;
167
+ shot.source = fixture.source ?? "frames";
168
+ if (Array.isArray(fixture.chapters)) shot.chapters = fixture.chapters;
169
+ }
170
+
171
+ shots.push(shot);
172
+ });
173
+
174
+ const manifestPath = path.join(featureDirectory, "manifest.json");
175
+ const manifest = {
176
+ version: 1,
177
+ feature,
178
+ run_id: runId,
179
+ status: "pass",
180
+ description:
181
+ "Synthetic proof set written by `astroshot demo` — stills plus one journey movie.",
182
+ shots,
183
+ };
184
+ writeAtomic(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
185
+ files.push(manifestPath);
186
+
187
+ return {
188
+ root: resolvedRoot,
189
+ feature,
190
+ featureDirectory,
191
+ manifestPath,
192
+ runId,
193
+ shots,
194
+ files,
195
+ };
196
+ }
197
+
198
+ function coverageAdvice(root) {
199
+ const configuration = readWatchConfiguration();
200
+ const coverage = evaluateWatchCoverage(root, configuration);
201
+ switch (coverage.state) {
202
+ case "inside-root":
203
+ return [
204
+ `Watched by Astroshots via ${coverage.matchedRoot}.`,
205
+ "Open the Astroshots menu-bar icon → Shots to see these frames.",
206
+ ];
207
+ case "setup-incomplete":
208
+ return [
209
+ "Astroshots has no watched folders yet, so it will not pick these up.",
210
+ "Fix: open Astroshots and choose a folder, or menu-bar icon → gear → Add folders…",
211
+ "Then re-check with: astroshot doctor",
212
+ ];
213
+ case "outside-roots":
214
+ return [
215
+ `This project is outside every watched folder (${coverage.roots.join(", ")}).`,
216
+ "Fix: Astroshots menu-bar icon → gear → Add folders… and add a parent of this project.",
217
+ "Then re-check with: astroshot doctor",
218
+ ];
219
+ case "unsupported":
220
+ return [
221
+ "The Astroshots app is macOS-only; the files above still follow the .astroshot contract.",
222
+ ];
223
+ default:
224
+ // Unknown — do not claim the user has no watched folders.
225
+ return [
226
+ "Could not read Astroshots' watched folders, so whether this project is",
227
+ "watched is unknown (the files above were still written correctly).",
228
+ "Check with: astroshot doctor",
229
+ ];
230
+ }
231
+ }
232
+
233
+ export function runDemo(argv, { log = console.log } = {}) {
234
+ const options = { feature: DEFAULT_DEMO_FEATURE, root: undefined, json: false };
235
+ for (let index = 0; index < argv.length; index += 1) {
236
+ const token = argv[index];
237
+ if (token === "-h" || token === "--help" || token === "help") {
238
+ log(demoHelp());
239
+ return 0;
240
+ }
241
+ if (token === "--json") {
242
+ options.json = true;
243
+ continue;
244
+ }
245
+ if (token === "--feature" || token === "--root") {
246
+ const value = argv[index + 1];
247
+ if (!value || value.startsWith("-")) {
248
+ throw new Error(`${token} requires a value`);
249
+ }
250
+ options[token === "--feature" ? "feature" : "root"] = value;
251
+ index += 1;
252
+ continue;
253
+ }
254
+ throw new Error(`Unknown demo argument: ${token}`);
255
+ }
256
+
257
+ const result = writeDemo(options);
258
+ if (options.json) {
259
+ log(JSON.stringify({ ...result, advice: coverageAdvice(result.root) }, null, 2));
260
+ return 0;
261
+ }
262
+
263
+ const movies = result.shots.filter((shot) => shot.kind === "movie").length;
264
+ const stills = result.shots.length - movies;
265
+ log(`astroshot demo → ${result.featureDirectory}`);
266
+ for (const file of result.files) {
267
+ log(` ${path.relative(result.root, file)}`);
268
+ }
269
+ log("");
270
+ log(
271
+ `Wrote ${stills} still${stills === 1 ? "" : "s"}, ${movies} movie${movies === 1 ? "" : "s"} (poster + video), and manifest.json.`,
272
+ );
273
+ for (const line of coverageAdvice(result.root)) log(line);
274
+ return 0;
275
+ }