@archastro/astroshot 0.2.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 +29 -15
- package/bin/astroshot.mjs +30 -0
- package/bin/demo.mjs +275 -0
- package/bin/doctor.mjs +492 -0
- package/bin/mac-preferences.mjs +346 -0
- package/fixtures/demo/fixtures.json +42 -0
- package/fixtures/demo/journey.png +0 -0
- package/fixtures/demo/journey.webm +0 -0
- package/fixtures/demo/next-steps.png +0 -0
- package/fixtures/demo/welcome.png +0 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -3,20 +3,35 @@
|
|
|
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
|
+
|
|
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
|
|
26
|
+
|
|
6
27
|
```bash
|
|
7
|
-
npx
|
|
8
|
-
|
|
9
|
-
npx
|
|
10
|
-
|
|
11
|
-
npx
|
|
12
|
-
|
|
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
|
|
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
|
|
20
35
|
```
|
|
21
36
|
|
|
22
37
|
## Movies (`astroshot movie`)
|
|
@@ -57,8 +72,7 @@ npm install --save-dev ink@^7.1 react@^19
|
|
|
57
72
|
Install the shared Chromium runtime once:
|
|
58
73
|
|
|
59
74
|
```bash
|
|
60
|
-
npx
|
|
61
|
-
@archastro/astroshot install-browser
|
|
75
|
+
npx astroshot install-browser
|
|
62
76
|
```
|
|
63
77
|
|
|
64
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
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]
|
|
@@ -23,6 +27,10 @@ Usage:
|
|
|
23
27
|
astroshot movie <command> [options]
|
|
24
28
|
astroshot install-browser [--with-deps]
|
|
25
29
|
|
|
30
|
+
Start here:
|
|
31
|
+
demo Write a complete .astroshot/ example set (no prerequisites)
|
|
32
|
+
doctor Check Node, watched folders, app, Chromium, permissions
|
|
33
|
+
|
|
26
34
|
Commands:
|
|
27
35
|
react Capture an isolated React component (still PNG)
|
|
28
36
|
ink Capture an Ink component fixture (still PNG)
|
|
@@ -196,6 +204,28 @@ if (!command || command === "help" || command === "-h" || command === "--help")
|
|
|
196
204
|
process.exit(command ? 0 : 1);
|
|
197
205
|
}
|
|
198
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
|
+
|
|
199
229
|
if (command === "install-browser") {
|
|
200
230
|
runEngine("react", ["install-browser", ...arguments_]);
|
|
201
231
|
}
|
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
|
+
}
|
package/bin/doctor.mjs
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `astroshot doctor` — report every way Astroshots setup silently fails.
|
|
3
|
+
*
|
|
4
|
+
* Read-only by contract: it never installs, launches, or writes preferences.
|
|
5
|
+
* Each check prints pass/fail plus the exact remediation command, and required
|
|
6
|
+
* failures make the process exit non-zero.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
import { loadDemoFixtures } from "./demo.mjs";
|
|
16
|
+
import {
|
|
17
|
+
ASTROSHOTS_DOMAIN,
|
|
18
|
+
evaluateWatchCoverage,
|
|
19
|
+
preferenceToolsAvailable,
|
|
20
|
+
readWatchConfiguration,
|
|
21
|
+
} from "./mac-preferences.mjs";
|
|
22
|
+
|
|
23
|
+
const APP_PROCESS_NAME = "Astroshots";
|
|
24
|
+
|
|
25
|
+
export function doctorHelp() {
|
|
26
|
+
return `astroshot doctor — diagnose Astroshots capture setup (read-only)
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
29
|
+
astroshot doctor [options]
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
--root <dir> Project directory to test for watch coverage (default: cwd)
|
|
33
|
+
--json Machine-readable report
|
|
34
|
+
--skip-screen Skip the macOS Screen Recording probe (avoids a Swift run)
|
|
35
|
+
-h, --help Show this help
|
|
36
|
+
|
|
37
|
+
Reports Node version, watched-folder coverage, whether the Astroshots app is
|
|
38
|
+
installed and running, the managed Chromium runtime, and macOS Screen Recording
|
|
39
|
+
state for desktop.window. Exits non-zero when a required check fails.
|
|
40
|
+
doctor never installs anything and never changes app state.`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readEngines() {
|
|
44
|
+
const packageJSON = JSON.parse(
|
|
45
|
+
fs.readFileSync(new URL("../package.json", import.meta.url), "utf8"),
|
|
46
|
+
);
|
|
47
|
+
return packageJSON.engines?.node ?? ">=22.14.0";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseVersion(value) {
|
|
51
|
+
const match = /(\d+)\.(\d+)\.(\d+)/.exec(value);
|
|
52
|
+
if (!match) return null;
|
|
53
|
+
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compareVersions(a, b) {
|
|
57
|
+
for (let index = 0; index < 3; index += 1) {
|
|
58
|
+
if (a[index] !== b[index]) return a[index] < b[index] ? -1 : 1;
|
|
59
|
+
}
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function checkNode(range) {
|
|
64
|
+
const minimum = parseVersion(range);
|
|
65
|
+
const current = parseVersion(process.versions.node);
|
|
66
|
+
if (!minimum || !current) {
|
|
67
|
+
return {
|
|
68
|
+
id: "node",
|
|
69
|
+
title: "Node.js version",
|
|
70
|
+
required: true,
|
|
71
|
+
status: "warn",
|
|
72
|
+
detail: `could not compare ${process.versions.node} with "${range}"`,
|
|
73
|
+
remediation: "Install Node.js 22.14.0 or newer: https://nodejs.org/en/download",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const ok = compareVersions(current, minimum) >= 0;
|
|
77
|
+
return {
|
|
78
|
+
id: "node",
|
|
79
|
+
title: "Node.js version",
|
|
80
|
+
required: true,
|
|
81
|
+
status: ok ? "pass" : "fail",
|
|
82
|
+
detail: ok
|
|
83
|
+
? `${process.versions.node} satisfies ${range}`
|
|
84
|
+
: `${process.versions.node} is older than required ${range}`,
|
|
85
|
+
remediation: "nvm install 22.14.0 && nvm use 22.14.0",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function checkDemoFixtures() {
|
|
90
|
+
try {
|
|
91
|
+
const index = loadDemoFixtures();
|
|
92
|
+
const movies = index.shots.filter((shot) => shot.video).length;
|
|
93
|
+
return {
|
|
94
|
+
id: "demo-fixtures",
|
|
95
|
+
title: "Bundled demo payload",
|
|
96
|
+
required: true,
|
|
97
|
+
status: "pass",
|
|
98
|
+
detail: `${index.shots.length} fixtures (${movies} movie) ready for "astroshot demo"`,
|
|
99
|
+
remediation: "npm install --global @archastro/astroshot",
|
|
100
|
+
};
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {
|
|
103
|
+
id: "demo-fixtures",
|
|
104
|
+
title: "Bundled demo payload",
|
|
105
|
+
required: true,
|
|
106
|
+
status: "fail",
|
|
107
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
108
|
+
remediation: "npm install --global @archastro/astroshot",
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function checkWatchCoverage(
|
|
114
|
+
projectPath,
|
|
115
|
+
platform,
|
|
116
|
+
// Seam for tests: the tool-failure path is otherwise only reachable by
|
|
117
|
+
// breaking the host's /usr/bin.
|
|
118
|
+
{ readConfiguration = readWatchConfiguration } = {},
|
|
119
|
+
) {
|
|
120
|
+
const base = { id: "watch-roots", title: "Watched folder covers this project" };
|
|
121
|
+
if (platform !== "darwin") {
|
|
122
|
+
return {
|
|
123
|
+
...base,
|
|
124
|
+
required: false,
|
|
125
|
+
status: "skip",
|
|
126
|
+
detail: "the Astroshots app is macOS-only; .astroshot files are still written",
|
|
127
|
+
remediation: "Run captures on macOS to review them in the Astroshots tray",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const configuration = readConfiguration();
|
|
132
|
+
const coverage = evaluateWatchCoverage(projectPath, configuration);
|
|
133
|
+
const source =
|
|
134
|
+
configuration.source === "plist-file"
|
|
135
|
+
? ` (read from ${configuration.plistPath}; cfprefsd unavailable)`
|
|
136
|
+
: "";
|
|
137
|
+
const legacy = configuration.usedLegacyKey ? " via legacy watchRoot key" : "";
|
|
138
|
+
|
|
139
|
+
switch (coverage.state) {
|
|
140
|
+
case "inside-root":
|
|
141
|
+
return {
|
|
142
|
+
...base,
|
|
143
|
+
required: true,
|
|
144
|
+
status: "pass",
|
|
145
|
+
detail: `${coverage.projectPath} is inside ${coverage.matchedRoot}${legacy}${source}`,
|
|
146
|
+
remediation: "Astroshots menu-bar icon → gear → Add folders…",
|
|
147
|
+
data: coverage,
|
|
148
|
+
};
|
|
149
|
+
case "setup-incomplete":
|
|
150
|
+
return {
|
|
151
|
+
...base,
|
|
152
|
+
required: true,
|
|
153
|
+
status: "fail",
|
|
154
|
+
detail:
|
|
155
|
+
"first-launch folder setup has not completed, so Astroshots watches nothing yet",
|
|
156
|
+
remediation:
|
|
157
|
+
"open -a Astroshots # then choose the folder that holds your projects",
|
|
158
|
+
data: coverage,
|
|
159
|
+
};
|
|
160
|
+
case "outside-roots":
|
|
161
|
+
return {
|
|
162
|
+
...base,
|
|
163
|
+
required: true,
|
|
164
|
+
status: "fail",
|
|
165
|
+
detail: `${coverage.projectPath} is outside every watched folder (${coverage.roots.join(", ")})`,
|
|
166
|
+
remediation:
|
|
167
|
+
"Astroshots menu-bar icon → gear → Add folders… → add a parent folder of this project",
|
|
168
|
+
data: coverage,
|
|
169
|
+
};
|
|
170
|
+
default: {
|
|
171
|
+
// The configuration could not be read. Say exactly that: claiming
|
|
172
|
+
// "setup incomplete" here would tell a correctly configured user to redo
|
|
173
|
+
// work they already did.
|
|
174
|
+
const tools = preferenceToolsAvailable({ platform });
|
|
175
|
+
const toolProblem =
|
|
176
|
+
coverage.reason === "tool-unavailable" || !tools.available;
|
|
177
|
+
const missing = tools.missing.length
|
|
178
|
+
? ` (missing ${tools.missing.join(", ")})`
|
|
179
|
+
: "";
|
|
180
|
+
return {
|
|
181
|
+
...base,
|
|
182
|
+
required: true,
|
|
183
|
+
status: "warn",
|
|
184
|
+
detail: toolProblem
|
|
185
|
+
? `watch coverage is UNKNOWN, not unconfigured: could not read ${ASTROSHOTS_DOMAIN} because the macOS preference tools are unavailable${missing}`
|
|
186
|
+
: `watch coverage is UNKNOWN: could not read ${ASTROSHOTS_DOMAIN} preferences (${coverage.reason ?? "unknown"})`,
|
|
187
|
+
remediation: toolProblem
|
|
188
|
+
? "Re-run with /usr/bin on PATH — doctor needs /usr/bin/defaults and /usr/bin/plutil"
|
|
189
|
+
: "Install and launch Astroshots once: https://github.com/ArchAstro/astroshots/releases",
|
|
190
|
+
data: coverage,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function findInstalledApp({ home = os.homedir() } = {}) {
|
|
197
|
+
const candidates = [
|
|
198
|
+
"/Applications/Astroshots.app",
|
|
199
|
+
path.join(home, "Applications", "Astroshots.app"),
|
|
200
|
+
];
|
|
201
|
+
for (const candidate of candidates) {
|
|
202
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
203
|
+
}
|
|
204
|
+
const found = spawnSync(
|
|
205
|
+
"/usr/bin/mdfind",
|
|
206
|
+
["-0", `kMDItemCFBundleIdentifier == '${ASTROSHOTS_DOMAIN}'`],
|
|
207
|
+
{ encoding: "utf8" },
|
|
208
|
+
);
|
|
209
|
+
if (found.status === 0 && found.stdout) {
|
|
210
|
+
const hit = found.stdout
|
|
211
|
+
.split("\0")
|
|
212
|
+
.map((entry) => entry.trim())
|
|
213
|
+
.find((entry) => entry.endsWith(".app") && fs.existsSync(entry));
|
|
214
|
+
if (hit) return hit;
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function appVersion(appPath) {
|
|
220
|
+
const plist = path.join(appPath, "Contents", "Info.plist");
|
|
221
|
+
if (!fs.existsSync(plist)) return null;
|
|
222
|
+
const result = spawnSync(
|
|
223
|
+
"/usr/bin/plutil",
|
|
224
|
+
["-extract", "CFBundleShortVersionString", "raw", "-o", "-", plist],
|
|
225
|
+
{ encoding: "utf8" },
|
|
226
|
+
);
|
|
227
|
+
if (result.status !== 0) return null;
|
|
228
|
+
return result.stdout.trim() || null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function checkApp(platform) {
|
|
232
|
+
const base = { id: "app", title: "Astroshots app installed" };
|
|
233
|
+
if (platform !== "darwin") {
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
required: false,
|
|
237
|
+
status: "skip",
|
|
238
|
+
detail: "macOS-only review app",
|
|
239
|
+
remediation: "Review captured files directly on this platform",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const appPath = findInstalledApp();
|
|
243
|
+
if (!appPath) {
|
|
244
|
+
return {
|
|
245
|
+
...base,
|
|
246
|
+
required: true,
|
|
247
|
+
status: "fail",
|
|
248
|
+
detail: "no Astroshots.app found in /Applications or ~/Applications",
|
|
249
|
+
remediation:
|
|
250
|
+
"Download the latest DMG: https://github.com/ArchAstro/astroshots/releases",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const version = appVersion(appPath);
|
|
254
|
+
return {
|
|
255
|
+
...base,
|
|
256
|
+
required: true,
|
|
257
|
+
status: "pass",
|
|
258
|
+
detail: `${appPath}${version ? ` (${version})` : ""}`,
|
|
259
|
+
remediation: "open -a Astroshots",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function checkAppRunning(platform) {
|
|
264
|
+
const base = { id: "app-running", title: "Astroshots app running" };
|
|
265
|
+
if (platform !== "darwin") {
|
|
266
|
+
return {
|
|
267
|
+
...base,
|
|
268
|
+
required: false,
|
|
269
|
+
status: "skip",
|
|
270
|
+
detail: "macOS-only review app",
|
|
271
|
+
remediation: "Review captured files directly on this platform",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
const result = spawnSync("/usr/bin/pgrep", ["-x", APP_PROCESS_NAME], {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
});
|
|
277
|
+
const running = result.status === 0 && Boolean(result.stdout.trim());
|
|
278
|
+
return {
|
|
279
|
+
...base,
|
|
280
|
+
required: false,
|
|
281
|
+
status: running ? "pass" : "warn",
|
|
282
|
+
detail: running
|
|
283
|
+
? `pid ${result.stdout.trim().split(/\s+/).join(", ")} (menu-bar only, no Dock icon)`
|
|
284
|
+
: "not running, so new captures will not stream or flash an overlay",
|
|
285
|
+
remediation: "open -a Astroshots",
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function resolvePlaywright() {
|
|
290
|
+
// playwright belongs to the engine packages, so resolve it from the engine
|
|
291
|
+
// rather than assuming a hoisted install next to this CLI. It is CommonJS,
|
|
292
|
+
// so require it: ESM interop does not expose the `chromium` named export.
|
|
293
|
+
const engineEntry = fileURLToPath(
|
|
294
|
+
import.meta.resolve("@archastro/react-shot"),
|
|
295
|
+
);
|
|
296
|
+
return createRequire(engineEntry)("playwright");
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function checkChromium() {
|
|
300
|
+
const base = {
|
|
301
|
+
id: "chromium",
|
|
302
|
+
title: "Managed Chromium runtime",
|
|
303
|
+
required: false,
|
|
304
|
+
remediation: "astroshot install-browser # add --with-deps on Linux CI",
|
|
305
|
+
};
|
|
306
|
+
let executablePath;
|
|
307
|
+
try {
|
|
308
|
+
executablePath = resolvePlaywright().chromium.executablePath();
|
|
309
|
+
} catch (error) {
|
|
310
|
+
return {
|
|
311
|
+
...base,
|
|
312
|
+
status: "warn",
|
|
313
|
+
detail: `could not resolve Playwright (${error instanceof Error ? error.message.split("\n")[0] : error}); react/ink/pty and browser movies need it`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
const present = Boolean(executablePath) && fs.existsSync(executablePath);
|
|
317
|
+
return {
|
|
318
|
+
...base,
|
|
319
|
+
status: present ? "pass" : "warn",
|
|
320
|
+
detail: present
|
|
321
|
+
? executablePath
|
|
322
|
+
: `not installed at ${executablePath} — needed for react/ink/pty stills and browser movies (not for "astroshot demo")`,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function checkScreenRecording(platform, { skip = false } = {}) {
|
|
327
|
+
const base = {
|
|
328
|
+
id: "screen-recording",
|
|
329
|
+
title: "Screen Recording permission (movie --source desktop.window)",
|
|
330
|
+
required: false,
|
|
331
|
+
};
|
|
332
|
+
if (platform !== "darwin") {
|
|
333
|
+
return {
|
|
334
|
+
...base,
|
|
335
|
+
status: "skip",
|
|
336
|
+
detail: "desktop.window is macOS-only; use --source browser, pty, or frames",
|
|
337
|
+
remediation: "astroshot movie which-source \"<intent>\"",
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
if (skip) {
|
|
341
|
+
return {
|
|
342
|
+
...base,
|
|
343
|
+
status: "skip",
|
|
344
|
+
detail: "skipped with --skip-screen",
|
|
345
|
+
remediation: "astroshot movie check-screen-access",
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const engineEntry = fileURLToPath(
|
|
350
|
+
import.meta.resolve("@archastro/movie-harness"),
|
|
351
|
+
);
|
|
352
|
+
const harnessBin = path.join(
|
|
353
|
+
path.dirname(path.dirname(engineEntry)),
|
|
354
|
+
"bin",
|
|
355
|
+
"astroshot-movie.mjs",
|
|
356
|
+
);
|
|
357
|
+
const result = spawnSync(
|
|
358
|
+
process.execPath,
|
|
359
|
+
[harnessBin, "check-screen-access"],
|
|
360
|
+
{ encoding: "utf8" },
|
|
361
|
+
);
|
|
362
|
+
let report = null;
|
|
363
|
+
try {
|
|
364
|
+
report = JSON.parse(result.stdout ?? "");
|
|
365
|
+
} catch {
|
|
366
|
+
report = null;
|
|
367
|
+
}
|
|
368
|
+
if (!report) {
|
|
369
|
+
return {
|
|
370
|
+
...base,
|
|
371
|
+
status: "warn",
|
|
372
|
+
detail: `could not probe Screen Recording (${(result.stderr ?? "").trim().split("\n")[0] || `status ${result.status}`})`,
|
|
373
|
+
remediation: "xcode-select --install && astroshot movie check-screen-access",
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
...base,
|
|
378
|
+
status: report.granted ? "pass" : "warn",
|
|
379
|
+
detail: report.granted
|
|
380
|
+
? `granted for ${report.enableApp}`
|
|
381
|
+
: `denied — enable "${report.enableApp}" then quit and reopen it`,
|
|
382
|
+
remediation: report.granted
|
|
383
|
+
? "astroshot movie check-screen-access"
|
|
384
|
+
: "astroshot movie open-screen-settings",
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const SYMBOLS = { pass: "PASS", fail: "FAIL", warn: "WARN", skip: "SKIP" };
|
|
389
|
+
|
|
390
|
+
export function collectDoctorChecks({
|
|
391
|
+
projectPath = process.cwd(),
|
|
392
|
+
platform = process.platform,
|
|
393
|
+
skipScreen = false,
|
|
394
|
+
} = {}) {
|
|
395
|
+
return [
|
|
396
|
+
checkNode(readEngines()),
|
|
397
|
+
checkDemoFixtures(),
|
|
398
|
+
checkApp(platform),
|
|
399
|
+
checkAppRunning(platform),
|
|
400
|
+
checkWatchCoverage(projectPath, platform),
|
|
401
|
+
checkChromium(),
|
|
402
|
+
checkScreenRecording(platform, { skip: skipScreen }),
|
|
403
|
+
];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export function summarizeDoctor(checks) {
|
|
407
|
+
const failures = checks.filter(
|
|
408
|
+
(check) => check.required && check.status === "fail",
|
|
409
|
+
);
|
|
410
|
+
const warnings = checks.filter((check) => check.status === "warn");
|
|
411
|
+
return {
|
|
412
|
+
ok: failures.length === 0,
|
|
413
|
+
failures: failures.map((check) => check.id),
|
|
414
|
+
warnings: warnings.map((check) => check.id),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function runDoctor(argv, { log = console.log } = {}) {
|
|
419
|
+
const options = { root: undefined, json: false, skipScreen: false };
|
|
420
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
421
|
+
const token = argv[index];
|
|
422
|
+
if (token === "-h" || token === "--help" || token === "help") {
|
|
423
|
+
log(doctorHelp());
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
if (token === "--json") {
|
|
427
|
+
options.json = true;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (token === "--skip-screen") {
|
|
431
|
+
options.skipScreen = true;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (token === "--root") {
|
|
435
|
+
const value = argv[index + 1];
|
|
436
|
+
if (!value || value.startsWith("-")) throw new Error("--root requires a value");
|
|
437
|
+
options.root = value;
|
|
438
|
+
index += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
throw new Error(`Unknown doctor argument: ${token}`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const projectPath = resolveProjectPath(options.root);
|
|
445
|
+
const checks = collectDoctorChecks({
|
|
446
|
+
projectPath,
|
|
447
|
+
skipScreen: options.skipScreen,
|
|
448
|
+
});
|
|
449
|
+
const summary = summarizeDoctor(checks);
|
|
450
|
+
|
|
451
|
+
if (options.json) {
|
|
452
|
+
log(JSON.stringify({ project: projectPath, ...summary, checks }, null, 2));
|
|
453
|
+
return summary.ok ? 0 : 1;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
log(`astroshot doctor — ${projectPath}`);
|
|
457
|
+
log("");
|
|
458
|
+
for (const check of checks) {
|
|
459
|
+
const scope = check.required ? "required" : "optional";
|
|
460
|
+
log(`${SYMBOLS[check.status]} ${check.title} [${scope}]`);
|
|
461
|
+
log(` ${check.detail}`);
|
|
462
|
+
if (check.status === "fail" || check.status === "warn") {
|
|
463
|
+
log(` fix: ${check.remediation}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
log("");
|
|
467
|
+
if (summary.ok) {
|
|
468
|
+
log(
|
|
469
|
+
summary.warnings.length
|
|
470
|
+
? `All required checks pass (${summary.warnings.length} optional warning${summary.warnings.length === 1 ? "" : "s"} above).`
|
|
471
|
+
: "All checks pass. Try: astroshot demo",
|
|
472
|
+
);
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
log(
|
|
476
|
+
`${summary.failures.length} required check${summary.failures.length === 1 ? "" : "s"} failed: ${summary.failures.join(", ")}`,
|
|
477
|
+
);
|
|
478
|
+
log("Run the fix line under each failure, then re-run: astroshot doctor");
|
|
479
|
+
return 1;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function resolveProjectPath(explicitRoot) {
|
|
483
|
+
if (explicitRoot) return path.resolve(explicitRoot);
|
|
484
|
+
try {
|
|
485
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
486
|
+
encoding: "utf8",
|
|
487
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
488
|
+
}).trim();
|
|
489
|
+
} catch {
|
|
490
|
+
return process.cwd();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only access to the Astroshots macOS app's real preferences.
|
|
3
|
+
*
|
|
4
|
+
* Two traps make the obvious implementations wrong:
|
|
5
|
+
*
|
|
6
|
+
* 1. Whole-domain plist → JSON conversion can never work. The domain contains
|
|
7
|
+
* AppKit's `NSOSPLastRootDirectory` open-panel bookmark (CFData), and JSON
|
|
8
|
+
* has no representation for it, so `plutil -convert json` aborts with
|
|
9
|
+
* "Invalid object in plist for JSON format" on effectively every real user's
|
|
10
|
+
* machine. Extract one key at a time instead.
|
|
11
|
+
* 2. `~/Library/Preferences/<domain>.plist` is a lazily flushed cache of
|
|
12
|
+
* cfprefsd state, so reading the file can report yesterday's configuration.
|
|
13
|
+
* Go through cfprefsd (`defaults export`) first and treat the file as a
|
|
14
|
+
* fallback only.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here writes: `astroshot doctor` must not mutate app state.
|
|
17
|
+
*/
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
export const ASTROSHOTS_DOMAIN = "ai.archastro.Astroshots";
|
|
24
|
+
|
|
25
|
+
const MISSING_KEY_PATTERN =
|
|
26
|
+
/No value at that key path|invalid key path|Invalid object in plist/i;
|
|
27
|
+
|
|
28
|
+
// Resolve Apple's tools by absolute path. They always live in /usr/bin, and a
|
|
29
|
+
// minimal or empty PATH must never be mistaken for "this user has no watch
|
|
30
|
+
// roots" — that is the exact false negative doctor exists to eliminate.
|
|
31
|
+
const DEFAULTS_BIN = "/usr/bin/defaults";
|
|
32
|
+
const PLUTIL_BIN = "/usr/bin/plutil";
|
|
33
|
+
|
|
34
|
+
/** Whether the tools this module shells out to are actually present. */
|
|
35
|
+
export function preferenceToolsAvailable({ platform = process.platform } = {}) {
|
|
36
|
+
if (platform !== "darwin") return { available: false, missing: [] };
|
|
37
|
+
const missing = [DEFAULTS_BIN, PLUTIL_BIN].filter(
|
|
38
|
+
(binary) => !fs.existsSync(binary),
|
|
39
|
+
);
|
|
40
|
+
return { available: missing.length === 0, missing };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Snapshot the preference domain as an XML plist.
|
|
45
|
+
*
|
|
46
|
+
* `defaults export` goes through cfprefsd, so it observes what the app itself
|
|
47
|
+
* sees. The on-disk plist is only used when cfprefsd is unavailable.
|
|
48
|
+
*/
|
|
49
|
+
export function readPreferenceDomain(
|
|
50
|
+
domain = ASTROSHOTS_DOMAIN,
|
|
51
|
+
{ home = os.homedir(), platform = process.platform } = {},
|
|
52
|
+
) {
|
|
53
|
+
if (platform !== "darwin") {
|
|
54
|
+
return { available: false, source: null, reason: "not-macos" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const exported = spawnSync(DEFAULTS_BIN, ["export", domain, "-"], {
|
|
58
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
59
|
+
});
|
|
60
|
+
if (!exported.error && exported.status === 0 && exported.stdout?.length) {
|
|
61
|
+
return { available: true, source: "cfprefsd", plist: exported.stdout };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const plistPath = path.join(
|
|
65
|
+
home,
|
|
66
|
+
"Library",
|
|
67
|
+
"Preferences",
|
|
68
|
+
`${domain}.plist`,
|
|
69
|
+
);
|
|
70
|
+
try {
|
|
71
|
+
const bytes = fs.readFileSync(plistPath);
|
|
72
|
+
return {
|
|
73
|
+
available: true,
|
|
74
|
+
source: "plist-file",
|
|
75
|
+
plist: bytes,
|
|
76
|
+
plistPath,
|
|
77
|
+
staleRisk: true,
|
|
78
|
+
};
|
|
79
|
+
} catch {
|
|
80
|
+
// `defaults` failing to launch is a tool failure, not evidence that the
|
|
81
|
+
// domain is absent.
|
|
82
|
+
if (exported.error) {
|
|
83
|
+
return {
|
|
84
|
+
available: false,
|
|
85
|
+
source: null,
|
|
86
|
+
reason: "tool-unavailable",
|
|
87
|
+
error: exported.error.message,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
available: false,
|
|
92
|
+
source: null,
|
|
93
|
+
reason:
|
|
94
|
+
exported.status === 1 && !exported.stdout?.length
|
|
95
|
+
? "domain-not-found"
|
|
96
|
+
: "unreadable",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Extract a single preference key from a plist snapshot.
|
|
103
|
+
*
|
|
104
|
+
* Three outcomes, deliberately distinct:
|
|
105
|
+
* - `{ present: true, raw }` the key exists
|
|
106
|
+
* - `{ present: false }` the key is genuinely absent
|
|
107
|
+
* - `{ present: false, failed: true }` the tool could not answer
|
|
108
|
+
*
|
|
109
|
+
* A missing key exits non-zero with the same "Invalid object" wording as a real
|
|
110
|
+
* failure, so absence is matched explicitly. Everything else — `plutil`
|
|
111
|
+
* missing, a spawn error, or an unrecognized non-zero exit — is a tool failure.
|
|
112
|
+
* Collapsing the third case into absence would make a correctly configured user
|
|
113
|
+
* read as "setup never completed".
|
|
114
|
+
*/
|
|
115
|
+
export function extractPreferenceKey(plist, key, format = "raw") {
|
|
116
|
+
const result = spawnSync(
|
|
117
|
+
PLUTIL_BIN,
|
|
118
|
+
["-extract", key, format, "-o", "-", "-"],
|
|
119
|
+
{ input: plist, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 },
|
|
120
|
+
);
|
|
121
|
+
if (result.error) {
|
|
122
|
+
return { present: false, failed: true, error: result.error.message };
|
|
123
|
+
}
|
|
124
|
+
if (result.status !== 0) {
|
|
125
|
+
if (MISSING_KEY_PATTERN.test(result.stderr ?? "")) {
|
|
126
|
+
return { present: false };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
present: false,
|
|
130
|
+
failed: true,
|
|
131
|
+
error: (result.stderr ?? "").trim() || `plutil exited ${result.status}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { present: true, raw: result.stdout };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractStringArray(plist, key) {
|
|
138
|
+
const extracted = extractPreferenceKey(plist, key, "json");
|
|
139
|
+
if (!extracted.present) {
|
|
140
|
+
return { present: false, failed: extracted.failed, error: extracted.error };
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const parsed = JSON.parse(extracted.raw);
|
|
144
|
+
if (!Array.isArray(parsed)) return { present: false };
|
|
145
|
+
return {
|
|
146
|
+
present: true,
|
|
147
|
+
value: parsed.filter((entry) => typeof entry === "string"),
|
|
148
|
+
};
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// The key exists but did not decode: unreadable, not absent.
|
|
151
|
+
return {
|
|
152
|
+
present: false,
|
|
153
|
+
failed: true,
|
|
154
|
+
error: error instanceof Error ? error.message : String(error),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function extractString(plist, key) {
|
|
160
|
+
const extracted = extractPreferenceKey(plist, key, "raw");
|
|
161
|
+
if (!extracted.present) {
|
|
162
|
+
return { present: false, failed: extracted.failed, error: extracted.error };
|
|
163
|
+
}
|
|
164
|
+
const value = extracted.raw.replace(/\n$/, "");
|
|
165
|
+
return { present: true, value };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractBoolean(plist, key) {
|
|
169
|
+
const extracted = extractString(plist, key);
|
|
170
|
+
if (!extracted.present) {
|
|
171
|
+
return { present: false, failed: extracted.failed };
|
|
172
|
+
}
|
|
173
|
+
return { present: true, value: /^(true|1|yes)$/i.test(extracted.value) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Mirror of `Preferences.normalizeWatchRootPaths` in the Swift app: expand
|
|
178
|
+
* tildes, standardize, resolve symlinks, drop duplicates, and drop roots
|
|
179
|
+
* already covered recursively by an earlier root.
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeWatchRootPaths(paths, { home = os.homedir() } = {}) {
|
|
182
|
+
const result = [];
|
|
183
|
+
for (const rawPath of paths) {
|
|
184
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) continue;
|
|
185
|
+
const candidate = normalizePath(rawPath, { home });
|
|
186
|
+
if (result.some((root) => pathIsInside(root, candidate))) continue;
|
|
187
|
+
for (let index = result.length - 1; index >= 0; index -= 1) {
|
|
188
|
+
if (pathIsInside(candidate, result[index])) result.splice(index, 1);
|
|
189
|
+
}
|
|
190
|
+
result.push(candidate);
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Expand `~`, make absolute, and resolve symlinks as far as the path exists. */
|
|
196
|
+
export function normalizePath(rawPath, { home = os.homedir() } = {}) {
|
|
197
|
+
let expanded = rawPath;
|
|
198
|
+
if (expanded === "~") expanded = home;
|
|
199
|
+
else if (expanded.startsWith("~/")) expanded = path.join(home, expanded.slice(2));
|
|
200
|
+
const absolute = path.resolve(expanded);
|
|
201
|
+
try {
|
|
202
|
+
return fs.realpathSync.native(absolute);
|
|
203
|
+
} catch {
|
|
204
|
+
// Resolve the deepest existing ancestor so a missing leaf still normalizes.
|
|
205
|
+
const parts = absolute.split(path.sep);
|
|
206
|
+
for (let depth = parts.length - 1; depth > 1; depth -= 1) {
|
|
207
|
+
const ancestor = parts.slice(0, depth).join(path.sep);
|
|
208
|
+
try {
|
|
209
|
+
const real = fs.realpathSync.native(ancestor);
|
|
210
|
+
return path.join(real, ...parts.slice(depth));
|
|
211
|
+
} catch {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return absolute;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Containment on path-component boundaries, so `/Users/x/proj-two` is not
|
|
221
|
+
* treated as living inside `/Users/x/proj`.
|
|
222
|
+
*/
|
|
223
|
+
export function pathIsInside(root, candidate) {
|
|
224
|
+
if (root === candidate) return true;
|
|
225
|
+
const prefix = root.endsWith(path.sep) ? root : `${root}${path.sep}`;
|
|
226
|
+
return candidate.startsWith(prefix);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* The app's live watch configuration.
|
|
231
|
+
*
|
|
232
|
+
* `watchRoots` (string array) is authoritative; the legacy singular
|
|
233
|
+
* `watchRoot` is still honored when `watchRoots` was never written so upgrades
|
|
234
|
+
* keep the folder the user already chose.
|
|
235
|
+
*/
|
|
236
|
+
export function readWatchConfiguration({
|
|
237
|
+
domain = ASTROSHOTS_DOMAIN,
|
|
238
|
+
home = os.homedir(),
|
|
239
|
+
platform = process.platform,
|
|
240
|
+
// Seam for tests: lets a suite supply a plist snapshot (including a corrupt
|
|
241
|
+
// one) without breaking the host's /usr/bin.
|
|
242
|
+
readDomain = readPreferenceDomain,
|
|
243
|
+
} = {}) {
|
|
244
|
+
const snapshot = readDomain(domain, { home, platform });
|
|
245
|
+
if (!snapshot.available) {
|
|
246
|
+
return {
|
|
247
|
+
available: false,
|
|
248
|
+
reason: snapshot.reason,
|
|
249
|
+
source: null,
|
|
250
|
+
roots: [],
|
|
251
|
+
hasCompletedFirstRunSetup: false,
|
|
252
|
+
usedLegacyKey: false,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const modern = extractStringArray(snapshot.plist, "watchRoots");
|
|
257
|
+
const legacy = modern.present
|
|
258
|
+
? { present: false }
|
|
259
|
+
: extractString(snapshot.plist, "watchRoot");
|
|
260
|
+
const firstRun = extractBoolean(snapshot.plist, "hasCompletedFirstRunSetup");
|
|
261
|
+
|
|
262
|
+
// If the extraction tool could not answer, we know nothing about this user's
|
|
263
|
+
// setup. Reporting "no watch roots" here would tell a correctly configured
|
|
264
|
+
// user to redo first-run setup, so surface the unreadable state instead.
|
|
265
|
+
const failure = [modern, legacy, firstRun].find((result) => result.failed);
|
|
266
|
+
if (failure) {
|
|
267
|
+
return {
|
|
268
|
+
available: false,
|
|
269
|
+
reason: "tool-unavailable",
|
|
270
|
+
error: failure.error,
|
|
271
|
+
source: snapshot.source,
|
|
272
|
+
roots: [],
|
|
273
|
+
hasCompletedFirstRunSetup: false,
|
|
274
|
+
usedLegacyKey: false,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const stored = modern.present
|
|
279
|
+
? modern.value
|
|
280
|
+
: legacy.present && legacy.value
|
|
281
|
+
? [legacy.value]
|
|
282
|
+
: [];
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
available: true,
|
|
286
|
+
source: snapshot.source,
|
|
287
|
+
staleRisk: Boolean(snapshot.staleRisk),
|
|
288
|
+
plistPath: snapshot.plistPath,
|
|
289
|
+
roots: normalizeWatchRootPaths(stored, { home }),
|
|
290
|
+
storedRoots: stored,
|
|
291
|
+
usedLegacyKey: !modern.present && legacy.present,
|
|
292
|
+
hasCompletedFirstRunSetup: firstRun.present ? firstRun.value : false,
|
|
293
|
+
hasConfiguredWatchRoots: modern.present || legacy.present,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Classify a project directory against the app's watch roots.
|
|
299
|
+
*
|
|
300
|
+
* Each outcome needs a different remediation, so they stay distinct instead of
|
|
301
|
+
* collapsing into "not watched":
|
|
302
|
+
* - `setup-incomplete` first-run folder setup never finished
|
|
303
|
+
* - `outside-roots` setup finished, but this project is not covered
|
|
304
|
+
* - `inside-root` covered by `matchedRoot`
|
|
305
|
+
* - `unknown` the configuration could not be read at all — never
|
|
306
|
+
* claim anything about the user's setup here
|
|
307
|
+
* - `unsupported` not macOS
|
|
308
|
+
*/
|
|
309
|
+
export function evaluateWatchCoverage(
|
|
310
|
+
projectPath,
|
|
311
|
+
configuration,
|
|
312
|
+
{ home = os.homedir() } = {},
|
|
313
|
+
) {
|
|
314
|
+
if (!configuration.available) {
|
|
315
|
+
return {
|
|
316
|
+
state: configuration.reason === "not-macos" ? "unsupported" : "unknown",
|
|
317
|
+
reason: configuration.reason,
|
|
318
|
+
error: configuration.error,
|
|
319
|
+
roots: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const normalizedProject = normalizePath(projectPath, { home });
|
|
323
|
+
const matchedRoot =
|
|
324
|
+
configuration.roots.find((root) => pathIsInside(root, normalizedProject)) ??
|
|
325
|
+
null;
|
|
326
|
+
if (matchedRoot) {
|
|
327
|
+
return {
|
|
328
|
+
state: "inside-root",
|
|
329
|
+
matchedRoot,
|
|
330
|
+
projectPath: normalizedProject,
|
|
331
|
+
roots: configuration.roots,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (!configuration.hasCompletedFirstRunSetup || configuration.roots.length === 0) {
|
|
335
|
+
return {
|
|
336
|
+
state: "setup-incomplete",
|
|
337
|
+
projectPath: normalizedProject,
|
|
338
|
+
roots: configuration.roots,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
state: "outside-roots",
|
|
343
|
+
projectPath: normalizedProject,
|
|
344
|
+
roots: configuration.roots,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"generated_by": "npm run build:demo-fixtures --workspace @archastro/astroshot",
|
|
4
|
+
"viewport": "720x450",
|
|
5
|
+
"shots": [
|
|
6
|
+
{
|
|
7
|
+
"asset": "welcome.png",
|
|
8
|
+
"slug": "welcome",
|
|
9
|
+
"title": "Watch path works",
|
|
10
|
+
"description": "astroshot demo wrote this still into .astroshot/ with zero prerequisites."
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"asset": "next-steps.png",
|
|
14
|
+
"slug": "next-steps",
|
|
15
|
+
"title": "Next steps",
|
|
16
|
+
"description": "The commands that capture real React, Ink, PTY, and movie states."
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"asset": "journey.png",
|
|
20
|
+
"video": "journey.webm",
|
|
21
|
+
"slug": "journey",
|
|
22
|
+
"title": "Journey movie",
|
|
23
|
+
"description": "A poster PNG plus sibling WebM proves movie playback, duration, and chapters.",
|
|
24
|
+
"duration_ms": 4240,
|
|
25
|
+
"source": "frames",
|
|
26
|
+
"chapters": [
|
|
27
|
+
{
|
|
28
|
+
"slug": "recording",
|
|
29
|
+
"t_ms": 0
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"slug": "poster",
|
|
33
|
+
"t_ms": 2685
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"slug": "streaming",
|
|
37
|
+
"t_ms": 3180
|
|
38
|
+
}
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@archastro/astroshot",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "One CLI for deterministic React, terminal, and movie UI captures",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -47,6 +47,7 @@
|
|
|
47
47
|
},
|
|
48
48
|
"files": [
|
|
49
49
|
"bin",
|
|
50
|
+
"fixtures",
|
|
50
51
|
"react.d.ts",
|
|
51
52
|
"react.js",
|
|
52
53
|
"ink.d.ts",
|
|
@@ -59,7 +60,8 @@
|
|
|
59
60
|
],
|
|
60
61
|
"scripts": {
|
|
61
62
|
"pretest": "npm run build --workspace @archastro/react-shot && npm run build --workspace @archastro/tui-shot && npm run build --workspace @archastro/movie-harness",
|
|
62
|
-
"test": "node --test test/*.test.mjs"
|
|
63
|
+
"test": "node --test test/*.test.mjs",
|
|
64
|
+
"build:demo-fixtures": "node scripts/build-demo-fixtures.mjs"
|
|
63
65
|
},
|
|
64
66
|
"engines": {
|
|
65
67
|
"node": ">=22.14.0"
|
|
@@ -70,8 +72,8 @@
|
|
|
70
72
|
"registry": "https://registry.npmjs.org/"
|
|
71
73
|
},
|
|
72
74
|
"dependencies": {
|
|
73
|
-
"@archastro/movie-harness": "0.2.
|
|
74
|
-
"@archastro/react-shot": "0.2.
|
|
75
|
-
"@archastro/tui-shot": "0.2.
|
|
75
|
+
"@archastro/movie-harness": "0.2.1",
|
|
76
|
+
"@archastro/react-shot": "0.2.1",
|
|
77
|
+
"@archastro/tui-shot": "0.2.1"
|
|
76
78
|
}
|
|
77
79
|
}
|