astroshot 0.2.1 → 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/node_modules/@archastro/astroshot/README.md +4 -0
- package/node_modules/@archastro/astroshot/bin/astroshot.mjs +56 -1
- package/node_modules/@archastro/astroshot/bin/demo.mjs +72 -16
- package/node_modules/@archastro/astroshot/package.json +5 -4
- package/node_modules/@archastro/astroshot-review/LICENSE +21 -0
- package/node_modules/@archastro/astroshot-review/README.md +19 -0
- package/node_modules/@archastro/astroshot-review/bin/astroshot-review.mjs +17 -0
- package/node_modules/@archastro/astroshot-review/dist/cli.d.ts +12 -0
- package/node_modules/@archastro/astroshot-review/dist/cli.js +240 -0
- package/node_modules/@archastro/astroshot-review/dist/data/friction.d.ts +22 -0
- package/node_modules/@archastro/astroshot-review/dist/data/friction.js +278 -0
- package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.d.ts +17 -0
- package/node_modules/@archastro/astroshot-review/dist/data/hash-cache.js +51 -0
- package/node_modules/@archastro/astroshot-review/dist/data/index-cache.d.ts +22 -0
- package/node_modules/@archastro/astroshot-review/dist/data/index-cache.js +64 -0
- package/node_modules/@archastro/astroshot-review/dist/data/manifest.d.ts +41 -0
- package/node_modules/@archastro/astroshot-review/dist/data/manifest.js +105 -0
- package/node_modules/@archastro/astroshot-review/dist/data/model.d.ts +96 -0
- package/node_modules/@archastro/astroshot-review/dist/data/model.js +1 -0
- package/node_modules/@archastro/astroshot-review/dist/data/paths.d.ts +27 -0
- package/node_modules/@archastro/astroshot-review/dist/data/paths.js +98 -0
- package/node_modules/@archastro/astroshot-review/dist/data/review-store.d.ts +67 -0
- package/node_modules/@archastro/astroshot-review/dist/data/review-store.js +237 -0
- package/node_modules/@archastro/astroshot-review/dist/data/scan.d.ts +32 -0
- package/node_modules/@archastro/astroshot-review/dist/data/scan.js +227 -0
- package/node_modules/@archastro/astroshot-review/dist/data/store.d.ts +92 -0
- package/node_modules/@archastro/astroshot-review/dist/data/store.js +408 -0
- package/node_modules/@archastro/astroshot-review/dist/data/watcher.d.ts +37 -0
- package/node_modules/@archastro/astroshot-review/dist/data/watcher.js +126 -0
- package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.d.ts +10 -0
- package/node_modules/@archastro/astroshot-review/dist/images/halfblocks.js +21 -0
- package/node_modules/@archastro/astroshot-review/dist/images/png.d.ts +14 -0
- package/node_modules/@archastro/astroshot-review/dist/images/png.js +45 -0
- package/node_modules/@archastro/astroshot-review/dist/images/scale.d.ts +31 -0
- package/node_modules/@archastro/astroshot-review/dist/images/scale.js +102 -0
- package/node_modules/@archastro/astroshot-review/dist/images/service.d.ts +54 -0
- package/node_modules/@archastro/astroshot-review/dist/images/service.js +163 -0
- package/node_modules/@archastro/astroshot-review/dist/images/worker.d.ts +21 -0
- package/node_modules/@archastro/astroshot-review/dist/images/worker.js +30 -0
- package/node_modules/@archastro/astroshot-review/dist/index.d.ts +12 -0
- package/node_modules/@archastro/astroshot-review/dist/index.js +9 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.d.ts +9 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/graphics-stdout.js +36 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.d.ts +61 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/herdr.js +327 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.d.ts +122 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/image-layer.js +471 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.d.ts +74 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/kitty.js +112 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/probe.d.ts +49 -0
- package/node_modules/@archastro/astroshot-review/dist/terminal/probe.js +206 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/app.d.ts +6 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/app.js +695 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/chrome.d.ts +53 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/chrome.js +69 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/context.d.ts +16 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/context.js +8 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/detail.d.ts +33 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/detail.js +39 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/friction.d.ts +42 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/friction.js +84 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/help.d.ts +4 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/help.js +61 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/hooks.d.ts +9 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/hooks.js +32 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.d.ts +29 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/movie-player.js +119 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/picture.d.ts +19 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/picture.js +100 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/selectors.d.ts +26 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/selectors.js +69 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/settings.d.ts +5 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/settings.js +17 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/stream.d.ts +38 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/stream.js +118 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/system.d.ts +4 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/system.js +34 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/takeover.d.ts +26 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/takeover.js +29 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/text-input.d.ts +8 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/text-input.js +74 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/theme.d.ts +21 -0
- package/node_modules/@archastro/astroshot-review/dist/ui/theme.js +63 -0
- package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.d.ts +54 -0
- package/node_modules/@archastro/astroshot-review/dist/video/ffmpeg.js +206 -0
- package/node_modules/@archastro/astroshot-review/package.json +71 -0
- package/node_modules/@archastro/movie-harness/package.json +1 -1
- package/node_modules/@archastro/react-shot/package.json +4 -4
- package/node_modules/@archastro/tui-shot/README.md +9 -0
- package/node_modules/@archastro/tui-shot/dist/index.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/index.js +1 -0
- package/node_modules/@archastro/tui-shot/dist/kitty-graphics.d.ts +45 -0
- package/node_modules/@archastro/tui-shot/dist/kitty-graphics.js +279 -0
- package/node_modules/@archastro/tui-shot/dist/pty-shot.js +25 -3
- package/node_modules/@archastro/tui-shot/dist/shot.d.ts +2 -0
- package/node_modules/@archastro/tui-shot/dist/shot.js +3 -1
- package/node_modules/@archastro/tui-shot/dist/types.d.ts +6 -0
- package/node_modules/@archastro/tui-shot/package.json +1 -1
- package/package.json +10 -5
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const ASTROSHOT_DIR = ".astroshot";
|
|
2
|
+
export declare const FRICTION_DIR = "friction-logs";
|
|
3
|
+
/** Directories the scanner never descends into (mirrors the macOS app). */
|
|
4
|
+
export declare const SKIP_DIRECTORIES: Set<string>;
|
|
5
|
+
export declare const MAX_SCAN_DEPTH = 10;
|
|
6
|
+
export declare const IMAGE_EXTENSIONS: Set<string>;
|
|
7
|
+
export declare const VIDEO_EXTENSIONS: string[];
|
|
8
|
+
export declare function extensionOf(fileName: string): string;
|
|
9
|
+
export declare function isImageFile(fileName: string): boolean;
|
|
10
|
+
export interface ShotPath {
|
|
11
|
+
worktreePath: string;
|
|
12
|
+
worktree: string;
|
|
13
|
+
feature: string;
|
|
14
|
+
featureDir: string;
|
|
15
|
+
fileName: string;
|
|
16
|
+
}
|
|
17
|
+
/** Accept only `<worktree>/.astroshot/<feature>/<image>`; friction logs are excluded. */
|
|
18
|
+
export declare function parseShotPath(imagePath: string): ShotPath | null;
|
|
19
|
+
export declare function sequenceAndSlug(fileName: string): {
|
|
20
|
+
sequence: string | null;
|
|
21
|
+
slug: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function humanize(slug: string): string;
|
|
24
|
+
export declare function worktreeShort(name: string): string;
|
|
25
|
+
export declare function isInsideFrictionLogs(filePath: string): boolean;
|
|
26
|
+
export declare function frictionLogsDir(astroshotDir: string): string;
|
|
27
|
+
export declare function abbreviateHome(filePath: string, home: string): string;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
export const ASTROSHOT_DIR = ".astroshot";
|
|
3
|
+
export const FRICTION_DIR = "friction-logs";
|
|
4
|
+
/** Directories the scanner never descends into (mirrors the macOS app). */
|
|
5
|
+
export const SKIP_DIRECTORIES = new Set([
|
|
6
|
+
"node_modules",
|
|
7
|
+
".git",
|
|
8
|
+
"DerivedData",
|
|
9
|
+
"build",
|
|
10
|
+
".build",
|
|
11
|
+
"Pods",
|
|
12
|
+
".next",
|
|
13
|
+
"dist",
|
|
14
|
+
"out",
|
|
15
|
+
"target",
|
|
16
|
+
"vendor",
|
|
17
|
+
"Checkouts",
|
|
18
|
+
"xcuserdata",
|
|
19
|
+
".turbo",
|
|
20
|
+
".cache",
|
|
21
|
+
"coverage",
|
|
22
|
+
"tmp",
|
|
23
|
+
".pnpm-store",
|
|
24
|
+
"Carthage",
|
|
25
|
+
"bazel-bin",
|
|
26
|
+
"bazel-out",
|
|
27
|
+
"bazel-testlogs",
|
|
28
|
+
".gradle",
|
|
29
|
+
]);
|
|
30
|
+
export const MAX_SCAN_DEPTH = 10;
|
|
31
|
+
export const IMAGE_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "gif"]);
|
|
32
|
+
export const VIDEO_EXTENSIONS = ["webm", "mp4", "mov", "m4v"];
|
|
33
|
+
export function extensionOf(fileName) {
|
|
34
|
+
return path.extname(fileName).slice(1).toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
export function isImageFile(fileName) {
|
|
37
|
+
return IMAGE_EXTENSIONS.has(extensionOf(fileName));
|
|
38
|
+
}
|
|
39
|
+
/** Accept only `<worktree>/.astroshot/<feature>/<image>`; friction logs are excluded. */
|
|
40
|
+
export function parseShotPath(imagePath) {
|
|
41
|
+
const fileName = path.basename(imagePath);
|
|
42
|
+
if (!isImageFile(fileName))
|
|
43
|
+
return null;
|
|
44
|
+
const featureDir = path.dirname(imagePath);
|
|
45
|
+
const feature = path.basename(featureDir);
|
|
46
|
+
if (!feature || feature === ASTROSHOT_DIR || feature === FRICTION_DIR)
|
|
47
|
+
return null;
|
|
48
|
+
const astroshotDir = path.dirname(featureDir);
|
|
49
|
+
if (path.basename(astroshotDir) !== ASTROSHOT_DIR)
|
|
50
|
+
return null;
|
|
51
|
+
if (imagePath.split(path.sep).includes(FRICTION_DIR))
|
|
52
|
+
return null;
|
|
53
|
+
const worktreePath = path.dirname(astroshotDir);
|
|
54
|
+
return {
|
|
55
|
+
worktreePath,
|
|
56
|
+
worktree: path.basename(worktreePath),
|
|
57
|
+
feature,
|
|
58
|
+
featureDir,
|
|
59
|
+
fileName,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function sequenceAndSlug(fileName) {
|
|
63
|
+
const stem = fileName.replace(/\.[^.]+$/, "");
|
|
64
|
+
const dash = stem.indexOf("-");
|
|
65
|
+
if (dash > 0) {
|
|
66
|
+
const head = stem.slice(0, dash);
|
|
67
|
+
if (/^\d+$/.test(head))
|
|
68
|
+
return { sequence: head, slug: stem.slice(dash + 1) };
|
|
69
|
+
}
|
|
70
|
+
return { sequence: null, slug: stem };
|
|
71
|
+
}
|
|
72
|
+
export function humanize(slug) {
|
|
73
|
+
return slug
|
|
74
|
+
.split(/[-_]+/)
|
|
75
|
+
.filter(Boolean)
|
|
76
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
77
|
+
.join(" ");
|
|
78
|
+
}
|
|
79
|
+
export function worktreeShort(name) {
|
|
80
|
+
const match = /wt\d+/.exec(name);
|
|
81
|
+
if (match)
|
|
82
|
+
return match[0];
|
|
83
|
+
return name.length <= 8 ? name : name.slice(0, 6);
|
|
84
|
+
}
|
|
85
|
+
export function isInsideFrictionLogs(filePath) {
|
|
86
|
+
const parts = filePath.split(path.sep);
|
|
87
|
+
const index = parts.indexOf(ASTROSHOT_DIR);
|
|
88
|
+
return index !== -1 && parts[index + 1] === FRICTION_DIR;
|
|
89
|
+
}
|
|
90
|
+
export function frictionLogsDir(astroshotDir) {
|
|
91
|
+
return path.join(astroshotDir, FRICTION_DIR);
|
|
92
|
+
}
|
|
93
|
+
export function abbreviateHome(filePath, home) {
|
|
94
|
+
if (filePath === home)
|
|
95
|
+
return "~";
|
|
96
|
+
const prefix = home.endsWith(path.sep) ? home : `${home}${path.sep}`;
|
|
97
|
+
return filePath.startsWith(prefix) ? `~${path.sep}${filePath.slice(prefix.length)}` : filePath;
|
|
98
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReviewComment, ReviewSnapshot } from "./model.js";
|
|
2
|
+
export declare const REVIEW_FILE = "review.json";
|
|
3
|
+
export interface ReviewEntry {
|
|
4
|
+
decision?: string;
|
|
5
|
+
reviewed_at?: string;
|
|
6
|
+
image_sha256?: string;
|
|
7
|
+
comments?: ReviewComment[];
|
|
8
|
+
}
|
|
9
|
+
export interface ReviewDocument {
|
|
10
|
+
version: number;
|
|
11
|
+
run_id?: string;
|
|
12
|
+
updated_at?: string;
|
|
13
|
+
reviews: Record<string, StoredEntry>;
|
|
14
|
+
}
|
|
15
|
+
interface StoredComment {
|
|
16
|
+
id: string;
|
|
17
|
+
body: string;
|
|
18
|
+
created_at: string;
|
|
19
|
+
}
|
|
20
|
+
interface StoredEntry {
|
|
21
|
+
decision?: string;
|
|
22
|
+
reviewed_at?: string;
|
|
23
|
+
image_sha256?: string;
|
|
24
|
+
comments?: StoredComment[];
|
|
25
|
+
}
|
|
26
|
+
export declare class UnsupportedReviewVersion extends Error {
|
|
27
|
+
constructor(version: unknown);
|
|
28
|
+
}
|
|
29
|
+
export declare function emptyDocument(): ReviewDocument;
|
|
30
|
+
/** Missing file → empty document. Malformed JSON → null (treated as unreadable). */
|
|
31
|
+
export declare function readReviewDocument(directory: string): Promise<ReviewDocument | null>;
|
|
32
|
+
export declare function parseReviewDocument(raw: string): ReviewDocument | null;
|
|
33
|
+
export declare function sha256File(filePath: string): Promise<string>;
|
|
34
|
+
export declare function sha256Bytes(bytes: Buffer): string;
|
|
35
|
+
/** The entry that applies to `fileName`, or null when the run gate rejects it. */
|
|
36
|
+
export declare function scopedEntry(document: ReviewDocument, fileName: string, expectedRunId: string | null): StoredEntry | null;
|
|
37
|
+
/** Whether validating this entry needs the image's current hash. */
|
|
38
|
+
export declare function entryNeedsHash(entry: StoredEntry | null): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The seen/stale truth table from the app's `ReviewSnapshot`:
|
|
41
|
+
* hash mismatch hides the decision but keeps comments and flags staleness.
|
|
42
|
+
*/
|
|
43
|
+
export declare function snapshotFromEntry(entry: StoredEntry | null, currentSha256: string | null): ReviewSnapshot;
|
|
44
|
+
export declare function nowIso(date?: Date): string;
|
|
45
|
+
export declare function newCommentId(): string;
|
|
46
|
+
export declare function serializeReviewDocument(document: ReviewDocument): string;
|
|
47
|
+
export declare function writeReviewDocument(directory: string, document: ReviewDocument): Promise<void>;
|
|
48
|
+
/** A run-id change starts a fresh review map, exactly like the app. */
|
|
49
|
+
export declare function resetReviewsIfNeeded(document: ReviewDocument, runId: string | null): void;
|
|
50
|
+
export interface ReviewWriteRequest {
|
|
51
|
+
/** Directory holding review.json (feature dir, or friction run dir). */
|
|
52
|
+
directory: string;
|
|
53
|
+
/** Key inside `reviews` (image file name, or `log.jsonl`). */
|
|
54
|
+
fileName: string;
|
|
55
|
+
runId: string | null;
|
|
56
|
+
/** Absolute path of the bytes to hash when marking seen. */
|
|
57
|
+
targetPath: string;
|
|
58
|
+
}
|
|
59
|
+
export declare function markSeen(request: ReviewWriteRequest, options?: {
|
|
60
|
+
comment?: string;
|
|
61
|
+
now?: Date;
|
|
62
|
+
}): Promise<ReviewSnapshot>;
|
|
63
|
+
export declare function addComment(request: ReviewWriteRequest, body: string, options?: {
|
|
64
|
+
currentSha256?: string | null;
|
|
65
|
+
now?: Date;
|
|
66
|
+
}): Promise<ReviewSnapshot>;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `review.json` — the human side of the on-disk contract.
|
|
3
|
+
*
|
|
4
|
+
* Reads mirror the macOS app exactly: version gate, run-id gate, then hash
|
|
5
|
+
* scoping. Writes mirror it too: sorted keys, second-precision UTC
|
|
6
|
+
* timestamps, uppercase UUID comment ids, run reset on a run-id change, and
|
|
7
|
+
* an atomic temp-file rename.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
export const REVIEW_FILE = "review.json";
|
|
13
|
+
export class UnsupportedReviewVersion extends Error {
|
|
14
|
+
constructor(version) {
|
|
15
|
+
super(`Unsupported review.json version ${String(version)}`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
export function emptyDocument() {
|
|
22
|
+
return { version: 1, reviews: {} };
|
|
23
|
+
}
|
|
24
|
+
/** Missing file → empty document. Malformed JSON → null (treated as unreadable). */
|
|
25
|
+
export async function readReviewDocument(directory) {
|
|
26
|
+
let raw;
|
|
27
|
+
try {
|
|
28
|
+
raw = await fs.promises.readFile(path.join(directory, REVIEW_FILE), "utf8");
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return emptyDocument();
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
return parseReviewDocument(raw);
|
|
36
|
+
}
|
|
37
|
+
export function parseReviewDocument(raw) {
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
if (!isRecord(parsed))
|
|
46
|
+
return null;
|
|
47
|
+
if (parsed.version !== 1)
|
|
48
|
+
throw new UnsupportedReviewVersion(parsed.version);
|
|
49
|
+
const reviews = {};
|
|
50
|
+
if (isRecord(parsed.reviews)) {
|
|
51
|
+
for (const [fileName, entry] of Object.entries(parsed.reviews)) {
|
|
52
|
+
if (!isRecord(entry))
|
|
53
|
+
continue;
|
|
54
|
+
const comments = Array.isArray(entry.comments)
|
|
55
|
+
? entry.comments.filter(isRecord).map((comment) => ({
|
|
56
|
+
id: String(comment.id ?? ""),
|
|
57
|
+
body: String(comment.body ?? ""),
|
|
58
|
+
created_at: String(comment.created_at ?? ""),
|
|
59
|
+
}))
|
|
60
|
+
: undefined;
|
|
61
|
+
reviews[fileName] = {
|
|
62
|
+
decision: typeof entry.decision === "string" ? entry.decision : undefined,
|
|
63
|
+
reviewed_at: typeof entry.reviewed_at === "string" ? entry.reviewed_at : undefined,
|
|
64
|
+
image_sha256: typeof entry.image_sha256 === "string" ? entry.image_sha256 : undefined,
|
|
65
|
+
comments,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
version: 1,
|
|
71
|
+
run_id: typeof parsed.run_id === "string" ? parsed.run_id : undefined,
|
|
72
|
+
updated_at: typeof parsed.updated_at === "string" ? parsed.updated_at : undefined,
|
|
73
|
+
reviews,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function sha256File(filePath) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const hash = createHash("sha256");
|
|
79
|
+
const stream = fs.createReadStream(filePath);
|
|
80
|
+
stream.on("error", reject);
|
|
81
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
82
|
+
stream.on("end", () => resolve(hash.digest("hex")));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
export function sha256Bytes(bytes) {
|
|
86
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
87
|
+
}
|
|
88
|
+
/** The entry that applies to `fileName`, or null when the run gate rejects it. */
|
|
89
|
+
export function scopedEntry(document, fileName, expectedRunId) {
|
|
90
|
+
if (expectedRunId !== null && document.run_id !== expectedRunId)
|
|
91
|
+
return null;
|
|
92
|
+
return document.reviews[fileName] ?? null;
|
|
93
|
+
}
|
|
94
|
+
/** Whether validating this entry needs the image's current hash. */
|
|
95
|
+
export function entryNeedsHash(entry) {
|
|
96
|
+
return Boolean(entry?.image_sha256);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The seen/stale truth table from the app's `ReviewSnapshot`:
|
|
100
|
+
* hash mismatch hides the decision but keeps comments and flags staleness.
|
|
101
|
+
*/
|
|
102
|
+
export function snapshotFromEntry(entry, currentSha256) {
|
|
103
|
+
const hashMatches = Boolean(entry?.image_sha256) && entry?.image_sha256 === currentSha256;
|
|
104
|
+
const decision = entry?.decision ?? null;
|
|
105
|
+
const effective = hashMatches ? decision : null;
|
|
106
|
+
return {
|
|
107
|
+
state: effective === "seen" || effective === "approved" ? "seen" : "pending",
|
|
108
|
+
decision,
|
|
109
|
+
hashMatches,
|
|
110
|
+
isStale: decision !== null && !hashMatches,
|
|
111
|
+
comments: (entry?.comments ?? []).map((comment) => ({
|
|
112
|
+
id: comment.id,
|
|
113
|
+
body: comment.body,
|
|
114
|
+
createdAt: comment.created_at,
|
|
115
|
+
})),
|
|
116
|
+
reviewedAt: entry?.reviewed_at ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
export function nowIso(date = new Date()) {
|
|
120
|
+
return date.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
121
|
+
}
|
|
122
|
+
export function newCommentId() {
|
|
123
|
+
return randomUUID().toUpperCase();
|
|
124
|
+
}
|
|
125
|
+
function sortKeys(value) {
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
return value.map(sortKeys);
|
|
128
|
+
if (isRecord(value)) {
|
|
129
|
+
const sorted = {};
|
|
130
|
+
for (const key of Object.keys(value).sort()) {
|
|
131
|
+
const inner = value[key];
|
|
132
|
+
if (inner !== undefined)
|
|
133
|
+
sorted[key] = sortKeys(inner);
|
|
134
|
+
}
|
|
135
|
+
return sorted;
|
|
136
|
+
}
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
export function serializeReviewDocument(document) {
|
|
140
|
+
return `${JSON.stringify(sortKeys(document), null, 2)}\n`;
|
|
141
|
+
}
|
|
142
|
+
export async function writeReviewDocument(directory, document) {
|
|
143
|
+
const target = path.join(directory, REVIEW_FILE);
|
|
144
|
+
const temp = path.join(directory, `.review.tmp.${randomUUID()}`);
|
|
145
|
+
await fs.promises.writeFile(temp, serializeReviewDocument(document), { flag: "wx" });
|
|
146
|
+
try {
|
|
147
|
+
await fs.promises.rename(temp, target);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
await fs.promises.rm(temp, { force: true });
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** A run-id change starts a fresh review map, exactly like the app. */
|
|
155
|
+
export function resetReviewsIfNeeded(document, runId) {
|
|
156
|
+
if (runId !== null && document.run_id !== runId) {
|
|
157
|
+
document.run_id = runId;
|
|
158
|
+
document.reviews = {};
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async function stampOf(filePath) {
|
|
162
|
+
try {
|
|
163
|
+
const stat = await fs.promises.stat(filePath);
|
|
164
|
+
return { mtimeMs: stat.mtimeMs, size: stat.size };
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function sameStamp(a, b) {
|
|
171
|
+
return a?.mtimeMs === b?.mtimeMs && a?.size === b?.size;
|
|
172
|
+
}
|
|
173
|
+
async function loadForWrite(directory) {
|
|
174
|
+
const stamp = await stampOf(path.join(directory, REVIEW_FILE));
|
|
175
|
+
const document = await readReviewDocument(directory);
|
|
176
|
+
if (document === null) {
|
|
177
|
+
throw new Error(`review.json in ${directory} is not valid JSON; refusing to overwrite it`);
|
|
178
|
+
}
|
|
179
|
+
return { document, stamp };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Read → mutate → write, re-reading when another writer (the macOS app, a
|
|
183
|
+
* second tray) changed the file in between so neither side's update is lost.
|
|
184
|
+
*/
|
|
185
|
+
async function mutateReviewDocument(directory, mutate) {
|
|
186
|
+
const target = path.join(directory, REVIEW_FILE);
|
|
187
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
188
|
+
const { document, stamp } = await loadForWrite(directory);
|
|
189
|
+
const result = await mutate(document);
|
|
190
|
+
if (!sameStamp(stamp, await stampOf(target)))
|
|
191
|
+
continue;
|
|
192
|
+
await writeReviewDocument(directory, document);
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
throw new Error(`review.json in ${directory} kept changing underneath this write; try again`);
|
|
196
|
+
}
|
|
197
|
+
function appendComment(entry, body, now) {
|
|
198
|
+
const trimmed = body.trim();
|
|
199
|
+
if (!trimmed)
|
|
200
|
+
throw new Error("Feedback cannot be empty");
|
|
201
|
+
const comment = { id: newCommentId(), body: trimmed, created_at: now };
|
|
202
|
+
entry.comments = [...(entry.comments ?? []), comment];
|
|
203
|
+
return { id: comment.id, body: comment.body, createdAt: comment.created_at };
|
|
204
|
+
}
|
|
205
|
+
export async function markSeen(request, options = {}) {
|
|
206
|
+
const sha = await sha256File(request.targetPath);
|
|
207
|
+
return mutateReviewDocument(request.directory, (document) => {
|
|
208
|
+
resetReviewsIfNeeded(document, request.runId);
|
|
209
|
+
const now = nowIso(options.now);
|
|
210
|
+
const entry = document.reviews[request.fileName] ?? {};
|
|
211
|
+
if (options.comment?.trim())
|
|
212
|
+
appendComment(entry, options.comment, now);
|
|
213
|
+
entry.decision = "seen";
|
|
214
|
+
entry.reviewed_at = now;
|
|
215
|
+
entry.image_sha256 = sha;
|
|
216
|
+
document.reviews[request.fileName] = entry;
|
|
217
|
+
document.updated_at = now;
|
|
218
|
+
return snapshotFromEntry(entry, sha);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
export async function addComment(request, body, options = {}) {
|
|
222
|
+
if (!body.trim())
|
|
223
|
+
throw new Error("Feedback cannot be empty");
|
|
224
|
+
const entry = await mutateReviewDocument(request.directory, (document) => {
|
|
225
|
+
resetReviewsIfNeeded(document, request.runId);
|
|
226
|
+
const now = nowIso(options.now);
|
|
227
|
+
const stored = document.reviews[request.fileName] ?? {};
|
|
228
|
+
appendComment(stored, body, now);
|
|
229
|
+
document.reviews[request.fileName] = stored;
|
|
230
|
+
document.updated_at = now;
|
|
231
|
+
return stored;
|
|
232
|
+
});
|
|
233
|
+
const sha = entry.image_sha256
|
|
234
|
+
? (options.currentSha256 ?? (await sha256File(request.targetPath)))
|
|
235
|
+
: null;
|
|
236
|
+
return snapshotFromEntry(entry, sha);
|
|
237
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { HashCache } from "./hash-cache.js";
|
|
2
|
+
import { type FeatureManifest } from "./manifest.js";
|
|
3
|
+
import type { AstroshotTree, Shot } from "./model.js";
|
|
4
|
+
import { type ReviewDocument } from "./review-store.js";
|
|
5
|
+
export interface FindOptions {
|
|
6
|
+
maxDepth?: number;
|
|
7
|
+
skip?: Set<string>;
|
|
8
|
+
concurrency?: number;
|
|
9
|
+
onFound?: (astroshotDir: string) => void;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
/** Breadth-first walk with bounded concurrency; never descends into a found tree. */
|
|
13
|
+
export declare function findAstroshotDirs(roots: string[], options?: FindOptions): Promise<string[]>;
|
|
14
|
+
export interface ShotContext {
|
|
15
|
+
worktreePath: string;
|
|
16
|
+
worktree: string;
|
|
17
|
+
feature: string;
|
|
18
|
+
featureDir: string;
|
|
19
|
+
}
|
|
20
|
+
interface FeatureListing {
|
|
21
|
+
files: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
export declare function buildShot(imagePath: string, context: ShotContext, manifest: FeatureManifest | null, review: ReviewDocument | null, listing: FeatureListing, hashes: HashCache): Promise<Shot | null>;
|
|
24
|
+
/** Every shot inside one feature directory, in directory order. */
|
|
25
|
+
export declare function scanFeatureDir(featureDir: string, context: {
|
|
26
|
+
worktreePath: string;
|
|
27
|
+
worktree: string;
|
|
28
|
+
}, hashes: HashCache): Promise<Shot[]>;
|
|
29
|
+
/** Re-read one shot in place (after its image or sidecars changed). */
|
|
30
|
+
export declare function rebuildShot(imagePath: string, context: ShotContext, hashes: HashCache): Promise<Shot | null>;
|
|
31
|
+
export declare function scanTree(astroshotDir: string, hashes: HashCache): Promise<AstroshotTree>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discover `.astroshot` trees under the watch roots and load their shots.
|
|
3
|
+
*/
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { loadFrictionLogs } from "./friction.js";
|
|
7
|
+
import { chaptersOf, matchManifestShot, parseFeatureStatus, parseIsoDate, readManifest, } from "./manifest.js";
|
|
8
|
+
import { ASTROSHOT_DIR, FRICTION_DIR, MAX_SCAN_DEPTH, SKIP_DIRECTORIES, VIDEO_EXTENSIONS, frictionLogsDir, humanize, isImageFile, sequenceAndSlug, worktreeShort, } from "./paths.js";
|
|
9
|
+
import { entryNeedsHash, readReviewDocument, scopedEntry, snapshotFromEntry, } from "./review-store.js";
|
|
10
|
+
/** Breadth-first walk with bounded concurrency; never descends into a found tree. */
|
|
11
|
+
export async function findAstroshotDirs(roots, options = {}) {
|
|
12
|
+
const maxDepth = options.maxDepth ?? MAX_SCAN_DEPTH;
|
|
13
|
+
const skip = options.skip ?? SKIP_DIRECTORIES;
|
|
14
|
+
const concurrency = options.concurrency ?? 16;
|
|
15
|
+
const found = [];
|
|
16
|
+
const queue = roots.map((root) => ({ dir: root, depth: 0 }));
|
|
17
|
+
let active = 0;
|
|
18
|
+
await new Promise((resolve) => {
|
|
19
|
+
const pump = () => {
|
|
20
|
+
if (options.signal?.aborted) {
|
|
21
|
+
if (active === 0)
|
|
22
|
+
resolve();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
while (active < concurrency && queue.length > 0) {
|
|
26
|
+
const item = queue.shift();
|
|
27
|
+
active += 1;
|
|
28
|
+
void visit(item.dir, item.depth).finally(() => {
|
|
29
|
+
active -= 1;
|
|
30
|
+
if (queue.length === 0 && active === 0)
|
|
31
|
+
resolve();
|
|
32
|
+
else
|
|
33
|
+
pump();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
if (queue.length === 0 && active === 0)
|
|
37
|
+
resolve();
|
|
38
|
+
};
|
|
39
|
+
const visit = async (dir, depth) => {
|
|
40
|
+
let entries;
|
|
41
|
+
try {
|
|
42
|
+
entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
const name = entry.name;
|
|
49
|
+
// Follow a linked `.astroshot` itself, but never descend through other
|
|
50
|
+
// symlinks: they can loop and are rarely where captures live.
|
|
51
|
+
if (!entry.isDirectory() && !(name === ASTROSHOT_DIR && (await kindOf(dir, entry)) === "dir"))
|
|
52
|
+
continue;
|
|
53
|
+
if (name === ASTROSHOT_DIR) {
|
|
54
|
+
const astroshotDir = path.join(dir, name);
|
|
55
|
+
found.push(astroshotDir);
|
|
56
|
+
options.onFound?.(astroshotDir);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (name.startsWith(".") || skip.has(name))
|
|
60
|
+
continue;
|
|
61
|
+
if (depth + 1 > maxDepth)
|
|
62
|
+
continue;
|
|
63
|
+
queue.push({ dir: path.join(dir, name), depth: depth + 1 });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
pump();
|
|
67
|
+
});
|
|
68
|
+
return found.sort();
|
|
69
|
+
}
|
|
70
|
+
/** Dirent kinds with symlinks resolved, so linked trees behave like real ones. */
|
|
71
|
+
async function kindOf(parent, entry) {
|
|
72
|
+
if (entry.isFile())
|
|
73
|
+
return "file";
|
|
74
|
+
if (entry.isDirectory())
|
|
75
|
+
return "dir";
|
|
76
|
+
if (!entry.isSymbolicLink())
|
|
77
|
+
return "other";
|
|
78
|
+
try {
|
|
79
|
+
const stat = await fs.promises.stat(path.join(parent, entry.name));
|
|
80
|
+
return stat.isFile() ? "file" : stat.isDirectory() ? "dir" : "other";
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return "other";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function listFeature(featureDir) {
|
|
87
|
+
try {
|
|
88
|
+
const entries = await fs.promises.readdir(featureDir, { withFileTypes: true });
|
|
89
|
+
const files = new Set();
|
|
90
|
+
for (const entry of entries) {
|
|
91
|
+
if ((await kindOf(featureDir, entry)) === "file")
|
|
92
|
+
files.add(entry.name);
|
|
93
|
+
}
|
|
94
|
+
return { files };
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function resolveVideo(entryVideo, fileName, files) {
|
|
101
|
+
if (entryVideo && entryVideo.trim())
|
|
102
|
+
return entryVideo;
|
|
103
|
+
const stem = fileName.replace(/\.[^.]+$/, "");
|
|
104
|
+
for (const extension of VIDEO_EXTENSIONS) {
|
|
105
|
+
const candidate = `${stem}.${extension}`;
|
|
106
|
+
if (files.has(candidate))
|
|
107
|
+
return candidate;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
async function reviewFor(document, fileName, runId, imagePath, stat, hashes) {
|
|
112
|
+
if (!document)
|
|
113
|
+
return null;
|
|
114
|
+
const entry = scopedEntry(document, fileName, runId);
|
|
115
|
+
let sha = null;
|
|
116
|
+
if (entryNeedsHash(entry)) {
|
|
117
|
+
try {
|
|
118
|
+
sha = await hashes.hash(imagePath, stat);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
sha = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return snapshotFromEntry(entry, sha);
|
|
125
|
+
}
|
|
126
|
+
export async function buildShot(imagePath, context, manifest, review, listing, hashes) {
|
|
127
|
+
let stat;
|
|
128
|
+
try {
|
|
129
|
+
stat = await fs.promises.stat(imagePath);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
const fileName = path.basename(imagePath);
|
|
135
|
+
const entry = matchManifestShot(manifest, fileName);
|
|
136
|
+
const parsed = sequenceAndSlug(fileName);
|
|
137
|
+
const slug = entry?.slug ?? parsed.slug;
|
|
138
|
+
const runId = manifest?.run_id ?? null;
|
|
139
|
+
const videoFileName = resolveVideo(entry?.video, fileName, listing.files);
|
|
140
|
+
const videoExists = videoFileName !== null && listing.files.has(videoFileName);
|
|
141
|
+
const durationMs = typeof entry?.duration_ms === "number" && entry.duration_ms > 0 ? entry.duration_ms : null;
|
|
142
|
+
return {
|
|
143
|
+
id: imagePath,
|
|
144
|
+
path: imagePath,
|
|
145
|
+
fileName,
|
|
146
|
+
worktreePath: context.worktreePath,
|
|
147
|
+
worktree: context.worktree,
|
|
148
|
+
worktreeShort: worktreeShort(context.worktree),
|
|
149
|
+
feature: context.feature,
|
|
150
|
+
featureDir: context.featureDir,
|
|
151
|
+
sequence: parsed.sequence ?? entry?.id ?? null,
|
|
152
|
+
slug,
|
|
153
|
+
title: entry?.title ?? humanize(slug),
|
|
154
|
+
description: entry?.description ?? "",
|
|
155
|
+
url: entry?.url ?? null,
|
|
156
|
+
runId,
|
|
157
|
+
status: parseFeatureStatus(manifest?.status),
|
|
158
|
+
capturedAt: parseIsoDate(entry?.captured_at) ?? stat.mtimeMs,
|
|
159
|
+
mtimeMs: stat.mtimeMs,
|
|
160
|
+
isMovie: (entry?.kind ?? "").toLowerCase() === "movie" || videoFileName !== null,
|
|
161
|
+
videoFileName,
|
|
162
|
+
videoPath: videoExists ? path.join(context.featureDir, videoFileName) : null,
|
|
163
|
+
durationMs,
|
|
164
|
+
source: entry?.source ?? null,
|
|
165
|
+
chapters: chaptersOf(entry),
|
|
166
|
+
review: await reviewFor(review, fileName, runId, imagePath, stat, hashes),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function readReviewSafely(directory) {
|
|
170
|
+
try {
|
|
171
|
+
return await readReviewDocument(directory);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Every shot inside one feature directory, in directory order. */
|
|
178
|
+
export async function scanFeatureDir(featureDir, context, hashes) {
|
|
179
|
+
const listing = await listFeature(featureDir);
|
|
180
|
+
if (!listing)
|
|
181
|
+
return [];
|
|
182
|
+
const feature = path.basename(featureDir);
|
|
183
|
+
const [manifest, review] = await Promise.all([readManifest(featureDir), readReviewSafely(featureDir)]);
|
|
184
|
+
const shotContext = { ...context, feature, featureDir };
|
|
185
|
+
const shots = [];
|
|
186
|
+
for (const name of listing.files) {
|
|
187
|
+
if (!isImageFile(name))
|
|
188
|
+
continue;
|
|
189
|
+
const shot = await buildShot(path.join(featureDir, name), shotContext, manifest, review, listing, hashes);
|
|
190
|
+
if (shot)
|
|
191
|
+
shots.push(shot);
|
|
192
|
+
}
|
|
193
|
+
return shots;
|
|
194
|
+
}
|
|
195
|
+
/** Re-read one shot in place (after its image or sidecars changed). */
|
|
196
|
+
export async function rebuildShot(imagePath, context, hashes) {
|
|
197
|
+
const listing = await listFeature(context.featureDir);
|
|
198
|
+
if (!listing)
|
|
199
|
+
return null;
|
|
200
|
+
const [manifest, review] = await Promise.all([
|
|
201
|
+
readManifest(context.featureDir),
|
|
202
|
+
readReviewSafely(context.featureDir),
|
|
203
|
+
]);
|
|
204
|
+
return buildShot(imagePath, context, manifest, review, listing, hashes);
|
|
205
|
+
}
|
|
206
|
+
export async function scanTree(astroshotDir, hashes) {
|
|
207
|
+
const worktreePath = path.dirname(astroshotDir);
|
|
208
|
+
const worktree = path.basename(worktreePath);
|
|
209
|
+
const context = { worktreePath, worktree };
|
|
210
|
+
let entries = [];
|
|
211
|
+
try {
|
|
212
|
+
entries = await fs.promises.readdir(astroshotDir, { withFileTypes: true });
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
entries = [];
|
|
216
|
+
}
|
|
217
|
+
const shots = [];
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
if (entry.name.startsWith(".") || entry.name === FRICTION_DIR)
|
|
220
|
+
continue;
|
|
221
|
+
if ((await kindOf(astroshotDir, entry)) !== "dir")
|
|
222
|
+
continue;
|
|
223
|
+
shots.push(...(await scanFeatureDir(path.join(astroshotDir, entry.name), context, hashes)));
|
|
224
|
+
}
|
|
225
|
+
const frictionLogs = await loadFrictionLogs(frictionLogsDir(astroshotDir), context, hashes);
|
|
226
|
+
return { astroshotDir, worktreePath, worktree, shots, frictionLogs };
|
|
227
|
+
}
|